Commit 7c8e4226 authored by FireFly's avatar FireFly Committed by Thiago Kenji Okada
Browse files

nixos-option: rewrite as a nix script



This ports the functionality of the C++ nixos-option to a nix script
(with a tiny shellscript for argument processing and invoking the nix
script). Benefits compared to the native binary include no longer being
tied to a specific nix version, generally improved maintainability and
improved stability.

The main tradeoff is that the C++ version would have better access to
introspecting and reporting errors nicely, but that doesn't seem to have
been the case in practice anyway.  The other tradeoff is that we
generate all the output at the end instead of streaming it as we
traverse the option tree.

Co-authored-by: default avatarZhong Jianxin <azuwis@gmail.com>
Co-authored-by: default avataraleksana <me@aleksana.moe>
Co-authored-by: default avatareclairevoyant <contactmeongithubinstead@proton.me>
parent 1c5eed9e
Loading
Loading
Loading
Loading
+56 −26
Original line number Diff line number Diff line
{
  lib,
  stdenv,
  boost,
  meson,
  ninja,
  pkg-config,
  makeWrapper,
  stdenvNoCC,
  installShellFiles,
  shellcheck,
  nix,
  nixosTests,
  jq,
  man-db,
  coreutils,
}:

stdenv.mkDerivation {
stdenvNoCC.mkDerivation {
  name = "nixos-option";

  src = ./.;

  postInstall = ''
    installManPage ../nixos-option.8
  '';

  strictDeps = true;
  src = ./nixos-option.sh;

  nativeBuildInputs = [
    meson
    ninja
    pkg-config
    installShellFiles
    makeWrapper
    shellcheck
  ];
  buildInputs = [
    boost
    nix
  ];

  passthru.tests.installer-simpleUefiSystemdBoot = nixosTests.installer.simpleUefiSystemdBoot;
  env = {
    nixosOptionNix = "${./nixos-option.nix}";
    nixosOptionManpage = "${placeholder "out"}/share/man";
  };

  dontUnpack = true;
  dontConfigure = true;
  dontPatch = true;
  dontBuild = true;

  installPhase = ''
    runHook preInstall

    install -Dm555 $src $out/bin/nixos-option
    substituteAllInPlace $out/bin/nixos-option
    installManPage ${./nixos-option.8}

  meta = with lib; {
    license = licenses.lgpl2Plus;
    runHook postInstall
  '';

  doInstallCheck = true;
  installCheckPhase = ''
    runHook preInstallCheck
    shellcheck $out/bin/nixos-option
    runHook postInstallCheck
  '';

  postFixup = ''
    wrapProgram $out/bin/nixos-option \
      --prefix PATH : ${
        lib.makeBinPath [
          nix
          jq
          man-db
          coreutils
        ]
      }
  '';

  meta = {
    description = "Evaluate NixOS configuration and return the properties of given option";
    license = lib.licenses.mit;
    mainProgram = "nixos-option";
    maintainers = [ ];
    inherit (nix.meta) platforms;
    maintainers = with lib.maintainers; [
      FireyFly
      azuwis
      aleksana
    ];
  };
}
+0 −15
Original line number Diff line number Diff line
project('nixos-option', 'cpp',
  version : '0.1.6',
  license : 'GPL-3.0',
)

nix_main_dep = dependency('nix-main', required: true)
nix_store_dep = dependency('nix-store', required: true)
nix_expr_dep = dependency('nix-expr', required: true)
nix_cmd_dep = dependency('nix-cmd', required: true)
nix_flake_dep = dependency('nix-flake', required: true)
threads_dep = dependency('threads', required: true)
nlohmann_json_dep = dependency('nlohmann_json', required: true)
boost_dep = dependency('boost', required: true)

subdir('src')
+167 −0
Original line number Diff line number Diff line
{
  nixos ? import <nixpkgs/nixos> { },
  # list representing a nixos option path (e.g. ['console' 'enable']), or a
  # prefix of such a path (e.g. ['console']), or a string representing the same
  # (e.g. 'console.enable')
  path,
  # whether to recurse down the config attrset and show each set value instead
  recursive ? false,
}:

let
  inherit (nixos.pkgs) lib;

  path' = if lib.isString path then (if path == "" then [ ] else readOption path) else path;

  # helper that maps `f` on subslices starting when `predStart x` and
  # ending when `predEnd x` (no support for nested occurrences)
  flatMapSlices =
    predStart: predEnd: f: list:
    let
      empty = {
        result = [ ];
        active = [ ];
      };
      op =
        { result, active }:
        x:
        if predStart x && predEnd x then
          {
            result = result ++ active ++ f [ x ];
            active = [ ];
          }
        else if predStart x then
          {
            result = result ++ active;
            active = [ x ];
          }
        else if predEnd x then
          {
            result = result ++ f (active ++ [ x ]);
            active = [ ];
          }
        else
          {
            inherit result;
            active = active ++ [ x ];
          };
    in
    (x: x.result ++ x.active) (lib.foldl op empty list);

  # tries to invert showOption, taking a written-out option name and splitting
  # it into its parts
  readOption =
    str:
    let
      unescape = list: lib.replaceStrings (map (c: "\\${c}") list) list;
      unescapeNixString = lib.flip lib.pipe [
        (lib.concatStringsSep ".")
        (unescape [ "$" ])
        builtins.fromJSON
      ];
    in
    flatMapSlices (lib.hasPrefix "\"") (lib.hasSuffix "\"") (x: [ (unescapeNixString x) ]) (
      lib.splitString "." str
    );

  # like 'mapAttrsRecursiveCond' but handling errors in the attrset tree as leaf
  # nodes (which means `f` is expected to handle shallow errors)
  safeMapAttrsRecursiveCond =
    cond: f: set:
    let
      recurse =
        path:
        lib.mapAttrs (
          name: value:
          let
            e = builtins.tryEval value;
            path' = path ++ [ name ];
          in
          if e.success && lib.isAttrs value && cond value then recurse path' value else f path' value
        );
    in
    recurse [ ] set;

  # traverse the option tree along `path` from `root`, returning the option or
  # attrset at the given location
  optionByPath =
    path: root:
    let
      into =
        opt: part:
        if lib.isOption opt && opt.type.descriptionClass == "composite" then
          opt.type.getSubOptions [ ]
        else if lib.isOption opt then
          throw "Trying to access '${part}' inside ${opt.type.name} option while traversing option path '${lib.showOption path}'"
        else if lib.isAttrs opt && lib.hasAttr part opt then
          opt.${part}
        else
          throw "Found neither an attrset nor supported option type near '${part}' while traversing option path '${lib.showOption path}'";
    in
    lib.foldl into root path;

  toPretty = lib.generators.toPretty { multiline = true; };
  safeToPretty =
    x:
    let
      e = builtins.tryEval (toPretty x);
    in
    if e.success then e.value else "«error»";

  indent = str: lib.concatStringsSep "\n" (map (x: "  " + x) (lib.splitString "\n" str));

  optionAttrNames = attrs: lib.filter (x: x != "_module") (lib.attrNames attrs);

  ## full, non-recursive mode: print an option from `options`
  renderAttrs =
    attrs: "This attribute set contains:\n${lib.concatStringsSep "\n" (optionAttrNames attrs)}";

  renderOption =
    option: value:
    let
      entry =
        cond: heading: value:
        lib.optional cond "${heading}:\n${indent value}";
    in
    lib.concatStringsSep "\n\n" (
      lib.concatLists [
        (entry true "Value" (toPretty value))
        (entry (option ? default) "Default" (toPretty option.default))
        (entry (option ? type) "Type" (option.type.description))
        (entry (option ? description) "Description" (lib.removeSuffix "\n" option.description))
        (entry (option ? example) "Example" (toPretty option.example))
        (entry (option ? declarations) "Declared by" (lib.concatStringsSep "\n" option.declarations))
        (entry (option ? files) "Defined by" (lib.concatStringsSep "\n" option.files))
      ]
    );

  renderFull =
    entry: configEntry:
    if lib.isOption entry then
      renderOption entry configEntry
    else if lib.isAttrs entry then
      renderAttrs entry
    else
      throw "Found neither an attrset nor option at option path '${lib.showOption path'}'";

  ## recursive mode: print paths and values from `config`
  renderRecursive =
    config:
    let
      renderShort = n: v: "${lib.showOption (path' ++ n)} = ${safeToPretty v};";
      mapAttrsRecursive' = safeMapAttrsRecursiveCond (x: !lib.isDerivation x);
    in
    if lib.isAttrs config then
      lib.concatStringsSep "\n" (lib.collect lib.isString (mapAttrsRecursive' renderShort config))
    else
      renderShort [ ] config;

in
if !lib.hasAttrByPath path' nixos.config then
  throw "Couldn't resolve config path '${lib.showOption path'}'"
else
  let
    optionEntry = optionByPath path' nixos.options;
    configEntry = lib.attrByPath path' null nixos.config;
  in
  if recursive then renderRecursive configEntry else renderFull optionEntry configEntry
+102 −0
Original line number Diff line number Diff line
#!/bin/bash
# shellcheck shell=bash
set -eou pipefail

recursive=false
no_flake=false
positional_args=()
nix_args=()
flake=""

while [[ $# -gt 0 ]]; do
  case "$1" in
    --help)
      exec man -M @nixosOptionManpage@ 8 nixos-option
      ;;

    -r|--recursive)
      recursive=true
      shift
      ;;

    -I)
      nix_args+=("$1" "$2")
      # Not breaking existing usages of it
      no_flake=true
      shift 2
      ;;

    -F|--flake)
      flake=$2
      shift 2
      ;;

    --no-flake)
      no_flake=true
      shift
      ;;

    --show-trace)
      nix_args+=("$1")
      shift
      ;;

    -*)
      echo >&2 "Unsupported option $1"
      exit 1
      ;;

    *)
      positional_args+=("$1")
      shift
      ;;
  esac
done

# Detection order, from high to low:
# `--flake`
# $NIXOS_CONFIG
# nixos-config=<path> in NIX_PATH (normally /etc/nixos/configuration.nix)
# `-I` (implies `--no-flake`)
# `--no-flake`
# /etc/nixos/flake.nix (if exists)

if [[ -z "$flake" ]] && [[ -e /etc/nixos/flake.nix ]] && [[ "$no_flake" == "false" ]]; then
  flake="$(dirname "$(realpath /etc/nixos/flake.nix)")"
fi

if [[ -n "$flake" ]]; then
  if [[ $flake =~ ^(.*)\#([^\#\"]*)$ ]]; then
    flake="${BASH_REMATCH[1]}"
    flakeAttr="${BASH_REMATCH[2]}"
  fi
  # Unlike nix cli, builtins.getFlake infer path:// when a path is given
  # See https://github.com/NixOS/nix/issues/5836
  if [[ -d "$flake" ]] && [[ -e "$flake/.git" ]] ; then
    flake="git+file://$(realpath "$flake")"
  fi
  if [[ -z "${flakeAttr:-}" ]]; then
    hostname=$(< /proc/sys/kernel/hostname)
    if [[ -z "${hostname:-}" ]]; then
      hostname=default
    fi
    flakeAttr="nixosConfigurations.\"$hostname\""
  else
    flakeAttr="nixosConfigurations.\"$flakeAttr\""
  fi
  nix_args+=(--arg nixos "(builtins.getFlake \"$flake\").$flakeAttr")
fi

case ${#positional_args[@]} in
  0) path= ;;
  # Remove trailing dot if exists, match the behavior of
  # old nixos-option and make shell completions happy
  1) path="${positional_args[0]%.}" ;;
  *) echo >&2 "Only one option path can be provided"; exit 1 ;;
esac

nix-instantiate "${nix_args[@]}" --eval --json \
    --argstr path "$path" \
    --arg recursive "$recursive" \
    @nixosOptionNix@ \
| jq -r
+0 −22
Original line number Diff line number Diff line
// These are useful methods inside the nix library that ought to be exported.
// Since they are not, copy/paste them here.
// TODO: Delete these and use the ones in the library as they become available.

#include "libnix-copy-paste.hh"
#include <nix/print.hh>                           // for Strings

// From nix/src/nix/repl.cc
bool isVarName(const std::string_view & s)
{
    if (s.size() == 0) return false;
    if (nix::isReservedKeyword(s)) return false;
    char c = s[0];
    if ((c >= '0' && c <= '9') || c == '-' || c == '\'') return false;
    for (auto & i : s)
        if (!((i >= 'a' && i <= 'z') ||
              (i >= 'A' && i <= 'Z') ||
              (i >= '0' && i <= '9') ||
              i == '_' || i == '-' || i == '\''))
            return false;
    return true;
}
Loading