From fe00dfbd562e3639fbb3294b77401b86acd3dee2 Mon Sep 17 00:00:00 2001 From: Robert Hensing Date: Thu, 13 Mar 2025 20:19:21 +0000 Subject: [PATCH] nix-cli: Add --json --pretty / --no-pretty Default: istty(stdout) This refactors `nix develop` internals a bit to use the `json` type more. The assertion now operates in the in-memory json instead of re-parsing it. While this is technically a weaker guarantee, we should be able to rely on the library to get this right. It's its most essential purpose. --- src/libmain/common-args.cc | 17 ++++++++- src/libmain/common-args.hh | 61 ++++++++++++++++++++++++++++++- src/nix/build.cc | 2 +- src/nix/config.cc | 2 +- src/nix/derivation-show.cc | 4 +- src/nix/develop.cc | 23 +++++++----- src/nix/eval.cc | 2 +- src/nix/flake.cc | 8 ++-- src/nix/make-content-addressed.cc | 2 +- src/nix/path-info.cc | 4 +- src/nix/prefetch.cc | 2 +- src/nix/profile.cc | 2 +- src/nix/realisation.cc | 2 +- src/nix/search.cc | 2 +- src/nix/store-info.cc | 2 +- tests/functional/json.sh | 44 ++++++++++++++++++++++ tests/functional/meson.build | 1 + 17 files changed, 151 insertions(+), 29 deletions(-) create mode 100644 tests/functional/json.sh diff --git a/src/libmain/common-args.cc b/src/libmain/common-args.cc index 13d358623..0e347d26f 100644 --- a/src/libmain/common-args.cc +++ b/src/libmain/common-args.cc @@ -1,3 +1,5 @@ +#include + #include "common-args.hh" #include "args/root.hh" #include "config-global.hh" @@ -93,5 +95,18 @@ void MixCommonArgs::initialFlagsProcessed() pluginsInited(); } - +template +void MixPrintJSON::printJSON(const T /* nlohmann::json */ & json) +{ + auto suspension = logger->suspend(); + if (outputPretty) { + logger->writeToStdout(json.dump(2)); + } else { + logger->writeToStdout(json.dump()); + } } + +template void MixPrintJSON::printJSON(const nlohmann::json & json); + + +} // namespace nix diff --git a/src/libmain/common-args.hh b/src/libmain/common-args.hh index c35406c3b..f4e62fe11 100644 --- a/src/libmain/common-args.hh +++ b/src/libmain/common-args.hh @@ -35,7 +35,66 @@ struct MixDryRun : virtual Args } }; -struct MixJSON : virtual Args +/** + * Commands that can print JSON according to the + * `--pretty`/`--no-pretty` flag. + * + * This is distinct from MixJSON, because for some commands, + * JSON outputs is not optional. + */ +struct MixPrintJSON : virtual Args +{ + bool outputPretty = isatty(STDOUT_FILENO); + + MixPrintJSON() + { + addFlag({ + .longName = "pretty", + .description = + R"( + Print multi-line, indented JSON output for readability. + + Default: indent if output is to a terminal. + + This option is only effective when `--json` is also specified. + )", + //.category = commonArgsCategory, + .handler = {&outputPretty, true}, + }); + addFlag({ + .longName = "no-pretty", + .description = + R"( + Print compact JSON output on a single line, even when the output is a terminal. + Some commands may print multiple JSON objects on separate lines. + + See `--pretty`. + )", + //.category = commonArgsCategory, + .handler = {&outputPretty, false}, + }); + }; + + /** + * Print an `nlohmann::json` to stdout + * + * - respecting `--pretty` / `--no-pretty`. + * - suspending the progress bar + * + * This is a template to avoid accidental coercions from `string` to `json` in the caller, + * to avoid mistakenly passing an already serialized JSON to this function. + * + * It is not recommended to print a JSON string - see the JSON guidelines + * about extensibility, https://nix.dev/manual/nix/development/development/json-guideline.html - + * but you _can_ print a sole JSON string by explicitly coercing it to + * `nlohmann::json` first. + */ + template >> + void printJSON(const T & json); +}; + +/** Optional JSON support via `--json` flag */ +struct MixJSON : virtual Args, virtual MixPrintJSON { bool json = false; diff --git a/src/nix/build.cc b/src/nix/build.cc index 4ba6241ec..120facf59 100644 --- a/src/nix/build.cc +++ b/src/nix/build.cc @@ -101,7 +101,7 @@ struct CmdBuild : InstallablesCommand, MixDryRun, MixJSON, MixProfile printMissing(store, pathsToBuild, lvlError); if (json) - logger->cout("%s", derivedPathsToJSON(pathsToBuild, *store).dump()); + printJSON(derivedPathsToJSON(pathsToBuild, *store)); return; } diff --git a/src/nix/config.cc b/src/nix/config.cc index 07f975a00..d77f5db69 100644 --- a/src/nix/config.cc +++ b/src/nix/config.cc @@ -63,7 +63,7 @@ struct CmdConfigShow : Command, MixJSON if (json) { // FIXME: use appropriate JSON types (bool, ints, etc). - logger->cout("%s", globalConfig.toJSON().dump()); + printJSON(globalConfig.toJSON()); } else { logger->cout("%s", globalConfig.toKeyValue()); } diff --git a/src/nix/derivation-show.cc b/src/nix/derivation-show.cc index 5a07f58e6..f0b9390fb 100644 --- a/src/nix/derivation-show.cc +++ b/src/nix/derivation-show.cc @@ -11,7 +11,7 @@ using namespace nix; using json = nlohmann::json; -struct CmdShowDerivation : InstallablesCommand +struct CmdShowDerivation : InstallablesCommand, MixPrintJSON { bool recursive = false; @@ -57,7 +57,7 @@ struct CmdShowDerivation : InstallablesCommand jsonRoot[store->printStorePath(drvPath)] = store->readDerivation(drvPath).toJSON(*store); } - logger->cout(jsonRoot.dump(2)); + printJSON(jsonRoot); } }; diff --git a/src/nix/develop.cc b/src/nix/develop.cc index 961962ebd..ca3bfc50d 100644 --- a/src/nix/develop.cc +++ b/src/nix/develop.cc @@ -63,14 +63,12 @@ struct BuildEnvironment std::map bashFunctions; std::optional> structuredAttrs; - static BuildEnvironment fromJSON(std::string_view in) + static BuildEnvironment fromJSON(const nlohmann::json & json) { BuildEnvironment res; std::set exported; - auto json = nlohmann::json::parse(in); - for (auto & [name, info] : json["variables"].items()) { std::string type = info["type"]; if (type == "var" || type == "exported") @@ -92,7 +90,14 @@ struct BuildEnvironment return res; } - std::string toJSON() const + static BuildEnvironment parseJSON(std::string_view in) + { + auto json = nlohmann::json::parse(in); + + return fromJSON(json); + } + + nlohmann::json toJSON() const { auto res = nlohmann::json::object(); @@ -124,11 +129,9 @@ struct BuildEnvironment res["structuredAttrs"] = std::move(contents); } - auto json = res.dump(); + assert(BuildEnvironment::fromJSON(res) == *this); - assert(BuildEnvironment::fromJSON(json) == *this); - - return json; + return res; } bool providesStructuredAttrs() const @@ -505,7 +508,7 @@ struct Common : InstallableCommand, MixProfile debug("reading environment file '%s'", strPath); - return {BuildEnvironment::fromJSON(readFile(store->toRealPath(shellOutPath))), strPath}; + return {BuildEnvironment::parseJSON(readFile(store->toRealPath(shellOutPath))), strPath}; } }; @@ -733,7 +736,7 @@ struct CmdPrintDevEnv : Common, MixJSON logger->stop(); if (json) { - logger->writeToStdout(buildEnvironment.toJSON()); + printJSON(buildEnvironment.toJSON()); } else { AutoDelete tmpDir(createTempDir("", "nix-dev-env"), true); logger->writeToStdout(makeRcScript(store, buildEnvironment, tmpDir)); diff --git a/src/nix/eval.cc b/src/nix/eval.cc index e038d75c3..b88588081 100644 --- a/src/nix/eval.cc +++ b/src/nix/eval.cc @@ -118,7 +118,7 @@ struct CmdEval : MixJSON, InstallableValueCommand, MixReadOnlyOption } else if (json) { - logger->cout("%s", printValueAsJSON(*state, true, *v, pos, context, false)); + printJSON(printValueAsJSON(*state, true, *v, pos, context, false)); } else { diff --git a/src/nix/flake.cc b/src/nix/flake.cc index e2099c401..2eddfdbbb 100644 --- a/src/nix/flake.cc +++ b/src/nix/flake.cc @@ -242,7 +242,7 @@ struct CmdFlakeMetadata : FlakeCommand, MixJSON j["locks"] = lockedFlake.lockFile.toJSON().first; if (auto fingerprint = lockedFlake.getFingerprint(store, fetchSettings)) j["fingerprint"] = fingerprint->to_string(HashFormat::Base16, false); - logger->cout("%s", j.dump()); + printJSON(j); } else { logger->cout( ANSI_BOLD "Resolved URL:" ANSI_NORMAL " %s", @@ -1115,7 +1115,7 @@ struct CmdFlakeArchive : FlakeCommand, MixJSON, MixDryRun {"path", store->printStorePath(storePath)}, {"inputs", traverse(*flake.lockFile.root)}, }; - logger->cout("%s", jsonRoot); + printJSON(jsonRoot); } else { traverse(*flake.lockFile.root); } @@ -1427,7 +1427,7 @@ struct CmdFlakeShow : FlakeCommand, MixJSON auto j = visit(*cache->getRoot(), {}, fmt(ANSI_BOLD "%s" ANSI_NORMAL, flake->flake.lockedRef), ""); if (json) - logger->cout("%s", j.dump()); + printJSON(j); } }; @@ -1473,7 +1473,7 @@ struct CmdFlakePrefetch : FlakeCommand, MixJSON res["hash"] = hash.to_string(HashFormat::SRI, true); res["original"] = fetchers::attrsToJSON(resolvedRef.toAttrs()); res["locked"] = fetchers::attrsToJSON(lockedRef.toAttrs()); - logger->cout(res.dump()); + printJSON(res); } else { notice("Downloaded '%s' to '%s' (hash '%s').", lockedRef.to_string(), diff --git a/src/nix/make-content-addressed.cc b/src/nix/make-content-addressed.cc index d9c988a9f..2d58377ed 100644 --- a/src/nix/make-content-addressed.cc +++ b/src/nix/make-content-addressed.cc @@ -44,7 +44,7 @@ struct CmdMakeContentAddressed : virtual CopyCommand, virtual StorePathsCommand, } auto json = json::object(); json["rewrites"] = jsonRewrites; - logger->cout("%s", json); + printJSON(json); } else { for (auto & path : storePaths) { auto i = remappings.find(path); diff --git a/src/nix/path-info.cc b/src/nix/path-info.cc index 8e3d0406d..3246d3751 100644 --- a/src/nix/path-info.cc +++ b/src/nix/path-info.cc @@ -154,11 +154,11 @@ struct CmdPathInfo : StorePathsCommand, MixJSON pathLen = std::max(pathLen, store->printStorePath(storePath).size()); if (json) { - logger->cout(pathInfoToJSON( + printJSON(pathInfoToJSON( *store, // FIXME: preserve order? StorePathSet(storePaths.begin(), storePaths.end()), - showClosureSize).dump()); + showClosureSize)); } else { diff --git a/src/nix/prefetch.cc b/src/nix/prefetch.cc index ba2fd39d8..8467a4e89 100644 --- a/src/nix/prefetch.cc +++ b/src/nix/prefetch.cc @@ -326,7 +326,7 @@ struct CmdStorePrefetchFile : StoreCommand, MixJSON auto res = nlohmann::json::object(); res["storePath"] = store->printStorePath(storePath); res["hash"] = hash.to_string(HashFormat::SRI, true); - logger->cout(res.dump()); + printJSON(res); } else { notice("Downloaded '%s' to '%s' (hash '%s').", url, diff --git a/src/nix/profile.cc b/src/nix/profile.cc index 324fd6330..f10d06747 100644 --- a/src/nix/profile.cc +++ b/src/nix/profile.cc @@ -802,7 +802,7 @@ struct CmdProfileList : virtual EvalCommand, virtual StoreCommand, MixDefaultPro ProfileManifest manifest(*getEvalState(), *profile); if (json) { - std::cout << manifest.toJSON(*store).dump() << "\n"; + printJSON(manifest.toJSON(*store)); } else { for (const auto & [i, e] : enumerate(manifest.elements)) { auto & [name, element] = e; diff --git a/src/nix/realisation.cc b/src/nix/realisation.cc index a386d98ea..e4b42510f 100644 --- a/src/nix/realisation.cc +++ b/src/nix/realisation.cc @@ -57,7 +57,7 @@ struct CmdRealisationInfo : BuiltPathsCommand, MixJSON res.push_back(currentPath); } - logger->cout("%s", res); + printJSON(res); } else { for (auto & path : realisations) { diff --git a/src/nix/search.cc b/src/nix/search.cc index 30b96c500..9975907de 100644 --- a/src/nix/search.cc +++ b/src/nix/search.cc @@ -198,7 +198,7 @@ struct CmdSearch : InstallableValueCommand, MixJSON visit(*cursor, cursor->getAttrPath(), true); if (json) - logger->cout("%s", *jsonOut); + printJSON(*jsonOut); if (!json && !results) throw Error("no results for the given search term(s)!"); diff --git a/src/nix/store-info.cc b/src/nix/store-info.cc index a7c595761..8d20a15cb 100644 --- a/src/nix/store-info.cc +++ b/src/nix/store-info.cc @@ -33,7 +33,7 @@ struct CmdPingStore : StoreCommand, MixJSON } else { nlohmann::json res; Finally printRes([&]() { - logger->cout("%s", res); + printJSON(res); }); res["url"] = store->getUri(); diff --git a/tests/functional/json.sh b/tests/functional/json.sh new file mode 100644 index 000000000..9cc7c6ec1 --- /dev/null +++ b/tests/functional/json.sh @@ -0,0 +1,44 @@ +#!/usr/bin/env bash + +source common.sh + +# Meson would split the output into two buffers, ruining the coherence of the log. +exec 1>&2 + +cat > "$TEST_HOME/expected-machine.json" < "$TEST_HOME/expected-pretty.json" < "$TEST_HOME/actual.json" +diff -U3 "$TEST_HOME/expected-machine.json" "$TEST_HOME/actual.json" + +nix eval --json --pretty --expr \ + '{ a.b.c = true; }' > "$TEST_HOME/actual.json" +diff -U3 "$TEST_HOME/expected-pretty.json" "$TEST_HOME/actual.json" + +if type script &>/dev/null; then + script --return --quiet --command 'nix eval --json --expr "{ a.b.c = true; }"' > "$TEST_HOME/actual.json" + cat "$TEST_HOME/actual.json" + # script isn't perfectly accurate? Let's grep for a pretty good indication, as the pretty output has a space between the key and the value. + # diff -U3 "$TEST_HOME/expected-pretty.json" "$TEST_HOME/actual.json" + grep -F '"a": {' "$TEST_HOME/actual.json" + + script --return --quiet --command 'nix eval --json --pretty --expr "{ a.b.c = true; }"' > "$TEST_HOME/actual.json" + cat "$TEST_HOME/actual.json" + grep -F '"a": {' "$TEST_HOME/actual.json" + + script --return --quiet --command 'nix eval --json --no-pretty --expr "{ a.b.c = true; }"' > "$TEST_HOME/actual.json" + cat "$TEST_HOME/actual.json" + grep -F '"a":{' "$TEST_HOME/actual.json" + +fi \ No newline at end of file diff --git a/tests/functional/meson.build b/tests/functional/meson.build index af95879fb..f2d6a64ea 100644 --- a/tests/functional/meson.build +++ b/tests/functional/meson.build @@ -157,6 +157,7 @@ suites = [ 'impure-derivations.sh', 'path-from-hash-part.sh', 'path-info.sh', + 'json.sh', 'toString-path.sh', 'read-only-store.sh', 'nested-sandboxing.sh',