From d84a8d6da761e9aac8fcd37ecefc231a205fc70d Mon Sep 17 00:00:00 2001 From: Olivier Bitter Date: Sun, 3 May 2026 22:07:31 +0200 Subject: [PATCH] feat(wrapperModules.oh-my-posh): init add check.nix implement omp + checks fix fileContains add configFile option clean up tests simplify use optionalString use omp-native "extends" feature to merge configs add config-chain folder preserve original names in config chain add foo.omp.json handle reverse traversal of ordered settings remove config-chain dir if empty ensure that settings get copied to config-chain properly copy settings.json remove nix store hash from config file update tests cleanup test refactor simplify tmp file rename _omp_out --> dst simplify copy case rework json normalization fix tests wip cleanup nix recursion rewrite recurse from the end of the list simplify chainFiles cleanup unreachable configs improve docs linting improve docs cleanup improve errMsgs in testlib add config.json test add test for breaking config-chain add test for extending segments remove dummie configs --- ci/test-lib.nix | 25 +- wrapperModules/o/oh-my-posh/check.nix | 332 +++++++++++++++++++++++++ wrapperModules/o/oh-my-posh/module.nix | 296 ++++++++++++++++++++++ 3 files changed, 649 insertions(+), 4 deletions(-) create mode 100644 wrapperModules/o/oh-my-posh/check.nix create mode 100644 wrapperModules/o/oh-my-posh/module.nix diff --git a/ci/test-lib.nix b/ci/test-lib.nix index 225c9ea1..92731b17 100644 --- a/ci/test-lib.nix +++ b/ci/test-lib.nix @@ -10,7 +10,7 @@ let wlib = self.lib; - errMsg = msg: "(echo ${renderMsg msg} >&2 && return 1)"; + errMsg = msg: "(echo \"${renderMsg msg}\" >&2 && return 1)"; indentBlock = str: num: @@ -280,10 +280,27 @@ in msg = "File ${path} should not exist"; }; + /** + Returns an `Assertion` that checks whether `path` does **not** exist as a directory. + + # Type + ``` + notIsFile :: String -> Assertion + ``` + + # Arguments + path + : The filesystem path that should be absent. + */ + notIsDirectory = path: { + cond = ''[ ! -d "${path}" ]''; + msg = "Directory ${path} should not exist"; + }; + /** Returns an `Assertion` that checks whether `file` contains a line matching `pattern`. - The check is performed with `grep -q`, so `pattern` is treated as a basic regular expression. + The check is performed with `grep -Eq`, so `pattern` is treated as an extended regular expression. # Type ``` @@ -295,10 +312,10 @@ in : Path to the file to search. pattern - : Basic regular expression to search for. + : Extended regular expression to search for. */ fileContains = file: pattern: { - cond = ''grep -q '${pattern}' "${file}"''; + cond = ''grep -Eq -- '${pattern}' "${file}"''; msg = "Pattern '${pattern}' not found in ${file}"; }; diff --git a/wrapperModules/o/oh-my-posh/check.nix b/wrapperModules/o/oh-my-posh/check.nix new file mode 100644 index 00000000..cbdd1bad --- /dev/null +++ b/wrapperModules/o/oh-my-posh/check.nix @@ -0,0 +1,332 @@ +{ + pkgs, + self, + tlib, + writeText, + ... +}: + +let + inherit (tlib) + fileContains + isFile + isDirectory + notIsFile + notIsDirectory + test + ; + wm = self.wrappers.oh-my-posh; +in +test { wrapper = "oh-my-posh"; } { + + "wrapper should output correct version" = + let + wrapper = wm.wrap { + inherit pkgs; + }; + in + '' + "${wrapper}/bin/oh-my-posh" --version | + grep -q "${wrapper.version}" + ''; + + "If config is provided then config.json is properly set up" = + let + wrapper = wm.wrap { + inherit pkgs; + theme = "jandedobbeleer"; + }; + configFile = "${wrapper}/config.json"; + in + [ + (isFile configFile) + (fileContains "${wrapper}/bin/oh-my-posh" "--config.*${configFile}") + ]; + + "If no config is provided then no config.json is set up" = + let + wrapper = wm.wrap { + inherit pkgs; + }; + configFile = "${wrapper}/config.json"; + in + notIsFile configFile; + + "config chains" = + let + baseWrapper = wm.wrap { + inherit pkgs; + theme = [ + "aliens" + "agnoster" + ]; + settings.foo = "foo"; + configFile = writeText "file-config.yaml" "bar: bar"; + }; + nixStoreFile = name: "/nix/store/[[:alnum:]]{32}-.*${name}"; + extendsPattern = path: ''"extends": "${path}"''; + in + { + "theme > file > settings (default order)" = + let + wrapper = baseWrapper; + configChainDir = "${wrapper}/config-chain"; + + nixSettingsFile = "${configChainDir}/settings.json"; + fileSettingsFile = "${configChainDir}/file-config.json"; + agnosterFile = "${configChainDir}/agnoster.omp.json"; + in + [ + "[[ -h ${wrapper}/config.json ]]" + "[[ $(readlink -f ${wrapper}/config.json) == ${nixSettingsFile} ]]" + (fileContains nixSettingsFile (extendsPattern fileSettingsFile)) + (fileContains fileSettingsFile (extendsPattern agnosterFile)) + (fileContains agnosterFile (extendsPattern (nixStoreFile "aliens.omp.json"))) + ]; + + "file > theme > settings" = + let + wrapper = baseWrapper.wrap { + order = [ + "file" + "theme" + "settings" + ]; + }; + configChainDir = "${wrapper}/config-chain"; + + nixSettingsFile = "${configChainDir}/settings.json"; + agnosterFile = "${configChainDir}/agnoster.omp.json"; + aliensFile = "${configChainDir}/aliens.omp.json"; + in + [ + "[[ -h ${wrapper}/config.json ]]" + "[[ $(readlink -f ${wrapper}/config.json) == ${nixSettingsFile} ]]" + (fileContains nixSettingsFile (extendsPattern agnosterFile)) + (fileContains agnosterFile (extendsPattern aliensFile)) + (fileContains aliensFile (extendsPattern (nixStoreFile "file-config.json"))) + ]; + + "file > settings > theme" = + let + wrapper = baseWrapper.wrap { + order = [ + "file" + "settings" + "theme" + ]; + }; + configChainDir = "${wrapper}/config-chain"; + + agnosterFile = "${configChainDir}/agnoster.omp.json"; + aliensFile = "${configChainDir}/aliens.omp.json"; + nixSettingsFile = "${configChainDir}/settings.json"; + in + [ + "[[ -h ${wrapper}/config.json ]]" + "[[ $(readlink -f ${wrapper}/config.json) == ${agnosterFile} ]]" + (fileContains agnosterFile (extendsPattern aliensFile)) + (fileContains aliensFile (extendsPattern nixSettingsFile)) + (fileContains nixSettingsFile (extendsPattern (nixStoreFile "file-config.json"))) + ]; + + "settings > theme > file" = + let + wrapper = baseWrapper.wrap { + order = [ + "settings" + "theme" + "file" + ]; + }; + configChainDir = "${wrapper}/config-chain"; + + fileSettingsFile = "${configChainDir}/file-config.json"; + agnosterFile = "${configChainDir}/agnoster.omp.json"; + aliensFile = "${configChainDir}/aliens.omp.json"; + in + [ + "[[ -h ${wrapper}/config.json ]]" + "[[ $(readlink -f ${wrapper}/config.json) == ${fileSettingsFile} ]]" + (fileContains fileSettingsFile (extendsPattern agnosterFile)) + (fileContains agnosterFile (extendsPattern aliensFile)) + (fileContains aliensFile (extendsPattern (nixStoreFile "settings.json"))) + ]; + + "theme > settings > file" = + let + wrapper = baseWrapper.wrap { + order = [ + "theme" + "settings" + "file" + ]; + }; + configChainDir = "${wrapper}/config-chain"; + + fileSettingsFile = "${configChainDir}/file-config.json"; + nixSettingsFile = "${configChainDir}/settings.json"; + agnosterFile = "${configChainDir}/agnoster.omp.json"; + in + [ + "[[ -h ${wrapper}/config.json ]]" + "[[ $(readlink -f ${wrapper}/config.json) == ${fileSettingsFile} ]]" + (fileContains fileSettingsFile (extendsPattern nixSettingsFile)) + (fileContains nixSettingsFile (extendsPattern agnosterFile)) + (fileContains agnosterFile (extendsPattern (nixStoreFile "aliens.omp.json"))) + ]; + + "settings > file > theme" = + let + wrapper = baseWrapper.wrap { + order = [ + "settings" + "file" + "theme" + ]; + }; + configChainDir = "${wrapper}/config-chain"; + + agnosterFile = "${configChainDir}/agnoster.omp.json"; + aliensFile = "${configChainDir}/aliens.omp.json"; + fileSettingsFile = "${configChainDir}/file-config.json"; + in + [ + "[[ -h ${wrapper}/config.json ]]" + "[[ $(readlink -f ${wrapper}/config.json) == ${agnosterFile} ]]" + (fileContains agnosterFile (extendsPattern aliensFile)) + (fileContains aliensFile (extendsPattern fileSettingsFile)) + (fileContains fileSettingsFile (extendsPattern (nixStoreFile "settings.json"))) + ]; + }; + + "config file formats" = + let + key = "dummy_key"; + value = "dummy_value"; + in + { + "json configFile is loaded" = + let + wrapper = wm.wrap { + inherit pkgs; + configFile = writeText "config.json" ''{"${key}": "${value}"}''; + }; + generatedConfig = "${wrapper}/config.json"; + in + [ + (isFile generatedConfig) + (fileContains generatedConfig key) + (fileContains generatedConfig value) + ]; + + "yaml configFile is loaded" = + let + wrapper = wm.wrap { + inherit pkgs; + configFile = writeText "config.yaml" "${key}: ${value}"; + }; + generatedConfig = "${wrapper}/config.json"; + in + [ + (isFile generatedConfig) + (fileContains generatedConfig key) + (fileContains generatedConfig value) + ]; + + "toml configFile is loaded" = + let + wrapper = wm.wrap { + inherit pkgs; + configFile = writeText "config.toml" ''${key} = "${value}"''; + }; + generatedConfig = "${wrapper}/config.json"; + in + [ + (isFile generatedConfig) + (fileContains generatedConfig key) + (fileContains generatedConfig value) + ]; + }; + + "explicit `extends` in settings breaks the config chain" = + let + wrapper = wm.wrap { + inherit pkgs; + theme = [ + "aliens" + "agnoster" + ]; + settings.extends = "foo"; + }; + configChainDir = "${wrapper}/config-chain"; + in + [ + (isFile "${configChainDir}/settings.json") + (notIsFile "${configChainDir}/agnoster.omp.json") + (notIsFile "${configChainDir}/aliens.omp.json") + ]; + + "If a single config is provided then no config-chain dir is created" = + let + wrapper = wm.wrap { + inherit pkgs; + settings.extends = "foo"; + }; + in + [ + (isFile "${wrapper}/config.json") + (notIsDirectory "${wrapper}/config-chain") + ]; + + "extending segments works as expected" = + let + wrapper = wm.wrap { + inherit pkgs; + configFile = writeText "config.yaml" '' + blocks: + - type: prompt + alignment: left + segments: + - type: text + style: plain + template: "[b1s1]" + - type: text + style: plain + template: "[b1s2]" + - type: prompt + alignment: right + segments: + - type: text + style: plain + template: "[b2s1]" + - type: text + style: plain + template: "[b2s2]" + alias: foo + ''; + settings.blocks = [ + { + type = "prompt"; + alignment = "right"; + segments = [ + { + type = "text"; + style = "plain"; + alias = "foo"; + template = "foo"; + } + ]; + } + ]; + }; + in + # '[b2s2]' should be overridden with 'foo' + [ + "${wrapper}/bin/oh-my-posh print primary | grep -Fq '[b1s1]'" + "${wrapper}/bin/oh-my-posh print primary | grep -Fq '[b1s2]'" + "${wrapper}/bin/oh-my-posh print primary | grep -Fq '[b2s1]'" + "${wrapper}/bin/oh-my-posh print primary | grep -Fqv '[b2s2]'" + "${wrapper}/bin/oh-my-posh print primary | grep -Fq 'foo'" + ]; +} diff --git a/wrapperModules/o/oh-my-posh/module.nix b/wrapperModules/o/oh-my-posh/module.nix new file mode 100644 index 00000000..65688845 --- /dev/null +++ b/wrapperModules/o/oh-my-posh/module.nix @@ -0,0 +1,296 @@ +{ + wlib, + lib, + config, + pkgs, + ... +}: +let + jsonFmt = pkgs.formats.json { }; + + themeKey = "theme"; + fileKey = "file"; + settingsKey = "settings"; + + defaultOrder = [ + themeKey + fileKey + settingsKey + ]; +in +{ + imports = [ wlib.modules.default ]; + + options = { + settings = lib.mkOption { + inherit (jsonFmt) type; + default = { }; + description = '' + Pure nix configuration oh-my-posh. + See + ''; + example = { + console_title_template = "{{ .Folder }}"; + }; + }; + configFile = lib.mkOption { + type = with lib.types; nullOr (either path package); + default = null; + description = '' + Path to an oh-my-posh configuration file. + Supported formats are JSON (`.json`), TOML (`.toml`), and YAML (`.yaml`, `.yml`). + See + ''; + example = lib.literalExpression "./config.yaml"; + }; + theme = lib.mkOption { + type = with lib.types; either str (listOf str); + default = [ ]; + apply = lib.toList; + description = '' + One or more built-in oh-my-posh themes to use as configuration. + When a list is provided, they will be merged by chaining them with + [`.extends`](https://ohmyposh.dev/docs/configuration/general#extends). + Themes later in the list take precedence. + + See . + ''; + example = [ + "1_shell" + "agnoster" + ]; + }; + order = lib.mkOption { + type = with lib.types; wlib.types.fixedList 3 (enum defaultOrder); + default = defaultOrder; + description = '' + The order in which the specified settings are merged. + Values later in the list will take precedence. + + The allowed keys are: + + - "${themeKey}": Settings from the the specified theme (`config.theme`) + - "${fileKey}": Settings from the specified config file (`config.configFile`) + - "${settingsKey}": Settings specified as a nix attrs (`config.settings`) + ''; + }; + }; + + config = + let + nixSettingsFile = pkgs.writeText "settings.json" (builtins.toJSON config.settings); + + stripStoreHash = + name: + let + m = builtins.match "[a-z0-9]{32}-(.*)" name; + in + if m != null then builtins.head m else name; + + normalizedConfigFile = + if config.configFile == null then + null + else + let + path = toString config.configFile; + baseName = stripStoreHash (baseNameOf path); + isJson = lib.hasSuffix ".json" baseName; + isToml = lib.hasSuffix ".toml" baseName; + isYaml = lib.hasSuffix ".yaml" baseName || lib.hasSuffix ".yml" baseName; + configFileName = + lib.pipe baseName [ + (lib.removeSuffix ".toml") + (lib.removeSuffix ".yaml") + (lib.removeSuffix ".yml") + ] + + ".json"; + in + if isJson then + config.configFile + else if isToml || isYaml then + pkgs.runCommand configFileName { } '' + ${pkgs.yq-go}/bin/yq -o=json '.' ${lib.escapeShellArg "${config.configFile}"} > $out + '' + else + throw "oh-my-posh: configFile must have a .json, .toml, .yaml, or .yml extension, got: ${path}"; + + # List of { srcPath, name } in precedence order (lowest to highest) + orderedConfigs = lib.concatMap ( + key: + { + ${themeKey} = map (p: { + srcPath = "${config.package}/share/oh-my-posh/themes/${p}.omp.json"; + name = "${p}.omp.json"; + }) config.theme; + ${fileKey} = lib.optional (config.configFile != null) { + srcPath = "${normalizedConfigFile}"; + name = stripStoreHash (baseNameOf (toString normalizedConfigFile)); + }; + ${settingsKey} = lib.optional (config.settings != { }) { + srcPath = "${nixSettingsFile}"; + name = "settings.json"; + }; + } + .${key} + ) config.order; + + jq = "${pkgs.jq}/bin/jq"; + + # Build constructFile entries for the config chain. + # + # `curr`: the current (higher-precedence) config being processed. + # `configs`: remaining configs in descending precedence order. + # + # The builder for `curr` will add an ".extends" key to the json which will + # point to the next (lower-precedence) entry in the `configs` list + generateConstructFileEntries = + curr: configs: + let + relPath = "config-chain/${curr.name}"; + prev = builtins.head configs; + + # If currPath already has an "extends" key, symlink it as-is. + # Otherwise, add prevPath as the "extends" value. + generateBuilderScript = currPath: prevPath: '' + mkdir -p "$(dirname "$2")" + if [ "$(${jq} 'has("extends")' ${lib.escapeShellArg currPath})" = "true" ]; then + ln -s ${lib.escapeShellArg currPath} "$2" + else + ${jq} --arg ext ${lib.escapeShellArg prevPath} '. + {extends: $ext}' ${lib.escapeShellArg currPath} > "$2" + fi + ''; + + in + if builtins.length configs == 1 then + # Base: prev is the lowest-precedence config — point directly to its source + { + ${relPath} = { + inherit relPath; + builder = generateBuilderScript curr.srcPath prev.srcPath; + }; + } + else + # Recursive: prev will itself be a generated file in config-chain + { + ${relPath} = { + inherit relPath; + builder = generateBuilderScript curr.srcPath "${placeholder "out"}/config-chain/${prev.name}"; + }; + } + // generateConstructFileEntries prev (builtins.tail configs); + + n = builtins.length orderedConfigs; + + constructFiles = + if n == 0 then + { } + else if n == 1 then + let + cfg = builtins.head orderedConfigs; + in + { + "config.json" = { + relPath = "config.json"; + builder = '' + mkdir -p "$(dirname "$2")" + ln -s ${lib.escapeShellArg cfg.srcPath} "$2" + ''; + }; + } + else + let + reversed = lib.reverseList orderedConfigs; + lastConfig = lib.last orderedConfigs; + in + generateConstructFileEntries (builtins.head reversed) (builtins.tail reversed) + // { + "config.json" = { + relPath = "config.json"; + builder = + let + chainDir = "${placeholder "out"}/config-chain"; + lastConfigPath = "${chainDir}/${lastConfig.name}"; + in + '' + # Follow the extends chain and remove files in config-chain that are + # no longer reachable (cut off by a config that already had "extends") + declare -A reachable + current=${lib.escapeShellArg lastConfigPath} + while [ -f "$current" ]; do + reachable["$current"]=1 + ext=$(${jq} -r '.extends // empty' "$current") + [[ "$ext" == ${lib.escapeShellArg chainDir}/* ]] || break + current="$ext" + done + for f in ${lib.escapeShellArg chainDir}/*; do + [ -v 'reachable[$f]' ] || rm "$f" + done + rmdir ${lib.escapeShellArg chainDir} 2>/dev/null || true + + mkdir -p "$(dirname "$2")" + ln -s ${lib.escapeShellArg lastConfigPath} "$2" + ''; + }; + }; + in + { + inherit constructFiles; + package = lib.mkDefault pkgs.oh-my-posh; + flags."--config" = lib.mkIf (n > 0) config.constructFiles."config.json".path; + meta = { + maintainers = with wlib.maintainers; [ + zenoli + ]; + description = '' + Wrapper Module for the [Oh-My-Posh Prompt](https://ohmyposh.dev/). + + Oh-My-Posh is configured via a [JSON/YAML/TOML file](https://ohmyposh.dev/docs/configuration/general). + This module provides three ways to do this: + + - By specifying one (or many) of the [built-in themes](https://ohmyposh.dev/docs/themes/). + - By pointing to a JSON, TOML, or YAML configuration file. + - By using pure Nix to write an attribute set that gets converted to JSON. + + These options are not mutually exclusive. If multiple are defined, + they will be merged according to the order specified in `config.order`. + + Merging is done using Oh-My-Posh's native + [extends](https://ohmyposh.dev/docs/configuration/general#extends) mechanic, + which allows a configuration file to inherit from another and override individual values. + + Configs will be modified during build-time to add an `.extends` key + pointing to the next config in the list. If a config already has an `.extends` key present, + it is used as-is and the remaining lower-precedence configs are ignored. + + **Example** + + ```nix + theme = "jandedobbeleer"; + configFile = ./my-config.yaml; + settings.console_title_template = "{{ .Folder }}"; + ``` + + With the default order (`theme < file < settings`), this produces the chain: + + ``` + settings.json → my-config.json → jandedobbeleer.omp.json + (highest precedence) (lowest precedence) + ``` + + `settings.json` has an `extends` key pointing to `my-config.json`, + which in turn extends `jandedobbeleer.omp.json`. + + If `my-config.yaml` already contains an `extends` key (e.g. pointing to another theme), + it is left unchanged and the chain stops there: + + ``` + settings.json → my-config.json -x- jandedobbeleer.omp.json + (highest precedence) ↓ + (its own extends) + ``` + + `jandedobbeleer.omp.json` is dropped entirely — `my-config.yaml`'s own `extends` takes over as the base. + ''; + }; + }; +}