├── .envrc ├── doc ├── src │ ├── introduction.md │ ├── SUMMARY.md │ ├── home.md │ ├── recipes │ │ ├── index.md │ │ ├── nix-darwin.md │ │ ├── nixos.md │ │ ├── flake-parts.md │ │ └── home-manager.md │ ├── usage │ │ ├── module.md │ │ ├── configuration.md │ │ ├── index.md │ │ └── scopes.md │ ├── installation.md │ └── reference.md ├── book.toml ├── options.nix ├── build.go └── man │ ├── optnix.1.scd │ └── optnix.toml.5.scd ├── shell.nix ├── internal ├── build │ └── vars.go ├── config │ ├── context.go │ ├── scope.go │ └── config.go ├── logger │ ├── context.go │ └── logger.go ├── cmd │ └── utils │ │ └── utils.go └── utils │ └── utils.go ├── main.go ├── .prettierrc.json ├── flake-module.nix ├── option ├── scope.go ├── evaluator.go ├── option.go └── printer.go ├── nix ├── flake-compat.nix ├── modules │ ├── home-manager.nix │ ├── nixos.nix │ └── nix-darwin.nix └── lib.nix ├── .gitignore ├── default.nix ├── .builds ├── mirror.yml ├── build_test.yml └── site.yml ├── flake.lock ├── package.nix ├── Makefile ├── flake.nix ├── tui ├── help.go ├── option_help.md ├── preview.go ├── value.go ├── search.go ├── results.go ├── select_scope.go └── tui.go ├── .github └── ISSUE_TEMPLATE │ ├── feature_request.yml │ └── bug.yml ├── cmd ├── completion.go └── root.go ├── README.md ├── go.mod ├── go.sum └── LICENSE /.envrc: -------------------------------------------------------------------------------- 1 | use flake 2 | -------------------------------------------------------------------------------- /doc/src/introduction.md: -------------------------------------------------------------------------------- 1 | # Introduction 2 | -------------------------------------------------------------------------------- /shell.nix: -------------------------------------------------------------------------------- 1 | (import ./nix/flake-compat.nix).shellNix 2 | -------------------------------------------------------------------------------- /internal/build/vars.go: -------------------------------------------------------------------------------- 1 | package buildOpts 2 | 3 | var Version string 4 | -------------------------------------------------------------------------------- /main.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import "snare.dev/optnix/cmd" 4 | 5 | func main() { 6 | cmd.Execute() 7 | } 8 | -------------------------------------------------------------------------------- /.prettierrc.json: -------------------------------------------------------------------------------- 1 | { 2 | "printWidth": 80, 3 | "tabWidth": 2, 4 | "useTabs": false, 5 | "singleQuote": true, 6 | "proseWrap": "always" 7 | } 8 | -------------------------------------------------------------------------------- /doc/book.toml: -------------------------------------------------------------------------------- 1 | [book] 2 | authors = ["Varun Narravula"] 3 | language = "en" 4 | src = "src" 5 | title = "optnix - a fast Nix options searcher" 6 | 7 | [output.html] 8 | git-repository-url = "https://sr.ht/~watersucks/optnix" 9 | -------------------------------------------------------------------------------- /flake-module.nix: -------------------------------------------------------------------------------- 1 | { 2 | lib, 3 | options, 4 | ... 5 | }: { 6 | # Required for evaluating module option values. 7 | debug = true; 8 | flake = { 9 | options-doc = lib.optionAttrSetToDocList options; 10 | }; 11 | } 12 | -------------------------------------------------------------------------------- /option/scope.go: -------------------------------------------------------------------------------- 1 | package option 2 | 3 | type OptionLoader func() (NixosOptionSource, error) 4 | 5 | type Scope struct { 6 | Name string 7 | Description string 8 | Loader OptionLoader 9 | Evaluator EvaluatorFunc 10 | } 11 | -------------------------------------------------------------------------------- /nix/flake-compat.nix: -------------------------------------------------------------------------------- 1 | import 2 | ( 3 | let 4 | lock = builtins.fromJSON (builtins.readFile ../flake.lock); 5 | in 6 | fetchTarball { 7 | url = "https://github.com/edolstra/flake-compat/archive/${lock.nodes.flake-compat.locked.rev}.tar.gz"; 8 | sha256 = lock.nodes.flake-compat.locked.narHash; 9 | } 10 | ) 11 | {src = ../.;} 12 | -------------------------------------------------------------------------------- /option/evaluator.go: -------------------------------------------------------------------------------- 1 | package option 2 | 3 | import "fmt" 4 | 5 | type EvaluatorFunc func(optionName string) (string, error) 6 | 7 | type AttributeEvaluationError struct { 8 | Attribute string 9 | EvaluationOutput string 10 | } 11 | 12 | func (e *AttributeEvaluationError) Error() string { 13 | return fmt.Sprintf("failed to evaluate attribute %s", e.Attribute) 14 | } 15 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Nix/direnv 2 | /result* 3 | /.direnv/ 4 | 5 | # Binary destination from `make` 6 | /optnix 7 | 8 | # For bubbletea log files 9 | *.log 10 | 11 | # Ignore any sample .toml files used for configuration 12 | /*.toml 13 | 14 | # Generated site artifacts 15 | /doc/book 16 | /doc/src/usage/generated-module.md 17 | /site 18 | 19 | # Generated man page artifacts 20 | /optnix.1 21 | /optnix.toml.5 22 | -------------------------------------------------------------------------------- /default.nix: -------------------------------------------------------------------------------- 1 | {pkgs ? import {}}: let 2 | flakeSelf = (import ./nix/flake-compat.nix).outputs; 3 | inherit (pkgs.stdenv.hostPlatform) system; 4 | in { 5 | inherit (flakeSelf.packages.${system}) optnix; 6 | 7 | nixosModules.optnix = flakeSelf.nixosModules.optnix; 8 | darwinModules.optnix = flakeSelf.darwinModules.optnix; 9 | homeModules.optnix = flakeSelf.homeModules.optnix; 10 | 11 | mkLib = import ./nix/lib.nix; 12 | } 13 | -------------------------------------------------------------------------------- /doc/options.nix: -------------------------------------------------------------------------------- 1 | {pkgs ? import {}}: let 2 | self = (import ../nix/flake-compat.nix).outputs; 3 | 4 | optnixLib = self.mkLib pkgs; 5 | in 6 | # Yes, we are dogfooding optnix to generate 7 | # its own list of options!!! 8 | optnixLib.mkOptionsListFromModules { 9 | modules = [ 10 | self.nixosModules.optnix 11 | ]; 12 | specialArgs = { 13 | inherit pkgs; 14 | }; 15 | excluded = ["_module.args"]; 16 | } 17 | -------------------------------------------------------------------------------- /internal/config/context.go: -------------------------------------------------------------------------------- 1 | package config 2 | 3 | import "context" 4 | 5 | type configCtxKeyType string 6 | 7 | const configCtxKey configCtxKeyType = "config" 8 | 9 | func WithConfig(ctx context.Context, cfg *Config) context.Context { 10 | return context.WithValue(ctx, configCtxKey, cfg) 11 | } 12 | 13 | func FromContext(ctx context.Context) *Config { 14 | logger, ok := ctx.Value(configCtxKey).(*Config) 15 | if !ok { 16 | panic("config not present in context") 17 | } 18 | return logger 19 | } 20 | -------------------------------------------------------------------------------- /doc/src/SUMMARY.md: -------------------------------------------------------------------------------- 1 | # Summary 2 | 3 | [Home](home.md) 4 | 5 | --- 6 | 7 | - [Installation](installation.md) 8 | - [Usage](usage/index.md) 9 | - [Scopes](usage/scopes.md) 10 | - [Configuration](usage/configuration.md) 11 | - [Module](usage/module.md) 12 | - [Recipes](recipes/index.md) 13 | - [`nixos`](recipes/nixos.md) 14 | - [`nix-darwin`](recipes/nix-darwin.md) 15 | - [`home-manager`](recipes/home-manager.md) 16 | - [`flake-parts`](recipes/flake-parts.md) 17 | - [API Reference](./reference.md) 18 | -------------------------------------------------------------------------------- /internal/logger/context.go: -------------------------------------------------------------------------------- 1 | package logger 2 | 3 | import "context" 4 | 5 | type loggerCtxKeyType string 6 | 7 | const loggerCtxKey loggerCtxKeyType = "logger" 8 | 9 | func WithLogger(ctx context.Context, logger *Logger) context.Context { 10 | return context.WithValue(ctx, loggerCtxKey, logger) 11 | } 12 | 13 | func FromContext(ctx context.Context) *Logger { 14 | logger, ok := ctx.Value(loggerCtxKey).(*Logger) 15 | if !ok { 16 | panic("logger not present in context") 17 | } 18 | return logger 19 | } 20 | -------------------------------------------------------------------------------- /.builds/mirror.yml: -------------------------------------------------------------------------------- 1 | image: alpine/edge 2 | secrets: 3 | - e09ba9f3-a378-4e6e-a9bf-43e1f3f6cca8 4 | sources: 5 | - https://git.sr.ht/~watersucks/optnix 6 | tasks: 7 | - write-ssh-config: | 8 | cat >> ~/.ssh/config << EOF 9 | Host github.com 10 | IdentityFile ~/.ssh/srht_github_mirror 11 | IdentitiesOnly yes 12 | BatchMode yes 13 | StrictHostKeyChecking no 14 | EOF 15 | - mirror: | 16 | cd ~/optnix 17 | git remote add github git@github.com:water-sucks/optnix.git 18 | git push --mirror github 19 | triggers: 20 | - action: email 21 | condition: failure 22 | to: '<~watersucks/optnix-devel@lists.sr.ht>' 23 | -------------------------------------------------------------------------------- /.builds/build_test.yml: -------------------------------------------------------------------------------- 1 | image: nixos/unstable 2 | 3 | secrets: 4 | - eb550589-e6db-4c04-9452-e30f6488f3d5 5 | 6 | packages: 7 | - nixos.cachix 8 | 9 | sources: 10 | - https://git.sr.ht/~watersucks/optnix 11 | 12 | environment: 13 | NIX_CONFIG: 'experimental-features = nix-command flakes' 14 | 15 | tasks: 16 | - setup: | 17 | { 18 | set +x 19 | . ~/.cachix-secrets 20 | set -x 21 | } 22 | export CACHIX_AUTH_TOKEN 23 | cachix use watersucks 24 | - check: | 25 | cd ~/optnix 26 | nix develop .# -c make check 27 | - test: | 28 | cd ~/optnix 29 | nix develop .# -c make test 30 | - build: | 31 | cd ~/optnix 32 | nix build .#optnix -L 33 | -------------------------------------------------------------------------------- /doc/src/home.md: -------------------------------------------------------------------------------- 1 | # `optnix` 2 | 3 | `optnix` is a fast, terminal-based options searcher for Nix module systems. 4 | 5 | 6 | 7 | There are multiple module systems that Nix users use on a daily basis: 8 | 9 | - [NixOS](https://github.com/nixos/nixpkgs) (the most well-known one) 10 | - [Home Manager](https://github.com/nix-community/home-manager) 11 | - [`nix-darwin`](https://github.com/nix-darwin/nix-darwin) 12 | - [`flake-parts`](https://github.com/hercules-ci/flake-parts) 13 | 14 | These systems can have difficult-to-navigate documentation, especially for 15 | options in external modules. 16 | 17 | `optnix` solves that problem, and lets users inspect option values if possible; 18 | just like `nix repl` in most cases, but prettier. 19 | -------------------------------------------------------------------------------- /internal/cmd/utils/utils.go: -------------------------------------------------------------------------------- 1 | package cmdUtils 2 | 3 | import ( 4 | "os" 5 | "strings" 6 | 7 | tea "github.com/charmbracelet/bubbletea" 8 | "github.com/fatih/color" 9 | ) 10 | 11 | type ErrorWithHint struct { 12 | Msg string 13 | Hint string 14 | } 15 | 16 | func (e ErrorWithHint) Error() string { 17 | msg := e.Msg 18 | if e.Hint != "" { 19 | msg += "\n\n" + color.YellowString("hint: %v", e.Hint) 20 | } 21 | msg += "\n\nTry 'optnix --help' for more information." 22 | 23 | return msg 24 | } 25 | 26 | func ConfigureBubbleTeaLogger(prefix string) (func(), error) { 27 | varName := strings.ToUpper(prefix) + "_DEBUG_MODE" 28 | if os.Getenv(varName) == "" { 29 | return func() {}, nil 30 | } 31 | 32 | file, err := tea.LogToFile(prefix+".debug.log", prefix) 33 | 34 | return func() { 35 | if err != nil || file == nil { 36 | return 37 | } 38 | _ = file.Close() 39 | }, err 40 | } 41 | -------------------------------------------------------------------------------- /.builds/site.yml: -------------------------------------------------------------------------------- 1 | image: nixos/unstable 2 | 3 | secrets: 4 | - 054733e5-e274-483d-b855-80f835ab0d98 5 | 6 | packages: 7 | - nixos.netlify-cli 8 | 9 | environment: 10 | NIX_CONFIG: 'experimental-features = nix-command flakes' 11 | NETLIFY_SITE_ID: '3dae129b-0a17-41d2-8fcf-80bef49247cd' 12 | 13 | sources: 14 | - https://git.sr.ht/~watersucks/optnix 15 | 16 | tasks: 17 | - build: | 18 | cd ~/optnix 19 | 20 | nix develop .# -c make site 21 | 22 | - deploy: | 23 | cd ~/optnix 24 | 25 | if [ "$(git rev-parse origin/main)" != "$(git rev-parse HEAD)" ]; then \ 26 | complete-build; \ 27 | fi 28 | 29 | { 30 | set +x 31 | . ~/.netlify-secrets 32 | set -x 33 | } 34 | 35 | export NETLIFY_AUTH_TOKEN 36 | 37 | netlify deploy --site=$NETLIFY_SITE_ID --dir=./site --prod --no-build 38 | 39 | triggers: 40 | - action: email 41 | condition: failure 42 | to: '<~watersucks/optnix-devel@lists.sr.ht>' 43 | -------------------------------------------------------------------------------- /internal/utils/utils.go: -------------------------------------------------------------------------------- 1 | package utils 2 | 3 | import ( 4 | "bytes" 5 | "os" 6 | "os/exec" 7 | 8 | "github.com/sahilm/fuzzy" 9 | ) 10 | 11 | func FilterMinimumScoreMatches(matches []fuzzy.Match, minScore int64) []fuzzy.Match { 12 | for i, v := range matches { 13 | if v.Score < int(minScore) { 14 | return matches[:i] 15 | } 16 | } 17 | 18 | return matches 19 | } 20 | 21 | type ShellExecOutput struct { 22 | State *os.ProcessState 23 | Stdout string 24 | Stderr string 25 | } 26 | 27 | func ExecShellAndCaptureOutput(commandStr string) (ShellExecOutput, error) { 28 | cmd := exec.Command("/bin/sh", "-c", commandStr) 29 | cmd.Env = os.Environ() 30 | 31 | var stdout bytes.Buffer 32 | var stderr bytes.Buffer 33 | 34 | cmd.Stdout = &stdout 35 | cmd.Stderr = &stderr 36 | 37 | result := ShellExecOutput{} 38 | 39 | err := cmd.Run() 40 | 41 | result.State = cmd.ProcessState 42 | result.Stdout = stdout.String() 43 | result.Stderr = stderr.String() 44 | 45 | return result, err 46 | } 47 | -------------------------------------------------------------------------------- /doc/src/recipes/index.md: -------------------------------------------------------------------------------- 1 | # Recipes 2 | 3 | There are four popular sets of Nix module systems: 4 | 5 | - [NixOS](https://nixos.org) 6 | - [`nix-darwin`](https://github.com/nix-darwin/nix-darwin) 7 | - [`home-manager`](https://github.com/nix-community/home-manager) 8 | - [`flake-parts`](https://flake.parts) 9 | 10 | Even despite their popularity, it can be a little hard to get things up and 11 | running without knowing how those module systems work first. 12 | 13 | The following sections are a set of common scope configurations that you can use 14 | in your configurations, specified in both Nix form using `optnix.mkLib` and raw 15 | TOML, whenever necessary. 16 | 17 | Make sure to look at the [API Reference](../reference.md) to check what 18 | arguments can be passed to `optnix` library functions. 19 | 20 | ⚠️ **CAUTION: Do not assume that these will automatically work with your setup. 21 | Tweak as needed.** 22 | 23 | Feel free to contribute more examples, or request more for different module 24 | systems, as needed. 25 | -------------------------------------------------------------------------------- /nix/modules/home-manager.nix: -------------------------------------------------------------------------------- 1 | self: { 2 | config, 3 | pkgs, 4 | lib, 5 | ... 6 | }: let 7 | cfg = config.programs.optnix; 8 | 9 | inherit (pkgs.stdenv.hostPlatform) system; 10 | 11 | tomlFormat = pkgs.formats.toml {}; 12 | in { 13 | options.programs.optnix = { 14 | enable = lib.mkEnableOption "CLI searcher for Nix module system options"; 15 | 16 | package = lib.mkOption { 17 | type = lib.types.package; 18 | description = "Package that provides optnix"; 19 | default = self.packages.${system}.optnix; 20 | }; 21 | 22 | settings = lib.mkOption { 23 | type = lib.types.attrs; 24 | description = "Settings to put into optnix.toml"; 25 | default = {}; 26 | }; 27 | }; 28 | 29 | config = lib.mkIf cfg.enable { 30 | home.packages = [cfg.package]; 31 | 32 | xdg.configFile = { 33 | "optnix/config.toml" = lib.mkIf (cfg.settings != {}) { 34 | source = tomlFormat.generate "config.toml" cfg.settings; 35 | }; 36 | }; 37 | }; 38 | } 39 | -------------------------------------------------------------------------------- /nix/modules/nixos.nix: -------------------------------------------------------------------------------- 1 | self: { 2 | config, 3 | pkgs, 4 | lib, 5 | ... 6 | }: let 7 | cfg = config.programs.optnix; 8 | 9 | inherit (pkgs.stdenv.hostPlatform) system; 10 | 11 | tomlFormat = pkgs.formats.toml {}; 12 | in { 13 | options.programs.optnix = { 14 | enable = lib.mkEnableOption "CLI searcher for Nix module system options"; 15 | 16 | package = lib.mkOption { 17 | type = lib.types.package; 18 | description = "Package that provides optnix"; 19 | default = self.packages.${system}.optnix; 20 | }; 21 | 22 | settings = lib.mkOption { 23 | type = lib.types.attrs; 24 | description = "Settings to put into optnix.toml"; 25 | default = {}; 26 | }; 27 | }; 28 | 29 | config = lib.mkIf cfg.enable { 30 | environment.systemPackages = [cfg.package]; 31 | 32 | environment.etc = { 33 | "optnix/config.toml" = lib.mkIf (cfg.settings != {}) { 34 | source = tomlFormat.generate "config.toml" cfg.settings; 35 | }; 36 | }; 37 | }; 38 | } 39 | -------------------------------------------------------------------------------- /nix/modules/nix-darwin.nix: -------------------------------------------------------------------------------- 1 | self: { 2 | config, 3 | pkgs, 4 | lib, 5 | ... 6 | }: let 7 | cfg = config.programs.optnix; 8 | 9 | inherit (pkgs.stdenv.hostPlatform) system; 10 | 11 | tomlFormat = pkgs.formats.toml {}; 12 | in { 13 | options.programs.optnix = { 14 | enable = lib.mkEnableOption "CLI searcher for Nix module system options"; 15 | 16 | package = lib.mkOption { 17 | type = lib.types.package; 18 | description = "Package that provides optnix"; 19 | default = self.packages.${system}.optnix; 20 | }; 21 | 22 | settings = lib.mkOption { 23 | type = lib.types.attrs; 24 | description = "Settings to put into optnix.toml"; 25 | default = {}; 26 | }; 27 | }; 28 | 29 | config = lib.mkIf cfg.enable { 30 | environment.systemPackages = [cfg.package]; 31 | 32 | environment.etc = { 33 | "optnix/config.toml" = lib.mkIf (cfg.settings != {}) { 34 | source = tomlFormat.generate "config.toml" cfg.settings; 35 | }; 36 | }; 37 | }; 38 | } 39 | -------------------------------------------------------------------------------- /flake.lock: -------------------------------------------------------------------------------- 1 | { 2 | "nodes": { 3 | "flake-compat": { 4 | "flake": false, 5 | "locked": { 6 | "lastModified": 1747046372, 7 | "narHash": "sha256-CIVLLkVgvHYbgI2UpXvIIBJ12HWgX+fjA8Xf8PUmqCY=", 8 | "owner": "edolstra", 9 | "repo": "flake-compat", 10 | "rev": "9100a0f413b0c601e0533d1d94ffd501ce2e7885", 11 | "type": "github" 12 | }, 13 | "original": { 14 | "owner": "edolstra", 15 | "repo": "flake-compat", 16 | "type": "github" 17 | } 18 | }, 19 | "nixpkgs": { 20 | "locked": { 21 | "lastModified": 1759070547, 22 | "narHash": "sha256-JVZl8NaVRYb0+381nl7LvPE+A774/dRpif01FKLrYFQ=", 23 | "owner": "NixOS", 24 | "repo": "nixpkgs", 25 | "rev": "647e5c14cbd5067f44ac86b74f014962df460840", 26 | "type": "github" 27 | }, 28 | "original": { 29 | "owner": "NixOS", 30 | "ref": "nixpkgs-unstable", 31 | "repo": "nixpkgs", 32 | "type": "github" 33 | } 34 | }, 35 | "root": { 36 | "inputs": { 37 | "flake-compat": "flake-compat", 38 | "nixpkgs": "nixpkgs" 39 | } 40 | } 41 | }, 42 | "root": "root", 43 | "version": 7 44 | } 45 | -------------------------------------------------------------------------------- /doc/src/usage/module.md: -------------------------------------------------------------------------------- 1 | # Module 2 | 3 | There are some packaged Nix modules for easy usage that all function in the same 4 | manner, available at the following paths (for both flake and legacy-style 5 | configs): 6 | 7 | - `nixosModules.optnix` :: for NixOS systems 8 | - `darwinModules.optnix` :: for `nix-darwin` systems 9 | - `homeModules.optnix` :: for `home-manager` systems 10 | 11 | They all contain the same options. 12 | 13 | ## Library 14 | 15 | When using the Nix modules, it is extremely useful to instantiate the Nix 16 | library provided with `optnix`. 17 | 18 | This can be done using the exported `optnix.mkLib` function: 19 | 20 | ```nix 21 | {pkgs, ...}: 22 | let 23 | # Assume `optnix` is imported already. 24 | optnixLib = optnix.mkLib pkgs; 25 | in { 26 | programs.optnix = { 27 | # whatever options 28 | }; 29 | } 30 | ``` 31 | 32 | The functions creates option lists from Nix code ahead of time. 33 | 34 | See the [API Reference](../reference.md) for what functions are available, as 35 | well as the [recipes page](../recipes/index.md) for some real-life examples on 36 | how to use the module and the corresponding functions from an instantiated 37 | `optnix` library. 38 | 39 | ## Options 40 | 41 | {{ #include generated-module.md }} 42 | -------------------------------------------------------------------------------- /package.nix: -------------------------------------------------------------------------------- 1 | { 2 | lib, 3 | stdenv, 4 | installShellFiles, 5 | buildGoModule, 6 | nix-gitignore, 7 | scdoc, 8 | }: 9 | buildGoModule (finalAttrs: { 10 | pname = "optnix"; 11 | version = "0.3.1-dev"; 12 | src = nix-gitignore.gitignoreSource [] ./.; 13 | 14 | vendorHash = "sha256-g/H91PiHWSRRQOkaobw2wAYX/07DFxWTCTlKzf7BT1Y="; 15 | 16 | nativeBuildInputs = [installShellFiles scdoc]; 17 | 18 | env = { 19 | CGO_ENABLED = 0; 20 | VERSION = finalAttrs.version; 21 | }; 22 | 23 | buildPhase = '' 24 | runHook preBuild 25 | make all man 26 | runHook postBuild 27 | ''; 28 | 29 | installPhase = '' 30 | runHook preInstall 31 | 32 | install -Dm755 ./optnix -t $out/bin 33 | 34 | install -Dm755 ./optnix.1 -t $out/share/man/man1 35 | install -Dm755 ./optnix.toml.5 -t $out/share/man/man5 36 | 37 | runHook postInstall 38 | ''; 39 | 40 | postInstall = lib.optionalString (stdenv.buildPlatform.canExecute stdenv.hostPlatform) '' 41 | installShellCompletion --cmd optnix \ 42 | --bash <($out/bin/optnix --completion bash) \ 43 | --fish <($out/bin/optnix --completion fish) \ 44 | --zsh <($out/bin/optnix --completion zsh) 45 | ''; 46 | 47 | meta = { 48 | homepage = "https://sr.ht/~watersucks/optnix"; 49 | description = "A fast options searcher for Nix module systems"; 50 | license = lib.licenses.gpl3Only; 51 | maintainers = with lib.maintainers; [water-sucks]; 52 | }; 53 | }) 54 | -------------------------------------------------------------------------------- /Makefile: -------------------------------------------------------------------------------- 1 | APP_NAME := optnix 2 | BUILD_VAR_PKG := snare.dev/optnix/internal/build 3 | 4 | VERSION ?= $(shell (git describe --tags --always || echo unknown)) 5 | GIT_REVISION ?= $(shell (git rev-parse HEAD || echo main)) 6 | 7 | LDFLAGS := -X $(BUILD_VAR_PKG).Version=$(VERSION) 8 | 9 | GENERATED_MODULE_DOCS := doc/src/usage/generated-module.md 10 | NIX_MODULE := nix/modules/nixos.nix 11 | 12 | MANPAGES_SRC := $(wildcard doc/man/*.scd) 13 | MANPAGES := $(patsubst doc/man/%.scd,%,$(MANPAGES_SRC)) 14 | 15 | # Disable CGO by default. This should be a static executable. 16 | CGO_ENABLED ?= 0 17 | 18 | all: build $(MANPAGES) 19 | 20 | .PHONY: build 21 | build: 22 | @echo "building $(APP_NAME)..." 23 | CGO_ENABLED=$(CGO_ENABLED) go build -o ./$(APP_NAME) -ldflags="$(LDFLAGS)" . 24 | 25 | .PHONY: clean 26 | clean: 27 | @echo "cleaning up..." 28 | go clean 29 | rm -rf ./nixos site/ $(MANPAGES) 30 | 31 | .PHONY: check 32 | check: 33 | @echo "running checks..." 34 | golangci-lint run 35 | 36 | .PHONY: test 37 | test: 38 | @echo "running tests..." 39 | CGO_ENABLED=$(CGO_ENABLED) go test ./... 40 | 41 | site: $(GENERATED_MODULE_DOCS) 42 | # -d is interpreted relative to the book directory. 43 | mdbook build ./doc -d ../site 44 | 45 | .PHONY: serve-site 46 | serve-site: $(GENERATED_MODULE_DOCS) 47 | mdbook serve ./doc --open 48 | 49 | $(GENERATED_MODULE_DOCS): $(NIX_MODULE) 50 | go run doc/build.go gen-module-docs 51 | 52 | man: $(MANPAGES) 53 | 54 | %: doc/man/%.scd 55 | scdoc < $< > $@ 56 | -------------------------------------------------------------------------------- /flake.nix: -------------------------------------------------------------------------------- 1 | { 2 | description = "A fast options searcher for Nix module systems"; 3 | 4 | inputs = { 5 | nixpkgs.url = "github:NixOS/nixpkgs/nixpkgs-unstable"; 6 | 7 | flake-compat = { 8 | url = "github:edolstra/flake-compat"; 9 | flake = false; 10 | }; 11 | }; 12 | 13 | outputs = { 14 | self, 15 | nixpkgs, 16 | ... 17 | }: let 18 | inherit (nixpkgs) lib; 19 | eachSystem = lib.genAttrs lib.systems.flakeExposed; 20 | in { 21 | mkLib = import ./nix/lib.nix; 22 | 23 | packages = eachSystem (system: let 24 | pkgs = nixpkgs.legacyPackages.${system}; 25 | inherit (pkgs) callPackage; 26 | in { 27 | default = self.packages.${system}.optnix; 28 | optnix = callPackage ./package.nix {}; 29 | }); 30 | 31 | devShells = eachSystem (system: let 32 | pkgs = nixpkgs.legacyPackages.${system}; 33 | inherit (pkgs) go golangci-lint mdbook prettier scdoc; 34 | in { 35 | default = pkgs.mkShell { 36 | name = "optnix-shell"; 37 | buildInputs = [ 38 | go 39 | golangci-lint 40 | 41 | mdbook 42 | prettier 43 | scdoc 44 | ]; 45 | }; 46 | }); 47 | 48 | nixosModules.optnix = import ./nix/modules/nixos.nix self; 49 | darwinModules.optnix = import ./nix/modules/nix-darwin.nix self; 50 | homeModules.optnix = import ./nix/modules/home-manager.nix self; 51 | 52 | flakeModules.flake-parts-doc = import ./flake-module.nix; 53 | }; 54 | } 55 | -------------------------------------------------------------------------------- /internal/config/scope.go: -------------------------------------------------------------------------------- 1 | package config 2 | 3 | import ( 4 | "encoding/json" 5 | "fmt" 6 | "os" 7 | 8 | "snare.dev/optnix/internal/utils" 9 | "snare.dev/optnix/option" 10 | ) 11 | 12 | type Scope struct { 13 | Name string `koanf:"-"` 14 | Description string `koanf:"description"` 15 | OptionsListFile string `koanf:"options-list-file"` 16 | OptionsListCmd string `koanf:"options-list-cmd"` 17 | EvaluatorCmd string `koanf:"evaluator"` 18 | } 19 | 20 | func (s Scope) Load() (option.NixosOptionSource, error) { 21 | if s.OptionsListFile != "" { 22 | optionsFile, err := os.Open(s.OptionsListFile) 23 | if err != nil { 24 | return nil, fmt.Errorf("failed to open options file: %v", err) 25 | } else { 26 | defer func() { _ = optionsFile.Close() }() 27 | 28 | l, err := option.LoadOptions(optionsFile) 29 | if err != nil { 30 | return nil, fmt.Errorf("failed to load options using file strategy: %v", err) 31 | } else { 32 | return l, nil 33 | } 34 | } 35 | } 36 | 37 | if s.OptionsListCmd != "" { 38 | l, err := runGenerateOptionListCmd(s.OptionsListCmd) 39 | if err != nil { 40 | return nil, fmt.Errorf("failed to run options cmd: %v", err) 41 | } 42 | 43 | return l, nil 44 | } 45 | 46 | return nil, fmt.Errorf("no options found through all strategies for scope '%v'", s.Name) 47 | } 48 | 49 | func runGenerateOptionListCmd(commandStr string) (option.NixosOptionSource, error) { 50 | cmdOutput, err := utils.ExecShellAndCaptureOutput(commandStr) 51 | if err != nil { 52 | return nil, err 53 | } 54 | 55 | var l option.NixosOptionSource 56 | err = json.Unmarshal([]byte(cmdOutput.Stdout), &l) 57 | if err != nil { 58 | return nil, err 59 | } 60 | 61 | return l, nil 62 | } 63 | -------------------------------------------------------------------------------- /tui/help.go: -------------------------------------------------------------------------------- 1 | package tui 2 | 3 | import ( 4 | _ "embed" 5 | 6 | "github.com/charmbracelet/bubbles/viewport" 7 | tea "github.com/charmbracelet/bubbletea" 8 | "github.com/charmbracelet/lipgloss" 9 | "snare.dev/optnix/option" 10 | ) 11 | 12 | //go:embed option_help.md 13 | var helpContent string 14 | 15 | func init() { 16 | r := option.NewMarkdownRenderer() 17 | rendered, err := r.Render(helpContent) 18 | if err == nil { 19 | helpContent = rendered 20 | } 21 | } 22 | 23 | type HelpModel struct { 24 | vp viewport.Model 25 | 26 | width int 27 | height int 28 | } 29 | 30 | func NewHelpModel() HelpModel { 31 | vp := viewport.New(0, 0) 32 | vp.SetHorizontalStep(1) 33 | 34 | vp.Style = focusedBorderStyle 35 | 36 | return HelpModel{ 37 | vp: vp, 38 | } 39 | } 40 | 41 | func (m HelpModel) Update(msg tea.Msg) (HelpModel, tea.Cmd) { 42 | switch msg := msg.(type) { 43 | case tea.KeyMsg: 44 | switch msg.String() { 45 | case "q", "esc": 46 | return m, func() tea.Msg { 47 | return ChangeViewModeMsg(ViewModeSearch) 48 | } 49 | } 50 | 51 | case tea.WindowSizeMsg: 52 | m.width = msg.Width - 4 53 | m.height = msg.Height - 4 54 | 55 | m.vp.Width = m.width 56 | m.vp.Height = m.height 57 | 58 | m.vp.SetContent(m.constructHelpContent()) 59 | 60 | return m, nil 61 | } 62 | 63 | var cmd tea.Cmd 64 | m.vp, cmd = m.vp.Update(msg) 65 | 66 | return m, cmd 67 | } 68 | 69 | func (m HelpModel) View() string { 70 | return m.vp.View() 71 | } 72 | 73 | func (m HelpModel) constructHelpContent() string { 74 | title := lipgloss.PlaceHorizontal(m.width, lipgloss.Center, titleStyle.Render("Help")) 75 | line := lipgloss.NewStyle().Width(m.width).Inherit(titleRuleStyle).Render("") 76 | 77 | return title + "\n" + line + helpContent 78 | } 79 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/feature_request.yml: -------------------------------------------------------------------------------- 1 | name: Feature Request 2 | description: 3 | Make a request for new functionality or enhancements to existing features 4 | labels: ['enhancement'] 5 | body: 6 | - type: textarea 7 | id: feature-description 8 | attributes: 9 | label: Feature Description 10 | description: | 11 | A concise description of what functionality you would like to see. 12 | 13 | Include details such as what problems this feature will solve. Keep 14 | in mind the following food for thought: 15 | 16 | - Are there existing tools that can do this? If so, how will this tool be able 17 | to do this better? 18 | - How complex of a feature will this be? 19 | - How can we make sure that this does not contribute to feature creep? Keeping 20 | ideas within the scope of this tool is **extremely** important. 21 | placeholder: 22 | 'I would like to be able to switch scopes without having to re-run the 23 | command.' 24 | validations: 25 | required: true 26 | - type: dropdown 27 | id: help 28 | attributes: 29 | label: 'Help' 30 | description: 31 | 'Would you be able to implement this by submitting a pull request?' 32 | options: 33 | - 'Yes' 34 | - "Yes, but I don't know how to start; I would need guidance" 35 | - 'No' 36 | validations: 37 | required: true 38 | - type: checkboxes 39 | id: looked-through-existing-requests 40 | attributes: 41 | label: Issues 42 | options: 43 | - label: 44 | I have checked [existing 45 | issues](https://github.com/water-sucks/optnix/issues?q=is%3Aissue) 46 | and there are no existing ones with the same request. 47 | required: true 48 | -------------------------------------------------------------------------------- /doc/src/recipes/nix-darwin.md: -------------------------------------------------------------------------------- 1 | # `nix-darwin` Recipes 2 | 3 | `optnix` recipes for the [nix-darwin](https://github.com/nix-darwin/nix-darwin) 4 | module system. 5 | 6 | ## `optnix` Module Examples 7 | 8 | A simple `nix-darwin` module showcasing the `programs.optnix` option and using 9 | `optnixLib.mkOptionList` for option list generation: 10 | 11 | ```nix 12 | { options, pkgs, ... 13 | }: let 14 | # Assume `optnix` is correctly instantiated. 15 | optnixLib = inputs.optnix.mkLib pkgs; 16 | in { 17 | programs.optnix = { 18 | enable = true; 19 | settings = { 20 | min_score = 3; 21 | 22 | scopes = { 23 | TimBrown = { 24 | description = "nix-darwin configuration for TimBrown"; 25 | options-list-file = optnixLib.mkOptionsList { inherit options; }; 26 | # For flake systems 27 | # evaluator = "nix eval /path/to/flake#darwinConfigurations.TimBrown.config.{{ .Option }}"; 28 | # For legacy systems 29 | # evaluator = "nix-instantiate --eval '' -A {{ .Option }}"; 30 | }; 31 | }; 32 | }; 33 | }; 34 | } 35 | ``` 36 | 37 | ## Raw TOML Examples 38 | 39 | ### Flakes 40 | 41 | Inside a flake directory `/path/to/flake` with a `nix-darwin` system named 42 | `TimBrown`: 43 | 44 | ```toml 45 | [scopes.nix-darwin] 46 | description = "nix-darwin flake configuration for TimBrown" 47 | options-list-cmd = ''' 48 | nix eval "/path/to/flake#darwinConfigurations.TimBrown" --json --apply 'input: let 49 | inherit (input) options pkgs; 50 | 51 | optionsList = builtins.filter 52 | (v: v.visible && !v.internal) 53 | (pkgs.lib.optionAttrSetToDocList options); 54 | in 55 | optionsList' 56 | ''' 57 | evaluator = "nix eval /path/to/flake#darwinConfigurations.TimBrown.config.{{ .Option }}" 58 | ``` 59 | 60 | ### Legacy 61 | 62 | **TODO: add an example of legacy, non-flake nix-darwin configuration with raw 63 | TOML.** 64 | -------------------------------------------------------------------------------- /internal/logger/logger.go: -------------------------------------------------------------------------------- 1 | package logger 2 | 3 | import ( 4 | "log" 5 | "os" 6 | 7 | "github.com/fatih/color" 8 | ) 9 | 10 | type Logger struct { 11 | print *log.Logger 12 | info *log.Logger 13 | warn *log.Logger 14 | error *log.Logger 15 | 16 | level LogLevel 17 | } 18 | 19 | type LogLevel int 20 | 21 | const ( 22 | LogLevelInfo LogLevel = 0 23 | LogLevelWarn LogLevel = 1 24 | LogLevelError LogLevel = 2 25 | LogLevelSilent LogLevel = 3 26 | ) 27 | 28 | func NewLogger() *Logger { 29 | green := color.New(color.FgGreen) 30 | boldYellow := color.New(color.FgYellow).Add(color.Bold) 31 | boldRed := color.New(color.FgRed).Add(color.Bold) 32 | 33 | return &Logger{ 34 | print: log.New(os.Stderr, "", 0), 35 | info: log.New(os.Stderr, green.Sprint("info: "), 0), 36 | warn: log.New(os.Stderr, boldYellow.Sprint("warning: "), 0), 37 | error: log.New(os.Stderr, boldRed.Sprint("error: "), 0), 38 | } 39 | } 40 | 41 | func (l *Logger) Print(v ...any) { 42 | l.print.Print(v...) 43 | } 44 | 45 | func (l *Logger) Printf(format string, v ...any) { 46 | l.print.Printf(format, v...) 47 | } 48 | 49 | func (l *Logger) Info(v ...any) { 50 | if l.level > LogLevelInfo { 51 | return 52 | } 53 | l.info.Println(v...) 54 | } 55 | 56 | func (l *Logger) Infof(format string, v ...any) { 57 | if l.level > LogLevelInfo { 58 | return 59 | } 60 | l.info.Printf(format+"\n", v...) 61 | } 62 | 63 | func (l *Logger) Warn(v ...any) { 64 | if l.level > LogLevelWarn { 65 | return 66 | } 67 | l.warn.Println(v...) 68 | } 69 | 70 | func (l *Logger) Warnf(format string, v ...any) { 71 | if l.level > LogLevelWarn { 72 | return 73 | } 74 | l.warn.Printf(format+"\n", v...) 75 | } 76 | 77 | func (l *Logger) Error(v ...any) { 78 | if l.level > LogLevelError { 79 | return 80 | } 81 | l.error.Println(v...) 82 | } 83 | 84 | func (l *Logger) Errorf(format string, v ...any) { 85 | if l.level > LogLevelError { 86 | return 87 | } 88 | l.error.Printf(format+"\n", v...) 89 | } 90 | 91 | func (l *Logger) SetLogLevel(level LogLevel) { 92 | l.level = level 93 | } 94 | -------------------------------------------------------------------------------- /cmd/completion.go: -------------------------------------------------------------------------------- 1 | package cmd 2 | 3 | import ( 4 | "fmt" 5 | "os" 6 | "strings" 7 | 8 | "github.com/spf13/cobra" 9 | "snare.dev/optnix/internal/config" 10 | ) 11 | 12 | func GenerateCompletions(cmd *cobra.Command, shell string) { 13 | switch shell { 14 | case "bash": 15 | _ = cmd.Root().GenBashCompletionV2(os.Stdout, true) 16 | case "zsh": 17 | _ = cmd.Root().GenZshCompletion(os.Stdout) 18 | case "fish": 19 | _ = cmd.Root().GenFishCompletion(os.Stdout, true) 20 | } 21 | } 22 | 23 | func completeCompletionShells(cmd *cobra.Command, args []string, toComplete string) ([]string, cobra.ShellCompDirective) { 24 | return []string{"bash", "fish", "zsh"}, cobra.ShellCompDirectiveDefault 25 | } 26 | 27 | func completeOptionsFromScope(scopeName *string) cobra.CompletionFunc { 28 | return func(cmd *cobra.Command, args []string, toComplete string) ([]string, cobra.ShellCompDirective) { 29 | if len(args) > 1 || *scopeName == "" { 30 | return nil, cobra.ShellCompDirectiveNoFileComp 31 | } 32 | 33 | cfg := config.FromContext(cmd.Context()) 34 | 35 | scope, ok := cfg.Scopes[*scopeName] 36 | if !ok { 37 | return nil, cobra.ShellCompDirectiveNoFileComp 38 | } 39 | 40 | options, err := scope.Load() 41 | if err != nil { 42 | return nil, cobra.ShellCompDirectiveNoFileComp 43 | } 44 | 45 | names := make([]string, 0, len(options)) 46 | for _, v := range options { 47 | if strings.HasPrefix(v.Name, toComplete) { 48 | names = append(names, v.Name) 49 | } 50 | } 51 | 52 | directive := cobra.ShellCompDirectiveNoFileComp 53 | if len(names) > 1 { 54 | directive |= cobra.ShellCompDirectiveNoSpace 55 | } 56 | 57 | return names, directive 58 | } 59 | } 60 | 61 | func completeScopes(cmd *cobra.Command, args []string, toComplete string) ([]string, cobra.ShellCompDirective) { 62 | cfg := config.FromContext(cmd.Context()) 63 | 64 | scopes := []string{} 65 | for name, scope := range cfg.Scopes { 66 | scopes = append(scopes, fmt.Sprintf("%s\t%s", name, scope.Description)) 67 | } 68 | 69 | return scopes, cobra.ShellCompDirectiveNoFileComp 70 | } 71 | -------------------------------------------------------------------------------- /doc/src/recipes/nixos.md: -------------------------------------------------------------------------------- 1 | # NixOS Recipes 2 | 3 | `optnix` recipes for the [NixOS](https://nixos.org) module system. 4 | 5 | ## `optnix` Module Examples 6 | 7 | A simple `nix-darwin` module showcasing the `programs.optnix` option and using 8 | `optnixLib.mkOptionList` for option list generation: 9 | 10 | ```nix 11 | { options, pkgs, ... }: let 12 | # Assume `optnix` is correctly instantiated. 13 | optnixLib = inputs.optnix.mkLib pkgs; 14 | in { 15 | programs.optnix = { 16 | enable = true; 17 | settings = { 18 | min_score = 3; 19 | 20 | scopes = { 21 | CharlesWoodson = { 22 | description = "NixOS configuration for CharlesWoodson"; 23 | options-list-file = optnixLib.mkOptionsList { inherit options; }; 24 | # For flake systems 25 | # evaluator = "nix eval /path/to/flake#nixosConfigurations.CharlesWoodson.config.{{ .Option }}"; 26 | # For legacy systems 27 | # evaluator = "nix-instantiate --eval '' -A config.{{ .Option }}"; 28 | }; 29 | }; 30 | }; 31 | }; 32 | } 33 | ``` 34 | 35 | ## Raw TOML Examples 36 | 37 | ### Flakes 38 | 39 | Inside a flake directory `/path/to/flake` with a NixOS system named 40 | `CharlesWoodson`: 41 | 42 | ```toml 43 | [scopes.nixos] 44 | description = "NixOS flake configuration for CharlesWoodson" 45 | options-list-cmd = ''' 46 | nix eval "/path/to/flake#nixosConfigurations.CharlesWoodson" --json --apply 'input: let 47 | inherit (input) options pkgs; 48 | 49 | optionsList = builtins.filter 50 | (v: v.visible && !v.internal) 51 | (pkgs.lib.optionAttrSetToDocList options); 52 | in 53 | optionsList' 54 | ''' 55 | evaluator = "nix eval /path/to/flake#nixosConfigurations.CharlesWoodson.config.{{ .Option }}" 56 | ``` 57 | 58 | ### Legacy 59 | 60 | This uses the local NixOS system attributes located in ``. 61 | 62 | ```toml 63 | [scopes.nixos-legacy] 64 | description = "NixOS flake configuration on local host" 65 | options-list-cmd = ''' 66 | nix-instantiate --eval --expr --strict --json 'let 67 | system = import {}; 68 | pkgs = system.pkgs; 69 | optionsList = pkgs.lib.optionAttrSetToDocList system.options; 70 | in 71 | optionsList' 72 | ''' 73 | evaluator = "nix-instantiate --eval '' -A config.{{ .Option }}" 74 | ``` 75 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/bug.yml: -------------------------------------------------------------------------------- 1 | name: Bug Report 2 | description: File a bug report 3 | labels: ['bug'] 4 | body: 5 | - type: markdown 6 | attributes: 7 | value: | 8 | A bug is when something (a feature, behavior, etc.) does not work in a 9 | way that is otherwise expected. 10 | 11 | ### Look through existing issues before filing! 12 | 13 | Please make an effort to look through the issue tracker before filing 14 | any bugs. Duplicates only create more work when triaging. 15 | - type: textarea 16 | id: what-happened 17 | attributes: 18 | label: What Happened? 19 | description: 'Explain what happened, in as much detail as necessary.' 20 | placeholder: 21 | 'I encountered a bug, and this is what happened, in good detail.' 22 | validations: 23 | required: true 24 | - type: textarea 25 | id: reproduction 26 | attributes: 27 | label: How To Reproduce 28 | description: | 29 | How can one reproduce this bug? Include all relevant information, such as: 30 | 31 | - Logs 32 | - Operating system 33 | - Potentially problematic env variables 34 | placeholder: | 35 | This is how to reproduce it. I am running NixOS 24.11 (Vicuna) on the stable branch. 36 | validations: 37 | required: true 38 | - type: textarea 39 | id: expected 40 | attributes: 41 | label: Expected Behavior 42 | description: 'What behavior was expected to occur?' 43 | placeholder: 44 | 'I expected to be able to run ___ without a segmentation fault.' 45 | validations: 46 | required: true 47 | - type: textarea 48 | id: compiled-features 49 | attributes: 50 | label: Features 51 | description: | 52 | Please run `optnix --version` and paste the output here. 53 | This will automatically formatted into code output. 54 | render: shell 55 | validations: 56 | required: true 57 | - type: checkboxes 58 | id: looked-through-existing-requests 59 | attributes: 60 | label: Issues 61 | options: 62 | - label: 63 | I have checked [existing 64 | issues](https://github.com/water-sucks/optnix/issues?q=is%3Aissue) 65 | and there are no existing ones with the same problem. 66 | required: true 67 | -------------------------------------------------------------------------------- /doc/src/recipes/flake-parts.md: -------------------------------------------------------------------------------- 1 | # `flake-parts` Recipes 2 | 3 | [`flake-parts`](https://flake.parts) is a framework/module system for writing 4 | flakes using Nix module system semantics. 5 | 6 | Singe `flake-parts` configurations can wildly vary in module selection, most 7 | users will want to define them on a per-flake basis. This is well-supported 8 | through the usage of a local `optnix.toml` file, relative to the flake. 9 | 10 | ## Exposing Documentation Through Flake 11 | 12 | Use the following flake module code to expose a flake attribute called 13 | `debug.options-doc`: 14 | 15 | ```nix 16 | { 17 | lib, 18 | options, 19 | ... 20 | }: { 21 | # Required for evaluating module option values. 22 | debug = true; 23 | flake = { 24 | debug.options-doc = builtins.unsafeDiscardStringContext 25 | (builtins.toJSON (lib.optionAttrSetToDocList options)); 26 | }; 27 | } 28 | ``` 29 | 30 | OR, if you do not want to copy-paste code, use the 31 | `optnix.flakeModules.flake-parts-doc` in an import like so: 32 | 33 | ```nix 34 | { 35 | inputs = { 36 | nixpkgs.url = "github:nixos/nixpkgs/nixpkgs-unstable"; 37 | 38 | flake-parts.url = "github:hercules-ci/flake-parts"; 39 | 40 | optnix.url = "github:water-sucks/optnix"; 41 | }; 42 | 43 | outputs = { optnix, ... }@inputs: 44 | flake-parts.lib.mkFlake {inherit inputs;} { 45 | imports = [ 46 | optnix.flakeModules.flake-parts-doc 47 | ]; 48 | 49 | # ... 50 | }; 51 | } 52 | ``` 53 | 54 | Then, configure an `optnix.toml` (in the same directory as the top-level 55 | `flake.nix`) for this is rather trivial: 56 | 57 | ```toml 58 | [scopes.flake-parts] 59 | description = "flake-parts config for NixOS configuration" 60 | options-list-cmd = "nix eval --json .#debug.options-doc" 61 | evaluator = "nix eval .#debug.config.{{ .Option }}" 62 | ``` 63 | 64 | Despite the usage of `options-list-cmd`, `flake-parts` evaluates decently fast 65 | at most times. 66 | 67 | If using `options-list-file` is a non-negotiable, exposing a package with 68 | `pkgs.writeText` and using the above code as a base is also possible. But you're 69 | on your own. If you really want an example, congrats on reading this! I love 70 | that you're taking the time to read through this application's documentation and 71 | trying to use it, so please file an issue, I was just too lazy to do this right 72 | now at time of writing. 73 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 |

optnix

2 |
An options searcher for Nix module systems.
3 | 4 | ## Introduction 5 | 6 | `optnix` is a fast, terminal-based options searcher for Nix module systems. 7 | 8 | [![a demo of optnix](https://asciinema.org/a/761292.svg)](https://asciinema.org/a/761292?autoplay=1) 9 | 10 | There are multiple module systems that Nix users use on a daily basis: 11 | 12 | - [NixOS](https://github.com/nixos/nixpkgs) (the most well-known one) 13 | - [Home Manager](https://github.com/nix-community/home-manager) 14 | - [`nix-darwin`](https://github.com/LnL7/nix-darwin) 15 | - [`flake-parts`](https://github.com/hercules-ci/flake-parts) 16 | 17 | And their documentation can be hard to look for. Not to mention, any external 18 | options from imported modules can be impossible to find without reading source 19 | code. `optnix` can solve that problem for you, and allows you to inspect their 20 | values if possible; just like `nix repl` in most cases, but better. 21 | 22 | There is a website for high-level documentation available at 23 | https://optnix.snare.dev. 24 | 25 | This repository is hosted on [sr.ht](https://sr.ht/~watersucks/optnix), with an 26 | official mirror on [GitHub](https://github.com/water-sucks/optnix). 27 | 28 | ## Install 29 | 30 | `optnix` is available in `nixpkgs`. 31 | 32 | More installation instructions can be found on the 33 | [documentation website](https://optnix.snare.dev/installation.html). 34 | 35 | ## Integrations 36 | 37 | `optnix` can be used as a Go library, and is used as such in the following 38 | applications. 39 | 40 | ### [`nixos-cli`](https://github.com/nix-community/nixos-cli) 41 | 42 | `optnix` is used as a library in the `nixos option` subcommand of `nixos-cli`. 43 | 44 | `nixos option` requires zero configuration for discovery of NixOS options. 45 | 46 | ## Contributing 47 | 48 | Prefer emailing patch sets to the 49 | [official development mailing list](mailto:~watersucks/optnix-devel@lists.sr.ht). 50 | 51 | While the official repository is located on 52 | [sr.ht](https://git.sr.ht/~watersucks/optnix), contributions are also accepted 53 | through GitHub using the 54 | [official mirror](https://github.com/water-sucks/optnix), if desired. 55 | 56 | Additionally, filing GitHub issues is fine, but consider using the official 57 | issue tracker on [sr.ht](https://todo.sr.ht/~watersucks/optnix). All issues from 58 | GitHub will be mirrored there by me anyway. 59 | -------------------------------------------------------------------------------- /doc/src/usage/configuration.md: -------------------------------------------------------------------------------- 1 | # Configuration 2 | 3 | Configurations are defined in [`TOML`](https://toml.io) format, and are merged 4 | together in order of priority. 5 | 6 | There are four possible locations for configurations (in order of highest to 7 | lowest priority, and only if they exist): 8 | 9 | - Configuration paths specified on the command line with `--config` 10 | - `optnix.toml` in the current directory 11 | - `$XDG_CONFIG_HOME/optnix/config.toml` or `$HOME/.config/optnix/config.toml` 12 | - `/etc/optnix/config.toml` 13 | 14 | ### Schema 15 | 16 | A more in-depth explanation for scope configuration values is on the 17 | [scopes page](./scopes.md). 18 | 19 | ```toml 20 | # Minimum score required for search matches; the higher the score, 21 | # the more fuzzy matches are required before it is displayed in the list. 22 | # Higher scores will generally lead to less (but more relevant) results, but 23 | # this has diminishing returns. 24 | min_score = 1 25 | # Debounce time for search, in ms 26 | debounce_time = 25 27 | # Default scope to use if not specified on the command line 28 | default_scope = "" 29 | # Formatter command to use for evaluated values, if available. Takes input on 30 | # stdin and outputs the formatted code back to stdout. 31 | formatter_cmd = "nixfmt" 32 | 33 | # is a placeholder for the name of the scope. 34 | # This is not a working scope! See the recipes page. 35 | # for real examples. 36 | [scopes.] 37 | # Description of this scope 38 | description = "NixOS configuration for `nixos` system" 39 | # A path to the options list file. Preferred over options-list-cmd. 40 | options-list-file = "/path/to/file" 41 | # A command to run to generate the options list file. The list must be 42 | # printed on stdout. 43 | # Check the recipes page for some example commands that can generate this. 44 | # This is not always needed for every scope. 45 | # The following is only an example. 46 | options-list-cmd = """ 47 | nix eval /path/to/flake#homeConfigurations.nixos --json --apply 'input: let 48 | inherit (input) options pkgs; 49 | 50 | optionsList = builtins.filter 51 | (v: v.visible && !v.internal) 52 | (pkgs.lib.optionAttrSetToDocList options); 53 | in 54 | optionsList' 55 | """ 56 | # Go template for what to run in order to evaluate the option. Optional, but 57 | # useful for previewing values. 58 | # Check the scopes page for an explanation of this value. 59 | evaluator = "nix eval /path/to/flake#nixosConfigurations.nixos.config.{{ .Option }}" 60 | ``` 61 | -------------------------------------------------------------------------------- /tui/option_help.md: -------------------------------------------------------------------------------- 1 | # Concepts 2 | 3 | This application consists of three views: 4 | 5 | - **Main View** :: Search and preview options 6 | - **Help View** :: Display this help page 7 | - **Value View** :: Show the current value of an option 8 | - **Scope Select View** :: Select scope to use 9 | 10 | A **purple border** indicates the active (focused) view. Keybinds will only work 11 | in the context of the currently active view. 12 | 13 | To quit this application, press `Ctrl+C` or `Esc` from the main view. 14 | 15 | --- 16 | 17 | ## Main View 18 | 19 | The main view appears when the application starts. It contains two _windows_: 20 | 21 | - **Search Window** (left) :: User types to see available options 22 | - **Preview Window** (right) :: Displays info about the selected option 23 | 24 | Press `` to switch focus between the two windows. 25 | 26 | Press `` to view the current value of a selected option, if available; 27 | this will open the **value view**. 28 | 29 | Press `Ctrl+O` to open the scope select view. 30 | 31 | ### Search Window 32 | 33 | There are two modes of search: **fuzzy search** and **regex search**. Fuzzy 34 | search is the default mode, and uses ranked approximate string matching. Regex 35 | mode allows using RE2-style regular expressions for more exact matching. 36 | 37 | Switch between these modes using `Ctrl+F`. Fuzzy mode is indicated by a `> ` 38 | prompt, while regex mode is indicated by a `(^$) ` prompt in the search bar. 39 | 40 | Use the `Up` + `Down` arrows to navigate the results. As you move through the 41 | list, the **Preview Window** updates automatically. 42 | 43 | ### Preview Window 44 | 45 | Shows detailed information about the selected option. 46 | 47 | Use the arrow keys or `h`, `j`, `k`, and `l` to scroll around. 48 | 49 | ## Value View 50 | 51 | Displays the current value of the selected option (if it can be evaluated). 52 | 53 | Use the arrow keys or `h`, `j`, `k`, and `l` to scroll around. 54 | 55 | Press `` or `q` to close this window. 56 | 57 | ## Scope Select View 58 | 59 | Shows all available scopes defined in the configuration, if there is more than 60 | one. May not always be applicable. 61 | 62 | Use the arrow keys or `j` and `k` to scroll the list. 63 | 64 | Use `/` to search the list of scopes. 65 | 66 | To switch to the selected scope, press `Enter`; if successful, this redirects 67 | back to the main view automatically. 68 | 69 | Press `` or `q` to close this window. 70 | 71 | ## Help View 72 | 73 | Use the arrow keys or `h`, `j`, `k`, and `l` to scroll around. 74 | 75 | Press `` or `q` to close this window. 76 | -------------------------------------------------------------------------------- /option/option.go: -------------------------------------------------------------------------------- 1 | package option 2 | 3 | import ( 4 | "bytes" 5 | "encoding/json" 6 | "fmt" 7 | "io" 8 | "os/exec" 9 | "regexp" 10 | 11 | "github.com/google/shlex" 12 | ) 13 | 14 | type NixosOption struct { 15 | Name string `json:"name"` 16 | Description string `json:"description"` 17 | Type string `json:"type"` 18 | Default *NixosOptionValue `json:"default"` 19 | Example *NixosOptionValue `json:"example"` 20 | Location []string `json:"loc"` 21 | ReadOnly bool `json:"readOnly"` 22 | Declarations []string `json:"declarations"` 23 | } 24 | 25 | type NixosOptionValue struct { 26 | Type string `json:"_type"` 27 | Text string `json:"text"` 28 | } 29 | 30 | type NixosOptionSource []NixosOption 31 | 32 | func (o NixosOptionSource) String(i int) string { 33 | return o[i].Name 34 | } 35 | 36 | func (o NixosOptionSource) Len() int { 37 | return len(o) 38 | } 39 | 40 | func LoadOptions(r io.Reader) (NixosOptionSource, error) { 41 | var options []NixosOption 42 | 43 | d := json.NewDecoder(r) 44 | err := d.Decode(&options) 45 | if err != nil { 46 | return nil, err 47 | } 48 | 49 | return options, nil 50 | } 51 | 52 | var ( 53 | elidedSetPattern = regexp.MustCompile(`\{[ ]*\.\.\.[ ]*\}`) 54 | elidedListPattern = regexp.MustCompile(`\[[ ]*\.\.\.[ ]*\]`) 55 | angledPattern = regexp.MustCompile(`«.*?»`) 56 | ) 57 | 58 | func escapeNixEvalAnnotations(s string) string { 59 | s = elidedSetPattern.ReplaceAllString(s, `"«elided set»"`) 60 | s = elidedListPattern.ReplaceAllString(s, `"«elided list»"`) 61 | 62 | s = angledPattern.ReplaceAllStringFunc(s, func(s string) string { 63 | return fmt.Sprintf("%q", s) 64 | }) 65 | 66 | return s 67 | } 68 | 69 | // Format a `nix-instantiate` or a `nix eval`-created string for pretty 70 | // printing using a Nix code formatter command. 71 | // 72 | // The command passed must take the Nix code from `stdin` and pass it back 73 | // out using `stdout`. 74 | func FormatNixValue(formatterCmd string, evaluatedValue string) (string, error) { 75 | var stdin bytes.Buffer 76 | var stdout bytes.Buffer 77 | var stderr bytes.Buffer 78 | 79 | stdin.WriteString(escapeNixEvalAnnotations(evaluatedValue)) 80 | 81 | argv, err := shlex.Split(formatterCmd) 82 | if err != nil { 83 | return "", err 84 | } 85 | 86 | cmd := exec.Command(argv[0], argv[1:]...) 87 | 88 | cmd.Stdout = &stdout 89 | cmd.Stderr = &stderr 90 | cmd.Stdin = &stdin 91 | 92 | err = cmd.Run() 93 | if err != nil { 94 | return "", err 95 | } 96 | 97 | return stdout.String(), nil 98 | } 99 | -------------------------------------------------------------------------------- /doc/src/usage/index.md: -------------------------------------------------------------------------------- 1 | # Usage 2 | 3 | The next few sections describe the different `optnix` concepts. 4 | 5 | But first: 6 | 7 | ### What's a module system, even? 8 | 9 | A _module system_ is a Nix library that allows you to configure a set of exposed 10 | _options_. All the systems mentioned above allow you to configure their 11 | respective options with your own values. 12 | 13 | While this can be a powerful paradigm for modeling any configuration system, 14 | these options can be rather hard to discover. Some of these options are found 15 | through web interfaces (like https://search.nixos.org), but many options can 16 | remain out of sight without reading source code, such as external module options 17 | or external module systems. 18 | 19 | More information on how module systems work can be found on 20 | [nix.dev](https://nix.dev/tutorials/module-system/index.html). 21 | 22 | ### How `optnix` Works 23 | 24 | `optnix` works by ingesting **option lists** that are generated from these 25 | module systems. 26 | 27 | An option list is a list of JSON objects; each JSON object describes a single 28 | option, with the following values for an example option: 29 | 30 | ```json 31 | { 32 | "name": "services.nginx.enable", 33 | "description": "Whether to enable Nginx Web Server.", 34 | "type": "boolean", 35 | "default": { 36 | "_type": "literalExpression", 37 | "text": "false" 38 | }, 39 | "example": { 40 | "_type": "literalExpression", 41 | "text": "true" 42 | }, 43 | "loc": ["services", "nginx", "enable"], 44 | "readOnly": false, 45 | "declarations": [ 46 | "/nix/store/path/nixos/modules/services/web-servers/nginx/default.nix" 47 | ] 48 | } 49 | ``` 50 | 51 | Given an options attribute set, a list of these options can be generated using 52 | [`lib.optionAttrSetToDocList`](https://noogle.dev/f/lib/optionAttrSetToDocList) 53 | from `nixpkgs`, or by using wrapper functions provided with `optnix` as a Nix 54 | library. This will be seen in later examples. 55 | 56 | ### Operation 57 | 58 | There are two modes of operation: interactive (the default) and non-interactive. 59 | 60 | Interactive mode will display a search UI that allows looking for options using 61 | fuzzy search keywords or regular expressions. Selected options in the list can 62 | also be evaluated in order to preview their values. 63 | 64 | Non-interactive mode requires a valid option name as input, and will display the 65 | option and its values (if applicable) without any user interaction. This kind of 66 | output is useful when an option name is known, such as for scripting. 67 | 68 | `optnix` is controlled through its configuration file (or files) that define 69 | "**scopes**". For more, look at the following pages: 70 | 71 | - [**Scopes**](./scopes.md) 72 | - [**Configuration**](./configuration.md.md) 73 | -------------------------------------------------------------------------------- /go.mod: -------------------------------------------------------------------------------- 1 | module snare.dev/optnix 2 | 3 | go 1.24.2 4 | 5 | require ( 6 | github.com/charmbracelet/bubbles v0.21.0 7 | github.com/charmbracelet/bubbletea v1.3.5 8 | github.com/charmbracelet/glamour v0.10.0 9 | github.com/charmbracelet/lipgloss v1.1.1-0.20250404203927-76690c660834 10 | github.com/fatih/color v1.18.0 11 | github.com/google/shlex v0.0.0-20191202100458-e7afc7fbc510 12 | github.com/knadh/koanf/parsers/toml/v2 v2.2.0 13 | github.com/knadh/koanf/providers/file v1.2.0 14 | github.com/knadh/koanf/v2 v2.2.0 15 | github.com/muesli/termenv v0.16.0 16 | github.com/sahilm/fuzzy v0.1.1 17 | github.com/spf13/cobra v1.9.1 18 | github.com/yarlson/pin v0.9.1 19 | ) 20 | 21 | require ( 22 | github.com/alecthomas/chroma/v2 v2.18.0 // indirect 23 | github.com/atotto/clipboard v0.1.4 // indirect 24 | github.com/aymanbagabas/go-osc52/v2 v2.0.1 // indirect 25 | github.com/aymerick/douceur v0.2.0 // indirect 26 | github.com/charmbracelet/colorprofile v0.3.1 // indirect 27 | github.com/charmbracelet/x/ansi v0.9.2 // indirect 28 | github.com/charmbracelet/x/cellbuf v0.0.13 // indirect 29 | github.com/charmbracelet/x/exp/slice v0.0.0-20250526211440-a664b62c405f // indirect 30 | github.com/charmbracelet/x/term v0.2.1 // indirect 31 | github.com/dlclark/regexp2 v1.11.5 // indirect 32 | github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f // indirect 33 | github.com/fsnotify/fsnotify v1.9.0 // indirect 34 | github.com/go-viper/mapstructure/v2 v2.2.1 // indirect 35 | github.com/gorilla/css v1.0.1 // indirect 36 | github.com/inconshreveable/mousetrap v1.1.0 // indirect 37 | github.com/knadh/koanf/maps v0.1.2 // indirect 38 | github.com/lucasb-eyer/go-colorful v1.2.0 // indirect 39 | github.com/mattn/go-colorable v0.1.14 // indirect 40 | github.com/mattn/go-isatty v0.0.20 // indirect 41 | github.com/mattn/go-localereader v0.0.1 // indirect 42 | github.com/mattn/go-runewidth v0.0.16 // indirect 43 | github.com/microcosm-cc/bluemonday v1.0.27 // indirect 44 | github.com/mitchellh/copystructure v1.2.0 // indirect 45 | github.com/mitchellh/reflectwalk v1.0.2 // indirect 46 | github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6 // indirect 47 | github.com/muesli/cancelreader v0.2.2 // indirect 48 | github.com/muesli/reflow v0.3.0 // indirect 49 | github.com/pelletier/go-toml/v2 v2.2.4 // indirect 50 | github.com/rivo/uniseg v0.4.7 // indirect 51 | github.com/spf13/pflag v1.0.6 // indirect 52 | github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e // indirect 53 | github.com/yuin/goldmark v1.7.12 // indirect 54 | github.com/yuin/goldmark-emoji v1.0.6 // indirect 55 | golang.org/x/net v0.40.0 // indirect 56 | golang.org/x/sync v0.14.0 // indirect 57 | golang.org/x/sys v0.33.0 // indirect 58 | golang.org/x/term v0.32.0 // indirect 59 | golang.org/x/text v0.25.0 // indirect 60 | ) 61 | -------------------------------------------------------------------------------- /doc/src/usage/scopes.md: -------------------------------------------------------------------------------- 1 | # Scopes 2 | 3 | A "scope", in the `optnix` context, is a combination of a module system option 4 | list, as well as a module system instantiation. 5 | 6 | For example, a "scope" for a NixOS configuration located at the flake ref 7 | `github:water-sucks/nixed#nixosConfigurations.CharlesWoodson` would be: 8 | 9 | - The module system's `options` set 10 | (`nixosConfigurations.CharlesWoodson.options`) 11 | - The module system's `config` set (`nixosConfigurations.CharlesWoodson.config`) 12 | 13 | `options` would be used to generate the option list, while `config` would be 14 | used to evaluate and preview values of those options. 15 | 16 | ## Components 17 | 18 | In `optnix`, scopes are defined from the configuration file, and have a few 19 | components. 20 | 21 | An example of the fields for a scope in `optnix` can be found on the 22 | [configuration page](./configuration.md), and real configuration examples can be 23 | found on the [recipes page](../recipes/index.md). 24 | 25 | #### `scopes..description` 26 | 27 | A small description of what the purpose of this scope is. Optional, but useful 28 | for command-line completion and listing scopes more descriptively. 29 | 30 | #### `scopes..options-list-{file,cmd}` 31 | 32 | The option list can be specified in two different ways: 33 | 34 | - A path to a JSON file containing the option list (`options-list-file`) 35 | - A command that prints the option list to `stdout` (`options-list-cmd`) 36 | 37 | **Specifying at least one of these two is mandatory for every scope.** 38 | 39 | `options-list-file` is preferred over `options-list-cmd`, but both can be 40 | specified; if the file does not exist or cannot be accessed/is incorrect, then 41 | the command is used as a fallback. 42 | 43 | The command will usually end up being some invocation of Nix, but this command 44 | is evaluated using a shell (`/bin/sh`), which means it supports POSIX shell 45 | constructs/available commands as long as they are in `$PATH`. 46 | 47 | Prefer using `options-list-file` when creating configurations, since this is 48 | almost always faster than running the equivalent `options-list-cmd`, since 49 | `options-list-cmd` is not cached. 50 | 51 | Generating options list files can be done using the `optnix` Nix library, and 52 | examples can be seen on the [recipes page](../recipes/index.md). 53 | 54 | #### `scopes..evaluator` 55 | 56 | An **evaluator** is a command template that can be used to evaluate a Nix 57 | configuration to retrieve values. 58 | 59 | It is a shell command (always some invocation of a Nix command), but with a 60 | twist: exactly one placeholder of `{{ .Option }}` for the option to evaluate is 61 | required. This will be filled in with the option to evaluate. 62 | 63 | An example evaluator for a Nix flake would be: 64 | 65 | ```sh 66 | nix eval "/path/to/flake#nixosConfigurations.nixos.config.{{ .Option }}" 67 | ``` 68 | 69 | Specifying an evaluator for a scope is optional. 70 | -------------------------------------------------------------------------------- /doc/src/installation.md: -------------------------------------------------------------------------------- 1 | # Installation 2 | 3 | ## Executable 4 | 5 | The latest version of `optnix` is almost always available in `nixpkgs`. 6 | 7 | Otherwise: 8 | 9 | ### Flakes 10 | 11 | Use the provided flake input: 12 | 13 | ```nix 14 | { 15 | inputs = { 16 | nixpkgs.url = "github:nixos/nixpkgs/nixpkgs-unstable"; 17 | 18 | optnix.url = "sourcehut:~watersucks/optnix"; 19 | }; 20 | 21 | outputs = inputs: { 22 | nixosConfigurations.jdoe = inputs.nixpkgs.lib.nixosSystem { 23 | system = "x86_64-linux"; 24 | modules = [ 25 | ({pkgs, ...}: let 26 | inherit (pkgs.stdenv.hostPlatform) system; 27 | in { 28 | environment.systemPackages = [ 29 | inputs.optnix.packages.${system}.optnix 30 | ]; 31 | }) 32 | ]; 33 | }; 34 | }; 35 | } 36 | ``` 37 | 38 | ### Legacy 39 | 40 | Or import it inside a Nix expression through `fetchTarball`: 41 | 42 | ```nix 43 | {pkgs, ...}: let 44 | optnix-url = "https://git.sr.ht/~watersucks/optnix/archive/GITREVORBRANCHDEADBEEFDEADBEEF0000.tar.gz"; 45 | optnix = (import "${builtins.fetchTarball optnix-url}" {inherit pkgs;}).optnix; 46 | in { 47 | environment.systemPackages = [ 48 | optnix 49 | # ... 50 | ]; 51 | } 52 | ``` 53 | 54 | ## When will this be in `nixpkgs`/`home-manager`/`nix-darwin`/etc.? 55 | 56 | I'm working on it. 57 | 58 | Ideally I'll want to get the whole project into `nix-community` too, once it 59 | gains some popularity. 60 | 61 | ## Cache 62 | 63 | There is a Cachix cache available. Add the following to your Nix configuration 64 | to avoid lengthy rebuilds and fetching extra build-time dependencies, if not 65 | using `nixpkgs`: 66 | 67 | ```nix 68 | { 69 | nix.settings = { 70 | substituters = [ "https://watersucks.cachix.org" ]; 71 | trusted-public-keys = [ 72 | "watersucks.cachix.org-1:6gadPC5R8iLWQ3EUtfu3GFrVY7X6I4Fwz/ihW25Jbv8=" 73 | ]; 74 | }; 75 | } 76 | ``` 77 | 78 | Or if using the Cachix CLI outside a NixOS environment: 79 | 80 | ```sh 81 | $ cachix use watersucks 82 | ``` 83 | 84 | ## Extras 85 | 86 | There are also some packaged Nix modules for easy usage that all function in the 87 | same manner, available at the following paths: 88 | 89 | - `nixosModules.optnix` :: for NixOS systems 90 | - `darwinModules.optnix` :: for `nix-darwin` systems 91 | - `homeModules.optnix` :: for `home-manager` systems 92 | 93 | Alongside these modules is a function `mkLib`; this function is the entry point 94 | for the `optnix` Nix library for generating lists. 95 | 96 | More information on these resources can be found on the 97 | [module page](./usage/module.md), as well as the [API Reference](./reference.md) 98 | for what functions are available in the `optnix` library. 99 | 100 | Additionally, some examples configuring `optnix` for different module systems 101 | are available on the [recipes page](./recipes/index.md). 102 | -------------------------------------------------------------------------------- /doc/build.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "bytes" 5 | "fmt" 6 | "os" 7 | "os/exec" 8 | "path/filepath" 9 | "strings" 10 | 11 | "github.com/spf13/cobra" 12 | 13 | "snare.dev/optnix/option" 14 | ) 15 | 16 | func main() { 17 | rootCmd := &cobra.Command{ 18 | Use: "build", 19 | SilenceUsage: true, 20 | CompletionOptions: cobra.CompletionOptions{ 21 | DisableDefaultCmd: true, 22 | HiddenDefaultCmd: true, 23 | }, 24 | } 25 | rootCmd.SetHelpCommand(&cobra.Command{Hidden: true}) 26 | 27 | moduleGenCmd := &cobra.Command{ 28 | Use: "gen-module-docs", 29 | Short: "Generate Markdown documentation for modules", 30 | RunE: func(cmd *cobra.Command, args []string) error { 31 | fmt.Println("generating module markdown documentation") 32 | 33 | generatedModulePath := filepath.Join("doc", "src", "usage", "generated-module.md") 34 | if err := generateModuleDocMarkdown(generatedModulePath); err != nil { 35 | return err 36 | } 37 | 38 | fmt.Println("generated module documentation for mdbook site") 39 | 40 | return nil 41 | }, 42 | } 43 | 44 | rootCmd.AddCommand(moduleGenCmd) 45 | 46 | if err := rootCmd.Execute(); err != nil { 47 | os.Exit(1) 48 | } 49 | } 50 | 51 | func buildModuleOptionsJSON() (option.NixosOptionSource, error) { 52 | buildModuleDocArgv := []string{"nix-build", "./doc/options.nix"} 53 | 54 | var buildModuleDocStdout bytes.Buffer 55 | 56 | buildModuleDocCmd := exec.Command(buildModuleDocArgv[0], buildModuleDocArgv[1:]...) 57 | buildModuleDocCmd.Stdout = &buildModuleDocStdout 58 | 59 | err := buildModuleDocCmd.Run() 60 | if err != nil { 61 | return nil, err 62 | } 63 | 64 | optionsDocFilename := strings.TrimSpace(buildModuleDocStdout.String()) 65 | 66 | optionsDocFile, err := os.Open(optionsDocFilename) 67 | if err != nil { 68 | return nil, err 69 | } 70 | defer func() { _ = optionsDocFile.Close() }() 71 | 72 | return option.LoadOptions(optionsDocFile) 73 | } 74 | 75 | func formatOptionMarkdown(opt option.NixosOption) string { 76 | var sb strings.Builder 77 | 78 | sb.WriteString(fmt.Sprintf("## `%s`\n\n", opt.Name)) 79 | 80 | if opt.Description != "" { 81 | sb.WriteString(opt.Description + "\n\n") 82 | } 83 | 84 | sb.WriteString(fmt.Sprintf("**Type:** `%s`\n\n", opt.Type)) 85 | 86 | if opt.Default != nil { 87 | sb.WriteString(fmt.Sprintf("**Default:** `%s`\n\n", opt.Default.Text)) 88 | } 89 | 90 | if opt.Example != nil { 91 | sb.WriteString(fmt.Sprintf("**Example:** `%s`\n\n", opt.Example.Text)) 92 | } 93 | 94 | return sb.String() 95 | } 96 | 97 | func generateModuleDocMarkdown(outputFilename string) error { 98 | options, err := buildModuleOptionsJSON() 99 | if err != nil { 100 | return err 101 | } 102 | 103 | var sb strings.Builder 104 | 105 | for _, opt := range options { 106 | sb.WriteString(formatOptionMarkdown(opt)) 107 | sb.WriteString("\n") 108 | } 109 | 110 | err = os.WriteFile(outputFilename, []byte(sb.String()), 0o644) 111 | if err != nil { 112 | return err 113 | } 114 | 115 | return nil 116 | } 117 | -------------------------------------------------------------------------------- /tui/preview.go: -------------------------------------------------------------------------------- 1 | package tui 2 | 3 | import ( 4 | "strings" 5 | 6 | "github.com/charmbracelet/bubbles/viewport" 7 | tea "github.com/charmbracelet/bubbletea" 8 | "github.com/charmbracelet/lipgloss" 9 | "github.com/fatih/color" 10 | "snare.dev/optnix/option" 11 | ) 12 | 13 | type PreviewModel struct { 14 | vp viewport.Model 15 | 16 | option *option.NixosOption 17 | focused bool 18 | lastRendered *option.NixosOption 19 | } 20 | 21 | func NewPreviewModel() PreviewModel { 22 | vp := viewport.New(0, 0) 23 | vp.SetHorizontalStep(1) 24 | 25 | return PreviewModel{ 26 | vp: vp, 27 | } 28 | } 29 | 30 | func (m PreviewModel) SetHeight(height int) PreviewModel { 31 | m.vp.Height = height 32 | return m 33 | } 34 | 35 | func (m PreviewModel) SetWidth(width int) PreviewModel { 36 | m.vp.Width = width 37 | return m 38 | } 39 | 40 | func (m PreviewModel) SetFocused(focus bool) PreviewModel { 41 | m.focused = focus 42 | return m 43 | } 44 | 45 | func (m PreviewModel) SetOption(opt *option.NixosOption) PreviewModel { 46 | m.option = opt 47 | return m 48 | } 49 | 50 | var titleColor = color.New(color.Bold) 51 | 52 | func (m PreviewModel) Update(msg tea.Msg) (PreviewModel, tea.Cmd) { 53 | switch msg := msg.(type) { 54 | case tea.KeyMsg: 55 | switch msg.String() { 56 | case "enter": 57 | if m.option == nil { 58 | break 59 | } 60 | changeModeCmd := func() tea.Msg { 61 | return EvalValueStartMsg{Option: m.option.Name} 62 | } 63 | return m, changeModeCmd 64 | } 65 | case tea.WindowSizeMsg: 66 | // Force a re-render. The option string is cached otherwise, 67 | // and this can screw with the centered portion. 68 | m = m.ForceContentUpdate() 69 | } 70 | 71 | var cmd tea.Cmd 72 | if m.focused { 73 | m.vp, cmd = m.vp.Update(msg) 74 | } 75 | 76 | o := m.option 77 | 78 | // Do not re-render options if it has already been rendered before. 79 | // Setting content will reset the scroll counter, and rendering 80 | // an option is expensive. 81 | if o == m.lastRendered && o != nil { 82 | return m, cmd 83 | } 84 | 85 | m.vp.SetContent(m.renderOptionView()) 86 | m.vp.GotoTop() 87 | 88 | m.lastRendered = o 89 | 90 | return m, cmd 91 | } 92 | 93 | func (m PreviewModel) ForceContentUpdate() PreviewModel { 94 | m.vp.SetContent(m.renderOptionView()) 95 | m.vp.GotoTop() 96 | 97 | return m 98 | } 99 | 100 | func (m PreviewModel) renderOptionView() string { 101 | o := m.option 102 | 103 | sb := strings.Builder{} 104 | 105 | title := lipgloss.PlaceHorizontal(m.vp.Width, lipgloss.Center, titleColor.Sprint("Option Preview")) 106 | sb.WriteString(title) 107 | sb.WriteString("\n\n") 108 | 109 | if m.option == nil { 110 | sb.WriteString("\n No option selected.") 111 | return sb.String() 112 | } 113 | 114 | sb.WriteString(o.PrettyPrint(nil)) 115 | 116 | return sb.String() 117 | } 118 | 119 | func (m PreviewModel) View() string { 120 | if m.focused { 121 | m.vp.Style = focusedBorderStyle 122 | } else { 123 | m.vp.Style = inactiveBorderStyle 124 | } 125 | 126 | return m.vp.View() 127 | } 128 | -------------------------------------------------------------------------------- /doc/src/recipes/home-manager.md: -------------------------------------------------------------------------------- 1 | # `home-manager` Recipes 2 | 3 | `optnix` recipes for 4 | [`home-manager`](https://github.com/nix-community/home-manager) (HM), a popular 5 | module system that manages user configurations. 6 | 7 | HM has some quirks that can make it rather weird to use in `optnix`. Absolutely 8 | prefer using the `optnix` modules + the `optnix` Nix library to generate HM 9 | options. If using `optnix` with HM with raw TOML, you are on your own; I have 10 | not been able to create good examples at the time of writing. 11 | 12 | ## Standalone/Inside `home-manager` Module 13 | 14 | Inside of `home-manager`, the `options` attribute is available and can be used 15 | directly. 16 | 17 | ```nix 18 | { options, config, pkgs, lib, ... }: let 19 | # Assume `optnix` is correctly instantiated. 20 | optnixLib = optnix.mkLib pkgs; 21 | in { 22 | programs.optnix = { 23 | enable = true; 24 | scopes = { 25 | home-manager = { 26 | description = "home-manager configuration for all systems"; 27 | options-list-file = optnixLib.mkOptionsList { 28 | inherit options; 29 | transform = o: 30 | o 31 | // { 32 | name = lib.removePrefix "home-manager.users.${config.home.username}." o.name; 33 | }; 34 | }; 35 | evaluator = ""; 36 | }; 37 | } 38 | }; 39 | } 40 | ``` 41 | 42 | **NOTE**: This may create a separate configuration file for ALL users depending 43 | on, so it may not necessarily be what you want on multi-user systems. Look at 44 | the next section for more on an alternative. 45 | 46 | ## NixOS/`nix-darwin` Module 47 | 48 | `home-manager` does not expose a proper `options` attribute set on NixOS and 49 | `nix-darwin` systems, which makes option introspection a little harder than it 50 | should be. 51 | 52 | Instead, an unexposed function from the type of the `home-manager.users` option 53 | itself, `getSubOptions`, can be used to obtain an `options` attribute set for 54 | HM. 55 | 56 | However, this is impossible to evaluate due to the fact that it relies on other 57 | settings that may not exist, such as usernames and such. 58 | 59 | When this is the case, the following scope declaration using the special 60 | function `optnixLib.mkOptionsListFromHMSource` can be used in any module: NixOS, 61 | `nix-darwin`, or even in standalone HM. 62 | 63 | This function is adapted from `home-manager`'s documentation facilities, and 64 | inserts some dummy modules that allow for proper option list generation without 65 | evaluation failing. 66 | 67 | ```nix 68 | { 69 | inputs, 70 | config, 71 | pkgs, 72 | ... 73 | }: let 74 | optnixLib = inputs.optnix.mkLib pkgs; 75 | in { 76 | programs.optnix = { 77 | enable = true; 78 | settings = { 79 | scopes = { 80 | home-manager = { 81 | description = "home-manager options for all systems"; 82 | options-list-file = optnixLib.hm.mkOptionsListFromHMSource { 83 | home-manager = inputs.home; 84 | modules = with inputs; [ 85 | # Other extra modules that may exist in your source tree 86 | # optnix.homeModules.optnix 87 | ]; 88 | }; 89 | }; 90 | }; 91 | }; 92 | }; 93 | } 94 | ``` 95 | -------------------------------------------------------------------------------- /doc/man/optnix.1.scd: -------------------------------------------------------------------------------- 1 | OPTNIX(1) 2 | 3 | # NAME 4 | 5 | optnix - a fast options searcher for NixOS module systems 6 | 7 | # SYNOPSIS 8 | 9 | *optnix* [options] [OPTION-NAME] 10 | 11 | # DESCRIPTION 12 | 13 | There are multiple module systems that Nix users use on a daily basis: 14 | 15 | - NixOS (the most well-known one) 16 | - Home Manager 17 | - nix-darwin 18 | - flake-parts 19 | 20 | These systems can have difficult-to-navigate documentation, especially for 21 | options in external modules. 22 | 23 | *optnix* solves that problem, and lets users inspect option values if possible; 24 | just like *nix repl* in most cases, but prettier. 25 | 26 | # EXAMPLES 27 | 28 | Search for options interactively using the _nixos_ scope: 29 | 30 | *optnix -s nixos* 31 | 32 | Search for the option _programs.zsh.enable_ in interactive mode using the 33 | default scope: 34 | 35 | *optnix programs.zsh.enable* 36 | 37 | Search for the option _programs.zsh.enable_ in non-interactive mode in the 38 | default scope: 39 | 40 | *optnix -n programs.zsh.enable* 41 | 42 | Display the value of _programs.zsh.enable_ in the _home-manager_ scope: 43 | 44 | *optnix -v -s home-manager programs.zsh.enable* 45 | 46 | Display the value of _flake.nixosConfigurations_ in the _flake-parts_ scope 47 | using a custom configuration file: 48 | 49 | *optnix -c ./contrib/optnix.toml -v -s flake-parts flake.apps* 50 | 51 | # ARGUMENTS 52 | 53 | *OPTION-NAME* 54 | Specify the name of an option to search for. 55 | 56 | In non-interactive mode, this argument is required, and it *MUST* evaluate 57 | to a valid option. 58 | 59 | In interactive mode, it serves as an initial input for the search bar, and 60 | is not required. 61 | 62 | # OPTIONS 63 | 64 | *-c*, *--config * 65 | Path to extra configuration file(s) to load. 66 | 67 | To specify multiple extra configuration files to load, pass this option 68 | multiple times. 69 | 70 | *-j*, *--json* 71 | Output information in JSON format. 72 | 73 | Implies non-interactive mode. 74 | 75 | *-l*, *--list-scopes* 76 | List available scopes and their origins (aka what configuration file they 77 | most recently were modified in) and exit. 78 | 79 | *-m*, *--min-score * 80 | Minimum score threshold for deeming a potential candidate a match. 81 | 82 | This option is ignored in regex mode. 83 | 84 | The lower the number is, the more search results will show up, but they may 85 | be less relevant. 86 | 87 | *-n*, *--non-interactive* 88 | Do not show search TUI for options. 89 | 90 | If specified, *OPTION-NAME* will become a mandatory parameter. 91 | 92 | *-s*, *--scope * 93 | Scope name to use. 94 | 95 | If a default scope is not defined in the configuration, this parameter is 96 | required. 97 | 98 | *-v*, *--value-only* 99 | Only show the value of the passed option; useful for scripting. 100 | 101 | Implies non-interactive mode. 102 | 103 | *--version* 104 | Display version information. 105 | 106 | *-h*, *--help* 107 | Show the help message for this command. 108 | 109 | # AUTHORS 110 | 111 | Maintained by Varun Narravula . Up-to-date sources can be 112 | found at https://sr.ht/~watersucks/optnix, and bugs reports or patches 113 | can be submitted by email to the sr.ht mailing lists/issue tracker, or to 114 | the GitHub mirror as a pull request/issue. Prefer the former if possible. 115 | -------------------------------------------------------------------------------- /option/printer.go: -------------------------------------------------------------------------------- 1 | package option 2 | 3 | import ( 4 | "fmt" 5 | "strings" 6 | 7 | "github.com/charmbracelet/glamour" 8 | glamourStyles "github.com/charmbracelet/glamour/styles" 9 | "github.com/fatih/color" 10 | ) 11 | 12 | type ValuePrinterInput struct { 13 | Value string 14 | Err error 15 | } 16 | 17 | func (o *NixosOption) PrettyPrint(value *ValuePrinterInput) string { 18 | var sb strings.Builder 19 | 20 | var ( 21 | titleStyle = color.New(color.Bold) 22 | italicStyle = color.New(color.Italic) 23 | ) 24 | 25 | desc := strings.TrimSpace(stripInlineCodeAnnotations(o.Description)) 26 | if desc == "" { 27 | desc = italicStyle.Sprint("(none)") 28 | } else { 29 | d, err := renderer.Render(desc) 30 | if err != nil { 31 | desc = italicStyle.Sprintf("warning: failed to render description: %v\n", err) + desc 32 | } else { 33 | desc = strings.TrimSpace(d) 34 | } 35 | } 36 | 37 | valueText := "" 38 | if value != nil { 39 | if value.Err != nil { 40 | valueText = "failed to evaluate value" 41 | 42 | if e, ok := value.Err.(*AttributeEvaluationError); ok { 43 | valueText = fmt.Sprintf("%v: %v", valueText, e.EvaluationOutput) 44 | } 45 | 46 | valueText = color.RedString(valueText) 47 | } else { 48 | valueText = color.WhiteString(strings.TrimSpace(value.Value)) 49 | } 50 | } 51 | 52 | var defaultText string 53 | if o.Default != nil { 54 | defaultText = color.WhiteString(strings.TrimSpace(o.Default.Text)) 55 | } else { 56 | defaultText = italicStyle.Sprint("(none)") 57 | } 58 | 59 | exampleText := "" 60 | if o.Example != nil { 61 | exampleText = color.WhiteString(strings.TrimSpace(o.Example.Text)) 62 | } 63 | 64 | fmt.Fprintf(&sb, "%v\n%v\n\n", titleStyle.Sprint("Name"), o.Name) 65 | fmt.Fprintf(&sb, "%v\n%v\n\n", titleStyle.Sprint("Description"), desc) 66 | fmt.Fprintf(&sb, "%v\n%v\n\n", titleStyle.Sprint("Type"), italicStyle.Sprint(o.Type)) 67 | 68 | if valueText != "" { 69 | fmt.Fprintf(&sb, "%v\n%v\n\n", titleStyle.Sprint("Value"), valueText) 70 | } 71 | fmt.Fprintf(&sb, "%v\n%v\n\n", titleStyle.Sprint("Default"), defaultText) 72 | if exampleText != "" { 73 | fmt.Fprintf(&sb, "%v\n%v\n\n", titleStyle.Sprint("Example"), exampleText) 74 | } 75 | 76 | if len(o.Declarations) > 0 { 77 | fmt.Fprintf(&sb, "%v\n", titleStyle.Sprint("Declared In")) 78 | for _, v := range o.Declarations { 79 | fmt.Fprintf(&sb, " - %v\n", italicStyle.Sprint(v)) 80 | } 81 | } 82 | if o.ReadOnly { 83 | fmt.Fprintf(&sb, "\n%v\n", color.YellowString("This option is read-only.")) 84 | } 85 | 86 | return sb.String() 87 | } 88 | 89 | var ( 90 | markdownRenderIndentWidth uint = 0 91 | renderer = NewMarkdownRenderer() 92 | ) 93 | 94 | func NewMarkdownRenderer() *glamour.TermRenderer { 95 | glamourStyles.DarkStyleConfig.Document.Margin = &markdownRenderIndentWidth 96 | 97 | r, _ := glamour.NewTermRenderer( 98 | glamour.WithStyles(glamourStyles.DarkStyleConfig), 99 | glamour.WithWordWrap(80), 100 | ) 101 | 102 | return r 103 | } 104 | 105 | var annotationsToRemove = []string{ 106 | "{option}`", 107 | "{var}`", 108 | "{file}`", 109 | "{env}`", 110 | "{command}`", 111 | "{manpage}`", 112 | } 113 | 114 | func stripInlineCodeAnnotations(slice string) string { 115 | result := slice 116 | 117 | for _, input := range annotationsToRemove { 118 | result = strings.ReplaceAll(result, input, "`") 119 | } 120 | 121 | return result 122 | } 123 | -------------------------------------------------------------------------------- /doc/man/optnix.toml.5.scd: -------------------------------------------------------------------------------- 1 | OPTNIX-TOML(5) 2 | 3 | # NAME 4 | 5 | optnix.toml - configuration options for optnix 6 | 7 | # DESCRIPTION 8 | 9 | This man page documents available configuration keys for the *optnix* command. 10 | 11 | Configuration files use the TOML format, and are merged together in the 12 | following order (if they exist): 13 | 14 | - Configuration paths specified on the command line with _--config _ 15 | - _optnix.toml_ in the current directory 16 | - _$XDG_CONFIG_HOME/optnix/config.toml_ or _$HOME/.config/optnix/config.toml_ 17 | - _/etc/optnix/config.toml_ 18 | 19 | Defaults are noted alongside each option below. 20 | 21 | For more information about the *optnix* command itself, check the *optnix(1)* 22 | man page. 23 | 24 | ## TERMINOLOGY 25 | 26 | *Option List* 27 | 28 | An _option list_ is a JSON-formatted list that has the following schema: 29 | 30 | ``` 31 | { 32 | "name": "services.nginx.enable", 33 | "description": "Whether to enable Nginx Web Server.", 34 | "type": "boolean", 35 | "default": { 36 | "_type": "literalExpression", 37 | "text": "false" 38 | }, 39 | "example": { 40 | "_type": "literalExpression", 41 | "text": "true" 42 | }, 43 | "loc": ["services", "nginx", "enable"], 44 | "readOnly": false, 45 | "declarations": [ 46 | "/nix/store/path/to/nginx/default.nix" 47 | ] 48 | } 49 | ``` 50 | 51 | Generating this list can be done with the _optnix_ flake library, or by using 52 | the _nixpkgs_ library function _lib.optionAttrSetToDocList_. 53 | 54 | See the documentation website for information on common configurations and 55 | setups to generate option lists. 56 | 57 | *Scope* 58 | 59 | A "scope", in its most basic form, consists of four elements: 60 | 61 | - Name 62 | - Description 63 | - Option list 64 | - Evaluator 65 | 66 | *optnix* operates on one scope at any given time. It will search the option list 67 | for available options, and evaluate options using the "evaluator", which 68 | is a small template for a _nix eval_ or _nix-instantiate_ command to run to 69 | evaluate a value. 70 | 71 | The only elements that are mandatory for a scope are a name and a an option 72 | list. 73 | 74 | # OPTIONS 75 | 76 | All available settings and their descriptions. 77 | 78 | *min_score* 79 | 80 | Minimum score required for fuzzy search matches; the higher the score, the more 81 | fuzzy matches are required before it is displayed in the list. Higher scores 82 | will generally lead to less (but more relevant) results, but this has 83 | diminishing returns. 84 | 85 | This option is not used in regex search mode. 86 | 87 | Default: _1_ 88 | 89 | 90 | *debounce_time* 91 | 92 | Debounce time for search, in milliseconds. 93 | 94 | Default: _25_ 95 | 96 | 97 | *default_scope* 98 | 99 | Default scope to use if not specified on the command line. 100 | 101 | Default: _(none)_ 102 | 103 | 104 | *formatter_cmd* 105 | 106 | Formatter command to use for evaluated values, if available. Takes input on 107 | stdin and outputs the formatted code back to stdout. 108 | 109 | Default: _nixfmt_ 110 | 111 | 112 | *scopes.* 113 | 114 | Scopes, specified as a map. Each scope will have a unique name. 115 | 116 | Default: _{} (none)_ 117 | 118 | 119 | *scopes..description* 120 | 121 | A small description of what the purpose of this scope is. Optional, but useful 122 | for command-line completion and listing scopes more descriptively. 123 | 124 | Default: _(none)_ 125 | 126 | 127 | *scopes..options-list-file* 128 | 129 | A JSON file containing an option list. This is preferred over 130 | _scopes..options-list-cmd_ if it exists. 131 | 132 | Default: _(none)_ 133 | 134 | 135 | *scopes..options-list-cmd* 136 | 137 | A command to evaluate that produces a JSON-formatted option list on _stdout_. 138 | This is a fallback if _scopes..options-list-file_ does not exist or fails. 139 | 140 | Default: _(none)_ 141 | 142 | 143 | *scopes..evaluator* 144 | 145 | A command template that can be used to evaluate a Nix configuration to retrieve 146 | values. 147 | 148 | Requires a single placeholder called _{{ .Option }}_ to be present; this is 149 | filled in with the option to evaluate automatically. 150 | 151 | Default: _(none)_ 152 | 153 | # SEE ALSO 154 | 155 | *optnix(1)* 156 | 157 | # AUTHORS 158 | 159 | Maintained by the *optnix* authors. See the main man page *optnix(1)* for 160 | details. 161 | -------------------------------------------------------------------------------- /tui/value.go: -------------------------------------------------------------------------------- 1 | package tui 2 | 3 | import ( 4 | "github.com/charmbracelet/bubbles/spinner" 5 | "github.com/charmbracelet/bubbles/viewport" 6 | tea "github.com/charmbracelet/bubbletea" 7 | "github.com/charmbracelet/lipgloss" 8 | "github.com/fatih/color" 9 | "github.com/muesli/termenv" 10 | "snare.dev/optnix/option" 11 | ) 12 | 13 | type EvalValueModel struct { 14 | vp viewport.Model 15 | spinner spinner.Model 16 | 17 | option string 18 | 19 | loading bool 20 | evaluated string 21 | evalErr error 22 | 23 | width int 24 | height int 25 | 26 | evaluator option.EvaluatorFunc 27 | } 28 | 29 | var spinnerStyle = lipgloss.NewStyle().Foreground(lipgloss.ANSIColor(termenv.ANSIBlue)) 30 | 31 | func NewEvalValueModel(evaluator option.EvaluatorFunc) EvalValueModel { 32 | vp := viewport.New(0, 0) 33 | vp.SetHorizontalStep(1) 34 | vp.Style = focusedBorderStyle 35 | 36 | sp := spinner.New() 37 | sp.Spinner = spinner.Line 38 | sp.Style = spinnerStyle 39 | 40 | return EvalValueModel{ 41 | vp: vp, 42 | evaluator: evaluator, 43 | spinner: sp, 44 | loading: false, 45 | } 46 | } 47 | 48 | type EvalValueStartMsg struct { 49 | Option string 50 | } 51 | 52 | type EvalValueFinishedMsg struct { 53 | Value string 54 | Err error 55 | } 56 | 57 | func (m EvalValueModel) Update(msg tea.Msg) (EvalValueModel, tea.Cmd) { 58 | var cmds []tea.Cmd 59 | 60 | switch msg := msg.(type) { 61 | case tea.KeyMsg: 62 | switch msg.String() { 63 | case "q", "esc": 64 | return m, func() tea.Msg { 65 | return ChangeViewModeMsg(ViewModeSearch) 66 | } 67 | } 68 | 69 | case tea.WindowSizeMsg: 70 | m.width = msg.Width - 4 71 | m.height = msg.Height - 4 72 | 73 | m.vp.Width = m.width 74 | m.vp.Height = m.height 75 | 76 | return m, nil 77 | 78 | case EvalValueStartMsg: 79 | if m.option == msg.Option { 80 | break 81 | } 82 | 83 | m.option = msg.Option 84 | m.loading = true 85 | m.evaluated = "" 86 | m.evalErr = nil 87 | 88 | cmds = append(cmds, m.evalOptionCmd()) 89 | cmds = append(cmds, m.spinner.Tick) 90 | 91 | case EvalValueFinishedMsg: 92 | m.loading = false 93 | m.evaluated = msg.Value 94 | m.evalErr = msg.Err 95 | 96 | m.vp.SetContent(m.constructValueContent()) 97 | case spinner.TickMsg: 98 | var cmd tea.Cmd 99 | m.spinner, cmd = m.spinner.Update(msg) 100 | cmds = append(cmds, cmd) 101 | } 102 | 103 | if m.loading { 104 | m.vp.SetContent(m.constructLoadingContent()) 105 | } 106 | 107 | var vpCmd tea.Cmd 108 | m.vp, vpCmd = m.vp.Update(msg) 109 | cmds = append(cmds, vpCmd) 110 | 111 | return m, tea.Batch(cmds...) 112 | } 113 | 114 | func (m EvalValueModel) evalOptionCmd() tea.Cmd { 115 | return func() tea.Msg { 116 | if m.evaluator == nil { 117 | return EvalValueFinishedMsg{Value: "no evaluator is configured"} 118 | } 119 | 120 | value, err := m.evaluator(m.option) 121 | return EvalValueFinishedMsg{Value: value, Err: err} 122 | } 123 | } 124 | 125 | func (m EvalValueModel) SetEvaluator(evaluator option.EvaluatorFunc) EvalValueModel { 126 | m.evaluator = evaluator 127 | return m 128 | } 129 | 130 | func (m EvalValueModel) SetOption(o string) (EvalValueModel, tea.Cmd) { 131 | if o == m.option { 132 | return m, nil 133 | } 134 | 135 | m.option = o 136 | m.loading = true 137 | m.evaluated = "" 138 | m.evalErr = nil 139 | 140 | return m, m.evalOptionCmd() 141 | } 142 | 143 | func (m EvalValueModel) View() string { 144 | return m.vp.View() 145 | } 146 | 147 | var ( 148 | evalSuccessColor = color.New(color.FgWhite) 149 | evalErrorColor = color.New(color.FgRed).Add(color.Bold) 150 | ) 151 | 152 | func (m EvalValueModel) constructLoadingContent() string { 153 | title := lipgloss.PlaceHorizontal(m.width, lipgloss.Left, titleStyle.Render(m.option)) 154 | line := lipgloss.NewStyle().Width(m.width).Inherit(titleRuleStyle).Render("") 155 | body := "Evaluating attribute..." + m.spinner.View() 156 | 157 | return title + "\n" + line + "\n" + body 158 | } 159 | 160 | func (m EvalValueModel) constructValueContent() string { 161 | title := lipgloss.PlaceHorizontal(m.width, lipgloss.Left, titleStyle.Render(m.option)) 162 | line := lipgloss.NewStyle().Width(m.width).Inherit(titleRuleStyle).Render("") 163 | 164 | body := "" 165 | 166 | err := m.evalErr 167 | if err != nil { 168 | errStr := err.Error() 169 | if e, ok := err.(*option.AttributeEvaluationError); ok { 170 | errStr += "\n\nevaluation trace:\n-----------------\n" + e.EvaluationOutput 171 | } 172 | 173 | body = evalErrorColor.Sprint(errStr) 174 | } else { 175 | body = evalSuccessColor.Sprint(m.evaluated) 176 | } 177 | 178 | return title + "\n" + line + "\n" + body 179 | } 180 | -------------------------------------------------------------------------------- /tui/search.go: -------------------------------------------------------------------------------- 1 | package tui 2 | 3 | import ( 4 | "fmt" 5 | "strings" 6 | "time" 7 | 8 | "github.com/charmbracelet/bubbles/textinput" 9 | tea "github.com/charmbracelet/bubbletea" 10 | "github.com/charmbracelet/lipgloss" 11 | ) 12 | 13 | type SearchBarModel struct { 14 | input textinput.Model 15 | debouncer Debouncer 16 | debounceTime int64 17 | 18 | width int 19 | height int 20 | focused bool 21 | 22 | searchMode SearchMode 23 | 24 | resultCount int 25 | totalCount int 26 | } 27 | 28 | type SearchMode int 29 | 30 | const ( 31 | SearchModeFuzzy SearchMode = iota 32 | SearchModeRegex 33 | ) 34 | 35 | func NewSearchBarModel(totalCount int, debounceTime int64) SearchBarModel { 36 | ti := textinput.New() 37 | ti.Placeholder = "Search for options..." 38 | ti.Prompt = "> " 39 | 40 | debouncer := Debouncer{} 41 | 42 | return SearchBarModel{ 43 | input: ti, 44 | debouncer: debouncer, 45 | debounceTime: debounceTime, 46 | totalCount: totalCount, 47 | searchMode: SearchModeFuzzy, 48 | } 49 | } 50 | 51 | func (m SearchBarModel) Update(msg tea.Msg) (SearchBarModel, tea.Cmd) { 52 | switch msg := msg.(type) { 53 | case tea.KeyMsg: 54 | if msg.String() == "ctrl+f" { 55 | switch m.searchMode { 56 | case SearchModeFuzzy: 57 | m.searchMode = SearchModeRegex 58 | m.input.Prompt = "(^$) " 59 | case SearchModeRegex: 60 | m.searchMode = SearchModeFuzzy 61 | m.input.Prompt = "> " 62 | } 63 | 64 | return m, nil 65 | } 66 | 67 | oldValue := m.input.Value() 68 | input, cmd := m.input.Update(msg) 69 | m.input = input 70 | 71 | if oldValue != m.input.Value() { 72 | delay := time.Duration(m.debounceTime) * time.Millisecond 73 | cmd := m.debouncer.Tick(delay, func() tea.Msg { 74 | return searchChangedMsg(m.input.Value()) 75 | }) 76 | return m, cmd 77 | } 78 | 79 | return m, cmd 80 | 81 | case DebounceMsg: 82 | if msg.ID != m.debouncer.ID { 83 | // Explicitly return the model early here, since this 84 | // is a stale debounce command; there's no need to rebuild 85 | // the UI off this message. 86 | return m, nil 87 | } 88 | 89 | switch inner := msg.Msg.(type) { 90 | case searchChangedMsg: 91 | return m, func() tea.Msg { 92 | return RunSearchMsg{ 93 | Query: string(inner), 94 | Mode: m.searchMode, 95 | } 96 | } 97 | } 98 | 99 | case ChangeScopeMsg: 100 | m.input.Reset() 101 | } 102 | 103 | return m, nil 104 | } 105 | 106 | func (m SearchBarModel) SetFocused(focused bool) SearchBarModel { 107 | m.focused = focused 108 | 109 | if focused { 110 | m.input.Focus() 111 | } else { 112 | m.input.Blur() 113 | } 114 | return m 115 | } 116 | 117 | func (m SearchBarModel) SetWidth(width int) SearchBarModel { 118 | m.width = width 119 | m.input.Width = width 120 | 121 | return m 122 | } 123 | 124 | func (m SearchBarModel) SetHeight(height int) SearchBarModel { 125 | m.height = height 126 | return m 127 | } 128 | 129 | func (m SearchBarModel) SetResultCount(count int) SearchBarModel { 130 | m.resultCount = count 131 | return m 132 | } 133 | 134 | func (m SearchBarModel) SetValue(input string) SearchBarModel { 135 | m.input.SetValue(input) 136 | return m 137 | } 138 | 139 | func (m SearchBarModel) Value() string { 140 | return m.input.Value() 141 | } 142 | 143 | func (m SearchBarModel) View() string { 144 | left := m.input.View() 145 | right := m.resultCountStr() 146 | 147 | rightWidth := lipgloss.Width(right) 148 | spaceBetween := 1 149 | 150 | maxLeftWidth := max(m.width-rightWidth-spaceBetween, 0) 151 | leftWidth := lipgloss.Width(left) 152 | if leftWidth > maxLeftWidth { 153 | left = truncateString(left, maxLeftWidth) 154 | } 155 | 156 | padding := max(m.width-lipgloss.Width(left)-rightWidth, 0) 157 | 158 | style := inactiveBorderStyle 159 | if m.focused { 160 | style = focusedBorderStyle 161 | } 162 | 163 | return style.Width(m.width).Render(left + strings.Repeat(" ", padding) + right) 164 | } 165 | 166 | func (m SearchBarModel) resultCountStr() string { 167 | if m.input.Value() != "" { 168 | return fmt.Sprintf("%d/%d", m.resultCount, m.totalCount) 169 | } 170 | 171 | return "" 172 | } 173 | 174 | func truncateString(s string, width int) string { 175 | runes := []rune(s) 176 | if len(runes) <= width { 177 | return s 178 | } 179 | return string(runes[:width]) 180 | } 181 | 182 | type Debouncer struct { 183 | ID int 184 | } 185 | 186 | func (d *Debouncer) Tick(dur time.Duration, msg func() tea.Msg) tea.Cmd { 187 | d.ID++ 188 | newDebounceID := d.ID 189 | 190 | return tea.Tick(dur, func(time.Time) tea.Msg { 191 | return DebounceMsg{ 192 | ID: newDebounceID, 193 | Msg: msg(), 194 | } 195 | }) 196 | } 197 | 198 | type DebounceMsg struct { 199 | ID int 200 | Msg tea.Msg 201 | } 202 | 203 | type searchChangedMsg string 204 | -------------------------------------------------------------------------------- /internal/config/config.go: -------------------------------------------------------------------------------- 1 | package config 2 | 3 | import ( 4 | "errors" 5 | "fmt" 6 | "os" 7 | "path/filepath" 8 | "regexp" 9 | 10 | "github.com/fatih/color" 11 | "github.com/knadh/koanf/parsers/toml/v2" 12 | "github.com/knadh/koanf/providers/file" 13 | "github.com/knadh/koanf/v2" 14 | ) 15 | 16 | type Config struct { 17 | MinScore int64 `koanf:"min_score"` 18 | DebounceTime int64 `koanf:"debounce_time"` 19 | DefaultScope string `koanf:"default_scope"` 20 | FormatterCmd string `koanf:"formatter_cmd"` 21 | 22 | Scopes map[string]Scope `koanf:"scopes"` 23 | 24 | // Origins of a set configuration value, used for tracking 25 | // when/where the most recent value of a field was set 26 | // for debugging configurations. 27 | fieldOrigins map[string]string 28 | } 29 | 30 | func NewConfig() *Config { 31 | return &Config{ 32 | MinScore: 1, 33 | DebounceTime: 25, 34 | FormatterCmd: "nixfmt", 35 | 36 | Scopes: make(map[string]Scope), 37 | } 38 | } 39 | 40 | func ParseConfig(location ...string) (*Config, error) { 41 | k := koanf.New(".") 42 | 43 | fieldOrigins := make(map[string]string) 44 | 45 | for _, loc := range location { 46 | if _, err := os.Stat(loc); err != nil { 47 | if errors.Is(err, os.ErrNotExist) { 48 | continue 49 | } 50 | 51 | return nil, err 52 | } 53 | 54 | fileK := koanf.New(".") 55 | 56 | err := fileK.Load(file.Provider(loc), toml.Parser()) 57 | if err != nil { 58 | return nil, err 59 | } 60 | 61 | for _, key := range fileK.Keys() { 62 | fieldOrigins[key] = loc 63 | } 64 | 65 | // Also load incomplete scope keys into the field origins, since 66 | // scopes without proper definitions can technically exist. 67 | if scopesMap, ok := fileK.Get("scopes").(map[string]interface{}); ok { 68 | for scopeName := range scopesMap { 69 | scopeKey := fmt.Sprintf("scopes.%s", scopeName) 70 | fieldOrigins[scopeKey] = loc 71 | } 72 | } 73 | 74 | if err := k.Merge(fileK); err != nil { 75 | return nil, err 76 | } 77 | 78 | } 79 | 80 | cfg := NewConfig() 81 | cfg.fieldOrigins = fieldOrigins 82 | 83 | if err := k.Unmarshal("", cfg); err != nil { 84 | return nil, err 85 | } 86 | 87 | for name, scope := range cfg.Scopes { 88 | scope.Name = name 89 | cfg.Scopes[name] = scope 90 | } 91 | 92 | return cfg, nil 93 | } 94 | 95 | var optionTemplateRegex = regexp.MustCompile(`{{\s*-?\s*\.Option\b[^}]*}}`) 96 | 97 | type ValidationError struct { 98 | Msg string 99 | Origin string 100 | } 101 | 102 | func (e ValidationError) Error() string { 103 | msg := e.Msg 104 | if e.Origin != "" { 105 | msg += "\n\n" + color.YellowString("hint: this setting was last defined in %v", e.Origin) 106 | } 107 | return msg 108 | } 109 | 110 | func (c *Config) Validate() error { 111 | for s, v := range c.Scopes { 112 | if v.OptionsListCmd == "" && v.OptionsListFile == "" { 113 | return ValidationError{ 114 | Msg: fmt.Sprintf("no option list source defined for scope '%v'", s), 115 | Origin: c.FieldOrigin(fmt.Sprintf("scopes.%v", s)), 116 | } 117 | } 118 | } 119 | 120 | if c.DefaultScope != "" { 121 | foundScope := false 122 | for n := range c.Scopes { 123 | if n == c.DefaultScope { 124 | foundScope = true 125 | break 126 | } 127 | } 128 | 129 | if !foundScope { 130 | return ValidationError{ 131 | Msg: fmt.Sprintf("default scope '%v' not found", c.DefaultScope), 132 | Origin: c.FieldOrigin("default_scope"), 133 | } 134 | } 135 | } 136 | 137 | for s, v := range c.Scopes { 138 | if v.EvaluatorCmd == "" { 139 | continue 140 | } 141 | 142 | matches := optionTemplateRegex.FindAllString(v.EvaluatorCmd, -1) 143 | if len(matches) != 1 { 144 | origin := c.FieldOrigin(fmt.Sprintf("scopes.%v.evaluator", s)) 145 | if len(matches) == 0 { 146 | return ValidationError{ 147 | Msg: fmt.Sprintf("evaluator for scope '%v' does not contain the placeholder {{ .Option }}", s), 148 | Origin: origin, 149 | } 150 | } else { 151 | return ValidationError{ 152 | Msg: fmt.Sprintf("multiple instances of {{ .Option }} placeholder in evaluator for scope '%v'", s), 153 | Origin: origin, 154 | } 155 | } 156 | } 157 | } 158 | 159 | return nil 160 | } 161 | 162 | func (c *Config) FieldOrigin(key string) string { 163 | if c.fieldOrigins == nil { 164 | return "" 165 | } 166 | 167 | return c.fieldOrigins[key] 168 | } 169 | 170 | var DefaultConfigLocations = []string{ 171 | "/etc/optnix/config.toml", 172 | // User config path filled in by init(), depending on `XDG_CONFIG_HOME` presence 173 | // optnix.toml in the current directory, if it exists 174 | } 175 | 176 | func init() { 177 | var homeDirPath string 178 | if xdgConfigHome := os.Getenv("XDG_CONFIG_HOME"); xdgConfigHome != "" { 179 | homeDirPath = filepath.Join(xdgConfigHome, "optnix", "config.toml") 180 | } else if home := os.Getenv("HOME"); home != "" { 181 | homeDirPath = filepath.Join(home, ".config", "optnix", "config.toml") 182 | } 183 | 184 | if homeDirPath != "" { 185 | DefaultConfigLocations = append(DefaultConfigLocations, homeDirPath) 186 | } 187 | 188 | DefaultConfigLocations = append(DefaultConfigLocations, "optnix.toml") 189 | } 190 | -------------------------------------------------------------------------------- /doc/src/reference.md: -------------------------------------------------------------------------------- 1 | # API Reference 2 | 3 | ## `mkLib` 4 | 5 | `mkLib :: pkgs -> set` 6 | 7 | Creates an `optnixLib` instance using an instantiated `pkgs` set. 8 | 9 | ## `optnixLib` 10 | 11 | `optnixLib` represents an instantiated value crated by `mkLib` above. 12 | 13 | ### `optnixLib.mkOptionsList` 14 | 15 | `mkOptionsList :: AttrSet -> Derivation` 16 | 17 | Generates an `options.json` derivation that contains a JSON options list from a 18 | provided options attribute set that can be linked into various Nix module 19 | systems. 20 | 21 | A base (but common pattern) of usage: 22 | 23 | ```nix 24 | {options, pkgs, ...}: let 25 | # Adapt this to wherever `optnix` came from 26 | optnixLib = optnix.mkLib pkgs; 27 | optionsList = mkOptionsList { 28 | inherit options; 29 | excluded = []; # Add problematic eval'd paths here 30 | }; 31 | in { 32 | # use here... 33 | } 34 | ``` 35 | 36 | Arguments are passed as a single attribute set in a named manner. 37 | 38 | #### Required Attributes 39 | 40 | - `options :: AttrSet` :: The options attrset to generate an option list for 41 | 42 | #### Optional Attributes 43 | 44 | - `transform :: AttrSet -> AttrSet` :: A function to apply to each generated 45 | option in the list, useful for stripping prefixes, where `AttrSet` is a single 46 | option in the list 47 | - `excluded :: [String]` :: A list of dot-paths in the options attribute set to 48 | exclude from the generated options list using `optnixLib.removeNestedAttrs` 49 | 50 | ### `optnixLib.mkOptionsListFromModules` 51 | 52 | `mkOptionsListFromModules :: AttrSet -> Derivation` 53 | 54 | Generates an `options.json` derivation that contains a JSON options list from a 55 | provided list of modules that can be linked into various Nix module systems. 56 | 57 | This can be useful when generating documentation for external modules that are 58 | not necessarily part of the configuration, such as generating option lists for 59 | usage outside of `optnix`, or for generating a system-agnostic documentation 60 | list. 61 | 62 | Arguments are passed as a single attribute set in a named manner. 63 | 64 | #### Arguments 65 | 66 | - `modules :: List[Module]` :: A list of modules to generate an option list for 67 | 68 | ### `optnixLib.combineLists` 69 | 70 | `combineLists :: [Derivation] -> Derivation` 71 | 72 | Combines together multiple JSON file derivations containing option lists (such 73 | as those created by `mkOptionsList`) into a single JSON file. 74 | 75 | `combineLists [optionsList1 optionsList2]` 76 | 77 | Internally uses `jq --slurp` add to merge JSON arrays. 78 | 79 | #### Arguments 80 | 81 | - `[Derivation]` :: A list of JSON file derivations, each containing an option 82 | list, preferably created using `mkOptionsList` 83 | 84 | ### `optnixLib.removeAtPath` 85 | 86 | `removeAtPath :: String -> AttrSet -> AttrSet` 87 | 88 | Recursively remove a nested attribute from an attrset, following a string array 89 | containing the path to remove. 90 | 91 | This can be useful for removing problematic options (i.e. ones that fail 92 | evaluation) in a custom manner, and is also used internally by the `excluded` 93 | parameter of `mkOptionsList`. 94 | 95 | `removeAtPath "services.nginx" options` 96 | 97 | This example will return the same attrset config, but with the `services.nginx` 98 | subtree removed. If a path does not exist or is not an attrset, it is left 99 | untouched. 100 | 101 | #### Arguments 102 | 103 | - `String` :: A dot-path string representing the attribute path to remove from 104 | the attrset 105 | - `AttrSet` :: The attrset to remove attributes recursively from 106 | 107 | ### `optnixLib.removeNestedAttrs` 108 | 109 | `removeNestedAttrs :: [String] -> AttrSet -> AttrSet` 110 | 111 | Recursively remove multiple attributes using `optnixLib.removeAtPath`. 112 | 113 | `removeNestedAttrs ["programs.chromium" "services.emacs"] options` 114 | 115 | This example will return the same attrset config, but with the `services.emacs` 116 | and `programs.chromium` subtrees removed. If a path does not exist or is not an 117 | attrset, it is left untouched. 118 | 119 | #### Arguments 120 | 121 | - `[String]` :: A list of dot-path strings representing the attribute paths to 122 | remove from the attrset 123 | - `AttrSet` :: The attrset to remove attributes recursively from 124 | 125 | ### `optnixLib.hm` 126 | 127 | Functions for `optnixLib` specifically related to 128 | [`home-manager`](https://github.com/nix-community/home-manager). 129 | 130 | ### `optnixLib.hm.mkOptionsListFromHMSource` 131 | 132 | `mkOptionsListFromHMSource :: AttrSet -> Derivation` 133 | 134 | Generate a JSON options list given a `home-manager` source tree, alongside 135 | additional `home-manager` modules if desired. 136 | 137 | This implementation is adapted from Home Manager's internal documentation 138 | generation pipeline and simplified for `optnix` usage. Prefer using 139 | `mkOptionsList` with an explicit instantiated `options` attribute set if 140 | possible. 141 | 142 | #### Required Attributes 143 | 144 | - `home-manager :: Derivation` :: The derivation containing `home-manager`, 145 | usually sourced using `fetchTarball`, a Nix channel, or a flake input 146 | 147 | #### Optional Attributes 148 | 149 | - `modules :: List[Module]` :: A list of extra modules to additionally evaluate 150 | when generating the option list 151 | -------------------------------------------------------------------------------- /nix/lib.nix: -------------------------------------------------------------------------------- 1 | pkgs: let 2 | inherit (pkgs) lib; 3 | 4 | parsePath = pathStr: lib.splitString "." pathStr; 5 | 6 | removeNestedAttrs = paths: set: lib.foldl' (s: p: removeAtPath (parsePath p) s) set paths; 7 | 8 | removeAtPath = path: set: 9 | if path == [] 10 | then set 11 | else let 12 | key = builtins.head path; 13 | rest = builtins.tail path; 14 | sub = set.${key} or null; 15 | in 16 | if rest == [] 17 | then builtins.removeAttrs set [key] 18 | else if builtins.isAttrs sub 19 | then 20 | set 21 | // { 22 | ${key} = removeAtPath rest sub; 23 | } 24 | else set; 25 | 26 | /* 27 | Combine together multiple option lists that were created using 28 | any of the `mkOptionsList*` functions defined in this library. 29 | 30 | @param lists list of options.json list derivations 31 | @return combined options.json file 32 | */ 33 | combineLists = lists: let 34 | in 35 | pkgs.runCommand "options.json" { 36 | nativeBuildInputs = [pkgs.jq]; 37 | } '' 38 | jq --slurp add ${lib.concatMapStringsSep " " (drv: "${drv}") lists} > $out 39 | ''; 40 | 41 | /* 42 | Create an options list JSON file from an options attribute set. 43 | 44 | @param options options attribute set to generate options list from 45 | @param excluded paths to options or sets of options to skip over 46 | @param transform function to apply to each option 47 | @return a derivation that builds an options.json file 48 | */ 49 | mkOptionsList = { 50 | options, 51 | transform ? lib.id, 52 | excluded ? [], 53 | }: let 54 | options' = removeAtPath excluded options; 55 | 56 | rawOptions = 57 | map transform (lib.optionAttrSetToDocList options'); 58 | 59 | # Yes, we're running the filter for excluded twice. But this is 60 | # only because some paths exist in here that cannot otherwise be 61 | # removed by removeAtPath, such as _module.args. 62 | filteredOptions = 63 | lib.filter ( 64 | opt: 65 | opt.visible 66 | && !opt.internal 67 | && !(builtins.elem opt.name excluded) 68 | ) 69 | rawOptions; 70 | 71 | optionsJSON = builtins.unsafeDiscardStringContext (builtins.toJSON filteredOptions); 72 | in 73 | pkgs.writeText "options.json" optionsJSON; 74 | 75 | /* 76 | Create an options list JSON file from a list of modules. 77 | 78 | @param modules list of modules containing options 79 | @return a derivation that builds an options.json file 80 | */ 81 | mkOptionsListFromModules = { 82 | modules, 83 | specialArgs ? {}, 84 | excluded ? [], 85 | }: let 86 | eval'd = lib.evalModules { 87 | modules = 88 | modules 89 | ++ [ 90 | {_module.check = false;} 91 | ]; 92 | inherit specialArgs; 93 | }; 94 | in 95 | mkOptionsList { 96 | inherit (eval'd) options; 97 | inherit excluded; 98 | }; 99 | 100 | hm = { 101 | /* 102 | Create an options list JSON file from a Home Manager source list of modules. 103 | 104 | This code is based on the implementation found directly in Home Manager's 105 | documentation generation, without using nixosOptionsDoc. 106 | 107 | If an options attribute set is exposed that can be evaluated, prefer using that over this. 108 | 109 | @param home-manager path to a home-manager source (like from a flake input or tarball) 110 | @param modules list of extra modules containing options to include 111 | @return a derivation that builds an options.json file 112 | */ 113 | mkOptionsListFromHMSource = { 114 | home-manager, 115 | modules ? [], 116 | }: let 117 | hmLib = import "${home-manager}/modules/lib/stdlib-extended.nix" lib; 118 | 119 | hmModules = import "${home-manager}/modules/modules.nix" { 120 | inherit pkgs; 121 | lib = hmLib; 122 | check = false; 123 | }; 124 | 125 | scrubDerivations = prefixPath: attrs: let 126 | scrubDerivation = name: value: let 127 | pkgAttrName = prefixPath + "." + name; 128 | in 129 | if lib.isAttrs value 130 | then 131 | scrubDerivations pkgAttrName value 132 | // lib.optionalAttrs (lib.isDerivation value) { 133 | outPath = "\${${pkgAttrName}}"; 134 | } 135 | else value; 136 | in 137 | lib.mapAttrs scrubDerivation attrs; 138 | 139 | # Make sure the used package is scrubbed to avoid actually 140 | # instantiating derivations. 141 | scrubbedPkgsModule = { 142 | imports = [ 143 | { 144 | _module.args = { 145 | pkgs = lib.mkForce (scrubDerivations "pkgs" pkgs); 146 | pkgs_i686 = lib.mkForce {}; 147 | }; 148 | } 149 | ]; 150 | }; 151 | 152 | dummyHomeUserModule = { 153 | home.stateVersion = pkgs.lib.trivial.release; 154 | home.homeDirectory = "/home/dummy"; 155 | }; 156 | 157 | allModules = 158 | hmModules 159 | ++ modules 160 | ++ [ 161 | scrubbedPkgsModule 162 | dummyHomeUserModule 163 | ]; 164 | 165 | options = 166 | (hmLib.evalModules { 167 | modules = allModules; 168 | class = "homeManager"; 169 | }).options; 170 | in 171 | mkOptionsList { 172 | inherit options; 173 | }; 174 | }; 175 | in { 176 | inherit 177 | removeAtPath 178 | removeNestedAttrs 179 | combineLists 180 | mkOptionsList 181 | mkOptionsListFromModules 182 | hm 183 | ; 184 | } 185 | -------------------------------------------------------------------------------- /tui/results.go: -------------------------------------------------------------------------------- 1 | package tui 2 | 3 | import ( 4 | "fmt" 5 | "strings" 6 | 7 | tea "github.com/charmbracelet/bubbletea" 8 | "github.com/charmbracelet/lipgloss" 9 | "github.com/muesli/termenv" 10 | "github.com/sahilm/fuzzy" 11 | "snare.dev/optnix/option" 12 | ) 13 | 14 | var ( 15 | selectedResultStyle = lipgloss.NewStyle(). 16 | Background(lipgloss.ANSIColor(termenv.ANSIBlue)). 17 | Foreground(lipgloss.ANSIColor(termenv.ANSIBrightWhite)). 18 | Padding(0, 2) 19 | resultItemStyle = lipgloss.NewStyle().Padding(0, 2) 20 | matchedCharStyle = lipgloss.NewStyle(). 21 | Foreground(lipgloss.ANSIColor(termenv.ANSIGreen)). 22 | Bold(true) 23 | unmatchedCharStyle = lipgloss.NewStyle(). 24 | Foreground(lipgloss.ANSIColor(termenv.ANSIBrightWhite)) 25 | ) 26 | 27 | type ResultListModel struct { 28 | scopeName string 29 | options option.NixosOptionSource 30 | filtered []fuzzy.Match 31 | input string 32 | 33 | focused bool 34 | 35 | searchErr error 36 | 37 | selected int 38 | start int 39 | 40 | width int 41 | height int 42 | } 43 | 44 | func NewResultListModel(options option.NixosOptionSource, scopeName string) ResultListModel { 45 | return ResultListModel{ 46 | scopeName: scopeName, 47 | options: options, 48 | } 49 | } 50 | 51 | func (m ResultListModel) SetResultList(matches []fuzzy.Match) ResultListModel { 52 | m.filtered = matches 53 | return m 54 | } 55 | 56 | func (m ResultListModel) SetQuery(input string) ResultListModel { 57 | m.input = input 58 | return m 59 | } 60 | 61 | func (m ResultListModel) SetSearchError(err error) ResultListModel { 62 | m.searchErr = err 63 | return m 64 | } 65 | 66 | func (m ResultListModel) SetSelectedIndex(index int) ResultListModel { 67 | m.selected = index 68 | 69 | // Also set the starting index for the window. This is needed 70 | // for smooth scrolling. 71 | visible := min(m.visibleResultRows(), len(m.filtered)) 72 | m.start = max(m.selected-(visible-1), 0) 73 | 74 | return m 75 | } 76 | 77 | func (m ResultListModel) SetFocused(focus bool) ResultListModel { 78 | m.focused = focus 79 | return m 80 | } 81 | 82 | func (m ResultListModel) SetWidth(width int) ResultListModel { 83 | m.width = width 84 | return m 85 | } 86 | 87 | func (m ResultListModel) SetHeight(height int) ResultListModel { 88 | m.height = height 89 | return m 90 | } 91 | 92 | // Scroll up one entry in the result list window. Note that 93 | // scrolling up in the results list means less relevant results. 94 | func (m ResultListModel) ScrollUp() ResultListModel { 95 | if !m.focused { 96 | return m 97 | } 98 | 99 | if m.selected > 0 { 100 | m.selected-- 101 | 102 | if m.selected < m.start && m.start > 0 { 103 | m.start-- 104 | } 105 | } 106 | 107 | return m 108 | } 109 | 110 | func (m ResultListModel) ScrollDown() ResultListModel { 111 | if !m.focused { 112 | return m 113 | } 114 | 115 | if m.selected < len(m.filtered)-1 { 116 | m.selected++ 117 | 118 | if m.selected >= m.start+m.visibleResultRows() { 119 | m.start++ 120 | } 121 | } 122 | 123 | return m 124 | } 125 | 126 | func (m ResultListModel) GetSelectedOption() *option.NixosOption { 127 | if m.selected >= 0 && len(m.filtered) > 0 { 128 | optionIdx := m.filtered[m.selected].Index 129 | return &m.options[optionIdx] 130 | } 131 | 132 | return nil 133 | } 134 | 135 | func (m ResultListModel) Update(msg tea.Msg) (ResultListModel, tea.Cmd) { 136 | switch msg := msg.(type) { 137 | case tea.KeyMsg: 138 | switch msg.String() { 139 | case "up": 140 | m = m.ScrollUp() 141 | 142 | case "down": 143 | m = m.ScrollDown() 144 | 145 | case "enter": 146 | if len(m.filtered) < 1 { 147 | return m, nil 148 | } 149 | 150 | changeModeCmd := func() tea.Msg { 151 | o := m.options[m.filtered[m.selected].Index] 152 | return EvalValueStartMsg{Option: o.Name} 153 | } 154 | 155 | return m, changeModeCmd 156 | } 157 | 158 | case ChangeScopeMsg: 159 | m.scopeName = msg.Name 160 | m.options = msg.Options 161 | m.selected = 0 162 | m.start = 0 163 | m.filtered = nil 164 | } 165 | 166 | // Make sure that resizes don't result in the start row ending 167 | // up past an impossible index (i.e. there will be empty space 168 | // on the bottom of the screen). 169 | maxStart := max(len(m.filtered)-m.visibleResultRows(), 0) 170 | if m.start > maxStart { 171 | m.start = maxStart 172 | } 173 | 174 | return m, nil 175 | } 176 | 177 | func (m ResultListModel) View() string { 178 | var titleText string 179 | if m.scopeName != "" { 180 | titleText = fmt.Sprintf("Available Options (%v)", m.scopeName) 181 | } else { 182 | titleText = "Available Options" 183 | } 184 | 185 | title := lipgloss.PlaceHorizontal(m.width, lipgloss.Center, titleStyle.Render(titleText)) 186 | 187 | height := m.visibleResultRows() 188 | 189 | end := min(m.start+height, len(m.filtered)) 190 | 191 | lines := []string{} 192 | 193 | // Add padding, if necessary. 194 | linesOfPadding := height - len(m.filtered) 195 | for range linesOfPadding { 196 | lines = append(lines, "") 197 | } 198 | 199 | for i := m.start; i < end; i++ { 200 | match := m.filtered[i] 201 | o := m.options[match.Index] 202 | 203 | name := o.Name 204 | matched := map[int]struct{}{} 205 | for _, idx := range match.MatchedIndexes { 206 | matched[idx] = struct{}{} 207 | } 208 | 209 | style := resultItemStyle 210 | if i == m.selected { 211 | style = selectedResultStyle 212 | } 213 | 214 | var b strings.Builder 215 | for j, r := range name { 216 | s := unmatchedCharStyle 217 | if _, ok := matched[j]; ok { 218 | s = matchedCharStyle 219 | } 220 | 221 | b.WriteString(s.Inherit(style).Render(string(r))) 222 | } 223 | 224 | line := style.Width(m.width).MaxHeight(1).Render(b.String()) 225 | lines = append(lines, line) 226 | } 227 | 228 | if len(m.filtered) == 0 || m.searchErr != nil { 229 | for i := range lines { 230 | lines[i] = "" 231 | } 232 | 233 | inputLen := len(m.input) 234 | 235 | if len(lines) > 2 { 236 | if m.searchErr != nil { 237 | lines[2] = fmt.Sprintf(" %s", m.searchErr) 238 | } else if inputLen > 3 { 239 | lines[2] = " No results found." 240 | } else if inputLen > 0 { 241 | lines[2] = " Please provide more precise search terms." 242 | } 243 | } 244 | } 245 | 246 | body := strings.Join(lines, "\n") 247 | 248 | style := inactiveBorderStyle 249 | if m.focused { 250 | style = focusedBorderStyle 251 | } 252 | 253 | return style.Width(m.width).Render(title + "\n" + body) 254 | } 255 | 256 | func (m ResultListModel) visibleResultRows() int { 257 | // One for title, two for borders 258 | return m.height - 3 259 | } 260 | -------------------------------------------------------------------------------- /tui/select_scope.go: -------------------------------------------------------------------------------- 1 | package tui 2 | 3 | import ( 4 | "fmt" 5 | "io" 6 | "slices" 7 | "strings" 8 | 9 | "github.com/charmbracelet/bubbles/list" 10 | "github.com/charmbracelet/bubbles/spinner" 11 | "github.com/charmbracelet/bubbles/viewport" 12 | tea "github.com/charmbracelet/bubbletea" 13 | "github.com/charmbracelet/lipgloss" 14 | "github.com/muesli/termenv" 15 | "snare.dev/optnix/option" 16 | ) 17 | 18 | var ( 19 | ansiRed = lipgloss.ANSIColor(termenv.ANSIRed) 20 | ansiYellow = lipgloss.ANSIColor(termenv.ANSIYellow) 21 | ansiGreen = lipgloss.ANSIColor(termenv.ANSIGreen) 22 | ansiWhite = lipgloss.ANSIColor(termenv.ANSIBrightWhite) 23 | ansiBlue = lipgloss.ANSIColor(termenv.ANSIBlue) 24 | ansiCyan = lipgloss.ANSIColor(termenv.ANSICyan) 25 | ansiMagenta = lipgloss.ANSIColor(termenv.ANSIMagenta) 26 | 27 | itemStyle = lipgloss.NewStyle().MarginLeft(4).PaddingLeft(1).Border(lipgloss.NormalBorder(), false, false, false, true) 28 | currentItemStyle = lipgloss.NewStyle().MarginLeft(4).PaddingLeft(1).Foreground(ansiGreen).Border(lipgloss.NormalBorder(), false, false, false, true).BorderForeground(ansiGreen) 29 | selectedItemStyle = lipgloss.NewStyle().MarginLeft(4).PaddingLeft(1).Foreground(ansiYellow).Border(lipgloss.NormalBorder(), false, false, false, true).BorderForeground(ansiYellow) 30 | errorTextStyle = lipgloss.NewStyle().Foreground(ansiRed) 31 | attrStyle = lipgloss.NewStyle().Foreground(ansiCyan) 32 | boldStyle = lipgloss.NewStyle().Bold(true) 33 | italicStyle = lipgloss.NewStyle().Italic(true) 34 | ) 35 | 36 | type LoadScopeStartMsg option.Scope 37 | 38 | type LoadScopeFinishedMsg struct { 39 | Name string 40 | Options option.NixosOptionSource 41 | Evaluator option.EvaluatorFunc 42 | Err error 43 | } 44 | 45 | type ChangeScopeMsg struct { 46 | Name string 47 | Options option.NixosOptionSource 48 | Evaluator option.EvaluatorFunc 49 | } 50 | 51 | type scopeItem struct { 52 | Scope option.Scope 53 | Selected bool 54 | } 55 | 56 | func (i scopeItem) FilterValue() string { 57 | scope := i.Scope 58 | return fmt.Sprintf("%v %v", scope.Name, scope.Description) 59 | } 60 | 61 | type scopeItemDelegate struct{} 62 | 63 | func (d scopeItemDelegate) Height() int { return 2 } 64 | 65 | func (d scopeItemDelegate) Spacing() int { return 1 } 66 | 67 | func (d scopeItemDelegate) Update(_ tea.Msg, _ *list.Model) tea.Cmd { return nil } 68 | 69 | var selectedText = italicStyle.Render(" (selected)") 70 | 71 | func (d scopeItemDelegate) Render(w io.Writer, m list.Model, index int, listItem list.Item) { 72 | i, ok := listItem.(scopeItem) 73 | if !ok { 74 | return 75 | } 76 | 77 | s := i.Scope 78 | 79 | var str string 80 | if !i.Selected { 81 | str = boldStyle.Render(s.Name) 82 | } else { 83 | str = boldStyle.Render(fmt.Sprintf("%v%v", s.Name, selectedText)) 84 | } 85 | 86 | str += fmt.Sprintf("\n%s :: %s", attrStyle.Render("Description"), s.Description) 87 | 88 | fn := itemStyle.Render 89 | 90 | if index == m.Index() { 91 | fn = func(s ...string) string { 92 | return currentItemStyle.Render(strings.Join(s, " ")) 93 | } 94 | } else if i.Selected { 95 | fn = func(s ...string) string { 96 | return selectedItemStyle.Render(strings.Join(s, " ")) 97 | } 98 | } 99 | 100 | _, _ = fmt.Fprint(w, fn(str)) 101 | } 102 | 103 | type SelectScopeModel struct { 104 | vp viewport.Model 105 | spinner spinner.Model 106 | list list.Model 107 | 108 | width int 109 | height int 110 | 111 | scopes []option.Scope 112 | selectedScope string 113 | 114 | loading bool 115 | err error 116 | } 117 | 118 | func NewSelectScopeModel(scopes []option.Scope, selectedScope string) SelectScopeModel { 119 | slices.SortFunc(scopes, func(a, b option.Scope) int { 120 | return strings.Compare(a.Name, b.Name) 121 | }) 122 | 123 | items := make([]list.Item, len(scopes)) 124 | for i, s := range scopes { 125 | selected := s.Name == selectedScope 126 | 127 | items[i] = scopeItem{ 128 | Scope: s, 129 | Selected: selected, 130 | } 131 | } 132 | 133 | l := list.New(items, scopeItemDelegate{}, 0, 0) 134 | 135 | l.Title = "Available Scopes" 136 | 137 | l.Styles.Title = lipgloss.NewStyle().MarginLeft(2).Background(ansiRed).Foreground(ansiWhite) 138 | l.Styles.PaginationStyle = list.DefaultStyles().PaginationStyle.PaddingLeft(4) 139 | l.Styles.HelpStyle = list.DefaultStyles().HelpStyle.PaddingLeft(4).PaddingBottom(1) 140 | l.Styles.StatusBar = lipgloss.NewStyle().PaddingLeft(4).PaddingBottom(1).Foreground(ansiMagenta) 141 | 142 | l.FilterInput.PromptStyle = lipgloss.NewStyle().Foreground(ansiBlue).Bold(true).PaddingLeft(2) 143 | l.FilterInput.TextStyle = lipgloss.NewStyle().Foreground(ansiBlue) 144 | l.FilterInput.Cursor.Style = lipgloss.NewStyle().Foreground(ansiBlue) 145 | l.Styles.StatusBarActiveFilter = lipgloss.NewStyle().Foreground(ansiBlue) 146 | l.Styles.StatusBarFilterCount = lipgloss.NewStyle().Foreground(ansiBlue) 147 | 148 | vp := viewport.New(0, 0) 149 | vp.SetHorizontalStep(1) 150 | vp.Style = focusedBorderStyle 151 | 152 | sp := spinner.New() 153 | sp.Spinner = spinner.Line 154 | sp.Style = spinnerStyle 155 | 156 | return SelectScopeModel{ 157 | list: l, 158 | vp: vp, 159 | spinner: sp, 160 | 161 | scopes: scopes, 162 | selectedScope: selectedScope, 163 | } 164 | } 165 | 166 | func (m SelectScopeModel) Update(msg tea.Msg) (SelectScopeModel, tea.Cmd) { 167 | var cmds []tea.Cmd 168 | 169 | switch msg := msg.(type) { 170 | case tea.KeyMsg: 171 | if m.list.FilterState() == list.Filtering { 172 | break 173 | } 174 | 175 | if m.err != nil { 176 | m.err = nil 177 | break 178 | } 179 | 180 | switch msg.String() { 181 | case "q", "esc": 182 | return m, func() tea.Msg { 183 | return ChangeViewModeMsg(ViewModeSearch) 184 | } 185 | 186 | case "enter": 187 | item := m.list.SelectedItem().(scopeItem) 188 | if item.Selected { 189 | break 190 | } 191 | 192 | return m, func() tea.Msg { 193 | return LoadScopeStartMsg(item.Scope) 194 | } 195 | } 196 | 197 | case tea.WindowSizeMsg: 198 | m.list.SetWidth(msg.Width) 199 | m.list.SetHeight(msg.Height - 2) 200 | 201 | m.width = msg.Width - 4 202 | m.height = msg.Height - 4 203 | 204 | m.vp.Width = m.width 205 | m.vp.Height = m.height 206 | 207 | return m, nil 208 | 209 | case LoadScopeStartMsg: 210 | m.loading = true 211 | m.err = nil 212 | m.selectedScope = msg.Name 213 | 214 | items := make([]list.Item, len(m.scopes)) 215 | for i, s := range m.scopes { 216 | items[i] = scopeItem{ 217 | Scope: s, 218 | Selected: s.Name == m.selectedScope, 219 | } 220 | } 221 | 222 | cmds = append(cmds, m.spinner.Tick) 223 | cmds = append(cmds, m.list.SetItems(items)) 224 | cmds = append(cmds, func() tea.Msg { 225 | loaded, err := msg.Loader() 226 | return LoadScopeFinishedMsg{ 227 | Name: msg.Name, 228 | Options: loaded, 229 | Err: err, 230 | Evaluator: msg.Evaluator, 231 | } 232 | }) 233 | 234 | return m, tea.Batch(cmds...) 235 | 236 | case spinner.TickMsg: 237 | var cmd tea.Cmd 238 | m.spinner, cmd = m.spinner.Update(msg) 239 | cmds = append(cmds, cmd) 240 | 241 | case LoadScopeFinishedMsg: 242 | m.loading = false 243 | m.err = msg.Err 244 | 245 | if m.err == nil { 246 | return m, func() tea.Msg { 247 | return ChangeScopeMsg{ 248 | Name: msg.Name, 249 | Options: msg.Options, 250 | Evaluator: msg.Evaluator, 251 | } 252 | } 253 | } 254 | } 255 | 256 | if m.loading { 257 | m.vp.SetContent("Loading..." + m.spinner.View()) 258 | } else if m.err != nil { 259 | m.vp.SetContent(errorTextStyle.Render(fmt.Sprintf("error loading scope: %v\n\nPress any key to go back.\n", m.err))) 260 | } 261 | 262 | var vpCmd tea.Cmd 263 | m.vp, vpCmd = m.vp.Update(msg) 264 | cmds = append(cmds, vpCmd) 265 | 266 | var listCmd tea.Cmd 267 | m.list, listCmd = m.list.Update(msg) 268 | cmds = append(cmds, listCmd) 269 | 270 | return m, tea.Batch(cmds...) 271 | } 272 | 273 | func (m SelectScopeModel) View() string { 274 | if m.loading || m.err != nil { 275 | return m.vp.View() 276 | } 277 | 278 | return "\n\n" + m.list.View() 279 | } 280 | -------------------------------------------------------------------------------- /tui/tui.go: -------------------------------------------------------------------------------- 1 | package tui 2 | 3 | import ( 4 | "fmt" 5 | "regexp" 6 | "slices" 7 | "unicode" 8 | 9 | tea "github.com/charmbracelet/bubbletea" 10 | "github.com/charmbracelet/lipgloss" 11 | "github.com/muesli/termenv" 12 | "github.com/sahilm/fuzzy" 13 | cmdUtils "snare.dev/optnix/internal/cmd/utils" 14 | "snare.dev/optnix/internal/utils" 15 | "snare.dev/optnix/option" 16 | ) 17 | 18 | var ( 19 | titleStyle = lipgloss.NewStyle().Bold(true).Align(lipgloss.Center) 20 | 21 | inactiveBorderStyle = lipgloss.NewStyle().Border(lipgloss.NormalBorder()) 22 | focusedBorderStyle = lipgloss.NewStyle(). 23 | Border(lipgloss.NormalBorder()). 24 | BorderForeground(lipgloss.ANSIColor(termenv.ANSIMagenta)) 25 | titleRuleStyle = lipgloss.NewStyle(). 26 | Border(lipgloss.NormalBorder()). 27 | BorderForeground(lipgloss.ANSIColor(termenv.ANSIWhite)). 28 | BorderTop(true). 29 | BorderRight(false). 30 | BorderBottom(false). 31 | BorderLeft(false) 32 | 33 | marginStyle = lipgloss.NewStyle().Margin(2, 2, 0, 2) 34 | hintStyle = lipgloss.NewStyle(). 35 | Foreground(lipgloss.ANSIColor(termenv.ANSIYellow)) // Soft gray 36 | 37 | ) 38 | 39 | type Model struct { 40 | focus FocusArea 41 | mode ViewMode 42 | 43 | options option.NixosOptionSource 44 | enableScopeSwitching bool 45 | 46 | filtered []fuzzy.Match 47 | minScore int64 48 | 49 | width int 50 | height int 51 | 52 | search SearchBarModel 53 | results ResultListModel 54 | preview PreviewModel 55 | selectScope SelectScopeModel 56 | eval EvalValueModel 57 | help HelpModel 58 | } 59 | 60 | type ViewMode int 61 | 62 | const ( 63 | ViewModeSearch = iota 64 | ViewModeSelectScope 65 | ViewModeEvalValue 66 | ViewModeHelp 67 | ) 68 | 69 | type ChangeViewModeMsg ViewMode 70 | 71 | type FocusArea int 72 | 73 | const ( 74 | FocusAreaResults FocusArea = iota 75 | FocusAreaPreview 76 | ) 77 | 78 | func NewModel( 79 | scopes []option.Scope, 80 | selectedScope string, 81 | minScore int64, 82 | debounceTime int64, 83 | initialInput string, 84 | ) (*Model, error) { 85 | var scope *option.Scope 86 | for _, s := range scopes { 87 | if selectedScope == s.Name { 88 | scope = &s 89 | break 90 | } 91 | } 92 | 93 | if scope == nil { 94 | return nil, fmt.Errorf("scope '%v' not found in configuration", selectedScope) 95 | } 96 | 97 | options, err := scope.Loader() 98 | if err != nil { 99 | return nil, err 100 | } 101 | 102 | preview := NewPreviewModel() 103 | search := NewSearchBarModel(len(options), debounceTime). 104 | SetFocused(true). 105 | SetValue(initialInput) 106 | results := NewResultListModel(options, scope.Name). 107 | SetFocused(true) 108 | selectScope := NewSelectScopeModel(scopes, scope.Name) 109 | eval := NewEvalValueModel(scope.Evaluator) 110 | help := NewHelpModel() 111 | 112 | return &Model{ 113 | mode: ViewModeSearch, 114 | focus: FocusAreaResults, 115 | 116 | options: options, 117 | enableScopeSwitching: len(scopes) > 1, 118 | 119 | minScore: minScore, 120 | 121 | results: results, 122 | preview: preview, 123 | search: search, 124 | selectScope: selectScope, 125 | eval: eval, 126 | help: help, 127 | }, nil 128 | } 129 | 130 | func (m Model) Init() tea.Cmd { 131 | if m.search.Value() != "" { 132 | return func() tea.Msg { 133 | return RunSearchMsg{ 134 | Query: m.search.Value(), 135 | Mode: SearchModeFuzzy, 136 | } 137 | } 138 | } 139 | 140 | return nil 141 | } 142 | 143 | func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { 144 | switch msg := msg.(type) { 145 | case tea.KeyMsg: 146 | switch msg.String() { 147 | case "ctrl+c": 148 | return m, tea.Quit 149 | case "esc": 150 | if m.mode != ViewModeEvalValue { 151 | return m, tea.Quit 152 | } 153 | } 154 | case tea.WindowSizeMsg: 155 | m = m.updateWindowSize(msg.Width, msg.Height) 156 | 157 | // Always forward resize events to components that need them. 158 | m.eval, _ = m.eval.Update(msg) 159 | m.help, _ = m.help.Update(msg) 160 | m.selectScope, _ = m.selectScope.Update(msg) 161 | 162 | return m, nil 163 | 164 | case ChangeViewModeMsg: 165 | m.mode = ViewMode(msg) 166 | 167 | case EvalValueStartMsg: 168 | m.mode = ViewModeEvalValue 169 | 170 | case ChangeScopeMsg: 171 | m.mode = ViewModeSearch 172 | m.options = msg.Options 173 | m.eval = m.eval.SetEvaluator(msg.Evaluator) 174 | } 175 | 176 | switch m.mode { 177 | case ViewModeSearch: 178 | return m.updateSearch(msg) 179 | case ViewModeEvalValue: 180 | var evalCmd tea.Cmd 181 | m.eval, evalCmd = m.eval.Update(msg) 182 | return m, evalCmd 183 | case ViewModeSelectScope: 184 | var selectModeCmd tea.Cmd 185 | m.selectScope, selectModeCmd = m.selectScope.Update(msg) 186 | return m, selectModeCmd 187 | case ViewModeHelp: 188 | var helpCmd tea.Cmd 189 | m.help, helpCmd = m.help.Update(msg) 190 | return m, helpCmd 191 | } 192 | 193 | return m, nil 194 | } 195 | 196 | func (m Model) updateSearch(msg tea.Msg) (Model, tea.Cmd) { 197 | switch msg := msg.(type) { 198 | case tea.KeyMsg: 199 | switch msg.String() { 200 | case "tab": 201 | m = m.toggleFocus() 202 | 203 | case "ctrl+g": 204 | return m, func() tea.Msg { 205 | return ChangeViewModeMsg(ViewModeHelp) 206 | } 207 | case "ctrl+o": 208 | if !m.enableScopeSwitching { 209 | return m, nil 210 | } 211 | 212 | return m, func() tea.Msg { 213 | return ChangeViewModeMsg(ViewModeSelectScope) 214 | } 215 | } 216 | case RunSearchMsg: 217 | m = m.runSearch(msg.Query, msg.Mode) 218 | m.search = m.search.SetResultCount(len(m.filtered)) 219 | } 220 | 221 | var cmds []tea.Cmd 222 | 223 | var searchCmd tea.Cmd 224 | m.search, searchCmd = m.search.Update(msg) 225 | cmds = append(cmds, searchCmd) 226 | 227 | var resultsCmd tea.Cmd 228 | m.results, resultsCmd = m.results.Update(msg) 229 | cmds = append(cmds, resultsCmd) 230 | 231 | selectedOption := m.results.GetSelectedOption() 232 | m.preview = m.preview.SetOption(selectedOption) 233 | 234 | var previewCmd tea.Cmd 235 | m.preview, previewCmd = m.preview.Update(msg) 236 | cmds = append(cmds, previewCmd) 237 | 238 | return m, tea.Batch(cmds...) 239 | } 240 | 241 | func (m Model) runSearch(query string, mode SearchMode) Model { 242 | m.results = m.results.SetSearchError(nil) 243 | 244 | if len(query) == 0 { 245 | m.filtered = nil 246 | m.results = m.results. 247 | SetQuery(query). 248 | SetResultList(m.filtered). 249 | SetSelectedIndex(len(m.filtered) - 1) 250 | return m 251 | } 252 | 253 | var matches []fuzzy.Match 254 | switch mode { 255 | case SearchModeFuzzy: 256 | allMatches := fuzzy.FindFrom(query, m.options) 257 | matches = utils.FilterMinimumScoreMatches(allMatches, m.minScore) 258 | 259 | // Reverse the filtered match list, since we want more relevant 260 | // options at the bottom of the screen. 261 | slices.Reverse(matches) 262 | case SearchModeRegex: 263 | expr, err := regexp.Compile(query) 264 | if err != nil { 265 | m.results = m.results.SetSearchError(err) 266 | return m 267 | } 268 | matches = regexSearch(m.options, expr) 269 | default: 270 | panic("unhandled search mode") 271 | } 272 | 273 | m.filtered = matches 274 | 275 | m.results = m.results. 276 | SetQuery(query). 277 | SetResultList(m.filtered). 278 | SetSelectedIndex(len(m.filtered) - 1) 279 | 280 | return m 281 | } 282 | 283 | func regexSearch(options option.NixosOptionSource, expr *regexp.Regexp) []fuzzy.Match { 284 | var matches []fuzzy.Match 285 | 286 | for i, o := range options { 287 | matchedCaptureRanges := expr.FindAllStringSubmatchIndex(o.Name, -1) 288 | if len(matchedCaptureRanges) == 0 { 289 | continue 290 | } 291 | 292 | m := fuzzy.Match{} 293 | 294 | m.Index = i 295 | 296 | for _, capture := range matchedCaptureRanges { 297 | start, end := capture[0], capture[1]-1 298 | 299 | capturedIndices := make([]int, end-start+1) 300 | for j := range capturedIndices { 301 | capturedIndices[j] = start + j 302 | } 303 | 304 | m.Str = o.Name 305 | m.MatchedIndexes = append(m.MatchedIndexes, capturedIndices...) 306 | m.Score = calculateRegexScore(o.Name, capturedIndices) 307 | } 308 | 309 | matches = append(matches, m) 310 | } 311 | 312 | slices.SortFunc(matches, func(a, b fuzzy.Match) int { 313 | return a.Score - b.Score 314 | }) 315 | 316 | return matches 317 | } 318 | 319 | func calculateRegexScore(str string, matchedIndexes []int) int { 320 | if len(matchedIndexes) == 0 { 321 | return 0 322 | } 323 | 324 | const ( 325 | consecutiveBonus = 5 326 | wordBoundaryBonus = 10 327 | ) 328 | 329 | // Base score: 1 per matched character 330 | score := len(matchedIndexes) 331 | 332 | // Bonus for consecutive matches 333 | for i := 1; i < len(matchedIndexes); i++ { 334 | if matchedIndexes[i] == matchedIndexes[i-1]+1 { 335 | score += consecutiveBonus 336 | } 337 | } 338 | 339 | // Bonus for matches at start of word boundaries 340 | for _, idx := range matchedIndexes { 341 | if idx == 0 { 342 | // First character is automatically a word boundary 343 | score += wordBoundaryBonus 344 | } else { 345 | // Matched characters after non-alphanumeric ones 346 | // also are word boundaries 347 | char := rune(str[idx-1]) 348 | if !unicode.IsLetter(char) && !unicode.IsDigit(char) { 349 | score += wordBoundaryBonus 350 | } 351 | } 352 | } 353 | 354 | // Normalize by string length 355 | return score * 100 / len(str) 356 | } 357 | 358 | type RunSearchMsg struct { 359 | Query string 360 | Mode SearchMode 361 | } 362 | 363 | func (m Model) toggleFocus() Model { 364 | switch m.focus { 365 | case FocusAreaResults: 366 | m.focus = FocusAreaPreview 367 | 368 | m.results = m.results.SetFocused(false) 369 | m.search = m.search.SetFocused(false) 370 | m.preview = m.preview.SetFocused(true) 371 | case FocusAreaPreview: 372 | m.focus = FocusAreaResults 373 | 374 | m.results = m.results.SetFocused(true) 375 | m.search = m.search.SetFocused(true) 376 | m.preview = m.preview.SetFocused(false) 377 | } 378 | 379 | return m 380 | } 381 | 382 | func (m Model) updateWindowSize(width, height int) Model { 383 | m.width = width 384 | m.height = height 385 | 386 | usableWidth := width - 4 // 2 left + 2 right margins 387 | usableHeight := height - 2 // 2 top margin 388 | 389 | searchHeight := 3 390 | 391 | halfWidth := usableWidth / 2 392 | 393 | m.results = m.results. 394 | SetWidth(halfWidth - 2). // 1 border each side 395 | SetHeight(usableHeight - searchHeight - 2) 396 | 397 | m.search = m.search. 398 | SetWidth(halfWidth - 2). 399 | SetHeight(searchHeight) 400 | 401 | m.preview = m.preview. 402 | SetWidth(halfWidth - 2). 403 | SetHeight(usableHeight - 2) 404 | 405 | return m 406 | } 407 | 408 | func (m Model) View() string { 409 | switch m.mode { 410 | case ViewModeSelectScope: 411 | return marginStyle.Render(m.selectScope.View()) 412 | case ViewModeEvalValue: 413 | return marginStyle.Render(m.eval.View()) 414 | case ViewModeHelp: 415 | return marginStyle.Render(m.help.View()) 416 | } 417 | 418 | results := m.results.View() 419 | search := m.search.View() 420 | preview := m.preview.View() 421 | 422 | left := lipgloss.JoinVertical(lipgloss.Top, results, search) 423 | main := lipgloss.JoinHorizontal(lipgloss.Top, left, preview) 424 | 425 | hint := lipgloss.PlaceHorizontal(m.width, lipgloss.Center, hintStyle.Render("For basic help, press Ctrl-G.")) 426 | 427 | return lipgloss.JoinVertical( 428 | lipgloss.Top, 429 | marginStyle.Render(main), 430 | hint, 431 | ) 432 | } 433 | 434 | type OptionTUIArgs struct { 435 | Scopes []option.Scope 436 | SelectedScopeName string 437 | MinScore int64 438 | DebounceTime int64 439 | InitialInput string 440 | LogFileName string 441 | } 442 | 443 | func OptionTUI(args OptionTUIArgs) error { 444 | if args.LogFileName != "" { 445 | closeLogFile, _ := cmdUtils.ConfigureBubbleTeaLogger(args.LogFileName) 446 | defer closeLogFile() 447 | } 448 | 449 | m, err := NewModel(args.Scopes, args.SelectedScopeName, args.MinScore, args.DebounceTime, args.InitialInput) 450 | if err != nil { 451 | return err 452 | } 453 | 454 | p := tea.NewProgram(m, tea.WithAltScreen()) 455 | 456 | if _, err := p.Run(); err != nil { 457 | return err 458 | } 459 | 460 | return nil 461 | } 462 | -------------------------------------------------------------------------------- /go.sum: -------------------------------------------------------------------------------- 1 | github.com/alecthomas/assert/v2 v2.11.0 h1:2Q9r3ki8+JYXvGsDyBXwH3LcJ+WK5D0gc5E8vS6K3D0= 2 | github.com/alecthomas/assert/v2 v2.11.0/go.mod h1:Bze95FyfUr7x34QZrjL+XP+0qgp/zg8yS+TtBj1WA3k= 3 | github.com/alecthomas/chroma/v2 v2.18.0 h1:6h53Q4hW83SuF+jcsp7CVhLsMozzvQvO8HBbKQW+gn4= 4 | github.com/alecthomas/chroma/v2 v2.18.0/go.mod h1:RVX6AvYm4VfYe/zsk7mjHueLDZor3aWCNE14TFlepBk= 5 | github.com/alecthomas/repr v0.4.0 h1:GhI2A8MACjfegCPVq9f1FLvIBS+DrQ2KQBFZP1iFzXc= 6 | github.com/alecthomas/repr v0.4.0/go.mod h1:Fr0507jx4eOXV7AlPV6AVZLYrLIuIeSOWtW57eE/O/4= 7 | github.com/atotto/clipboard v0.1.4 h1:EH0zSVneZPSuFR11BlR9YppQTVDbh5+16AmcJi4g1z4= 8 | github.com/atotto/clipboard v0.1.4/go.mod h1:ZY9tmq7sm5xIbd9bOK4onWV4S6X0u6GY7Vn0Yu86PYI= 9 | github.com/aymanbagabas/go-osc52/v2 v2.0.1 h1:HwpRHbFMcZLEVr42D4p7XBqjyuxQH5SMiErDT4WkJ2k= 10 | github.com/aymanbagabas/go-osc52/v2 v2.0.1/go.mod h1:uYgXzlJ7ZpABp8OJ+exZzJJhRNQ2ASbcXHWsFqH8hp8= 11 | github.com/aymanbagabas/go-udiff v0.2.0 h1:TK0fH4MteXUDspT88n8CKzvK0X9O2xu9yQjWpi6yML8= 12 | github.com/aymanbagabas/go-udiff v0.2.0/go.mod h1:RE4Ex0qsGkTAJoQdQQCA0uG+nAzJO/pI/QwceO5fgrA= 13 | github.com/aymerick/douceur v0.2.0 h1:Mv+mAeH1Q+n9Fr+oyamOlAkUNPWPlA8PPGR0QAaYuPk= 14 | github.com/aymerick/douceur v0.2.0/go.mod h1:wlT5vV2O3h55X9m7iVYN0TBM0NH/MmbLnd30/FjWUq4= 15 | github.com/charmbracelet/bubbles v0.21.0 h1:9TdC97SdRVg/1aaXNVWfFH3nnLAwOXr8Fn6u6mfQdFs= 16 | github.com/charmbracelet/bubbles v0.21.0/go.mod h1:HF+v6QUR4HkEpz62dx7ym2xc71/KBHg+zKwJtMw+qtg= 17 | github.com/charmbracelet/bubbletea v1.3.5 h1:JAMNLTbqMOhSwoELIr0qyP4VidFq72/6E9j7HHmRKQc= 18 | github.com/charmbracelet/bubbletea v1.3.5/go.mod h1:TkCnmH+aBd4LrXhXcqrKiYwRs7qyQx5rBgH5fVY3v54= 19 | github.com/charmbracelet/colorprofile v0.3.1 h1:k8dTHMd7fgw4bnFd7jXTLZrSU/CQrKnL3m+AxCzDz40= 20 | github.com/charmbracelet/colorprofile v0.3.1/go.mod h1:/GkGusxNs8VB/RSOh3fu0TJmQ4ICMMPApIIVn0KszZ0= 21 | github.com/charmbracelet/glamour v0.10.0 h1:MtZvfwsYCx8jEPFJm3rIBFIMZUfUJ765oX8V6kXldcY= 22 | github.com/charmbracelet/glamour v0.10.0/go.mod h1:f+uf+I/ChNmqo087elLnVdCiVgjSKWuXa/l6NU2ndYk= 23 | github.com/charmbracelet/lipgloss v1.1.1-0.20250404203927-76690c660834 h1:ZR7e0ro+SZZiIZD7msJyA+NjkCNNavuiPBLgerbOziE= 24 | github.com/charmbracelet/lipgloss v1.1.1-0.20250404203927-76690c660834/go.mod h1:aKC/t2arECF6rNOnaKaVU6y4t4ZeHQzqfxedE/VkVhA= 25 | github.com/charmbracelet/x/ansi v0.9.2 h1:92AGsQmNTRMzuzHEYfCdjQeUzTrgE1vfO5/7fEVoXdY= 26 | github.com/charmbracelet/x/ansi v0.9.2/go.mod h1:3RQDQ6lDnROptfpWuUVIUG64bD2g2BgntdxH0Ya5TeE= 27 | github.com/charmbracelet/x/cellbuf v0.0.13 h1:/KBBKHuVRbq1lYx5BzEHBAFBP8VcQzJejZ/IA3iR28k= 28 | github.com/charmbracelet/x/cellbuf v0.0.13/go.mod h1:xe0nKWGd3eJgtqZRaN9RjMtK7xUYchjzPr7q6kcvCCs= 29 | github.com/charmbracelet/x/exp/golden v0.0.0-20241011142426-46044092ad91 h1:payRxjMjKgx2PaCWLZ4p3ro9y97+TVLZNaRZgJwSVDQ= 30 | github.com/charmbracelet/x/exp/golden v0.0.0-20241011142426-46044092ad91/go.mod h1:wDlXFlCrmJ8J+swcL/MnGUuYnqgQdW9rhSD61oNMb6U= 31 | github.com/charmbracelet/x/exp/slice v0.0.0-20250526211440-a664b62c405f h1:jecpAFqXsQcnAfbsI9kyp5araUd+vi74wa9tihCLgoU= 32 | github.com/charmbracelet/x/exp/slice v0.0.0-20250526211440-a664b62c405f/go.mod h1:vI5nDVMWi6veaYH+0Fmvpbe/+cv/iJfMntdh+N0+Tms= 33 | github.com/charmbracelet/x/term v0.2.1 h1:AQeHeLZ1OqSXhrAWpYUtZyX1T3zVxfpZuEQMIQaGIAQ= 34 | github.com/charmbracelet/x/term v0.2.1/go.mod h1:oQ4enTYFV7QN4m0i9mzHrViD7TQKvNEEkHUMCmsxdUg= 35 | github.com/cpuguy83/go-md2man/v2 v2.0.6/go.mod h1:oOW0eioCTA6cOiMLiUPZOpcVxMig6NIQQ7OS05n1F4g= 36 | github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= 37 | github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= 38 | github.com/dlclark/regexp2 v1.11.5 h1:Q/sSnsKerHeCkc/jSTNq1oCm7KiVgUMZRDUoRu0JQZQ= 39 | github.com/dlclark/regexp2 v1.11.5/go.mod h1:DHkYz0B9wPfa6wondMfaivmHpzrQ3v9q8cnmRbL6yW8= 40 | github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f h1:Y/CXytFA4m6baUTXGLOoWe4PQhGxaX0KpnayAqC48p4= 41 | github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f/go.mod h1:vw97MGsxSvLiUE2X8qFplwetxpGLQrlU1Q9AUEIzCaM= 42 | github.com/fatih/color v1.18.0 h1:S8gINlzdQ840/4pfAwic/ZE0djQEH3wM94VfqLTZcOM= 43 | github.com/fatih/color v1.18.0/go.mod h1:4FelSpRwEGDpQ12mAdzqdOukCy4u8WUtOY6lkT/6HfU= 44 | github.com/fsnotify/fsnotify v1.9.0 h1:2Ml+OJNzbYCTzsxtv8vKSFD9PbJjmhYF14k/jKC7S9k= 45 | github.com/fsnotify/fsnotify v1.9.0/go.mod h1:8jBTzvmWwFyi3Pb8djgCCO5IBqzKJ/Jwo8TRcHyHii0= 46 | github.com/go-viper/mapstructure/v2 v2.2.1 h1:ZAaOCxANMuZx5RCeg0mBdEZk7DZasvvZIxtHqx8aGss= 47 | github.com/go-viper/mapstructure/v2 v2.2.1/go.mod h1:oJDH3BJKyqBA2TXFhDsKDGDTlndYOZ6rGS0BRZIxGhM= 48 | github.com/google/shlex v0.0.0-20191202100458-e7afc7fbc510 h1:El6M4kTTCOh6aBiKaUGG7oYTSPP8MxqL4YI3kZKwcP4= 49 | github.com/google/shlex v0.0.0-20191202100458-e7afc7fbc510/go.mod h1:pupxD2MaaD3pAXIBCelhxNneeOaAeabZDe5s4K6zSpQ= 50 | github.com/gorilla/css v1.0.1 h1:ntNaBIghp6JmvWnxbZKANoLyuXTPZ4cAMlo6RyhlbO8= 51 | github.com/gorilla/css v1.0.1/go.mod h1:BvnYkspnSzMmwRK+b8/xgNPLiIuNZr6vbZBTPQ2A3b0= 52 | github.com/hexops/gotextdiff v1.0.3 h1:gitA9+qJrrTCsiCl7+kh75nPqQt1cx4ZkudSTLoUqJM= 53 | github.com/hexops/gotextdiff v1.0.3/go.mod h1:pSWU5MAI3yDq+fZBTazCSJysOMbxWL1BSow5/V2vxeg= 54 | github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8= 55 | github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw= 56 | github.com/knadh/koanf/maps v0.1.2 h1:RBfmAW5CnZT+PJ1CVc1QSJKf4Xu9kxfQgYVQSu8hpbo= 57 | github.com/knadh/koanf/maps v0.1.2/go.mod h1:npD/QZY3V6ghQDdcQzl1W4ICNVTkohC8E73eI2xW4yI= 58 | github.com/knadh/koanf/parsers/toml/v2 v2.2.0 h1:2nV7tHYJ5OZy2BynQ4mOJ6k5bDqbbCzRERLUKBytz3A= 59 | github.com/knadh/koanf/parsers/toml/v2 v2.2.0/go.mod h1:JpjTeK1Ge1hVX0wbof5DMCuDBriR8bWgeQP98eeOZpI= 60 | github.com/knadh/koanf/providers/file v1.2.0 h1:hrUJ6Y9YOA49aNu/RSYzOTFlqzXSCpmYIDXI7OJU6+U= 61 | github.com/knadh/koanf/providers/file v1.2.0/go.mod h1:bp1PM5f83Q+TOUu10J/0ApLBd9uIzg+n9UgthfY+nRA= 62 | github.com/knadh/koanf/v2 v2.2.0 h1:FZFwd9bUjpb8DyCWARUBy5ovuhDs1lI87dOEn2K8UVU= 63 | github.com/knadh/koanf/v2 v2.2.0/go.mod h1:PSFru3ufQgTsI7IF+95rf9s8XA1+aHxKuO/W+dPoHEY= 64 | github.com/kylelemons/godebug v1.1.0 h1:RPNrshWIDI6G2gRW9EHilWtl7Z6Sb1BR0xunSBf0SNc= 65 | github.com/kylelemons/godebug v1.1.0/go.mod h1:9/0rRGxNHcop5bhtWyNeEfOS8JIWk580+fNqagV/RAw= 66 | github.com/lucasb-eyer/go-colorful v1.2.0 h1:1nnpGOrhyZZuNyfu1QjKiUICQ74+3FNCN69Aj6K7nkY= 67 | github.com/lucasb-eyer/go-colorful v1.2.0/go.mod h1:R4dSotOR9KMtayYi1e77YzuveK+i7ruzyGqttikkLy0= 68 | github.com/mattn/go-colorable v0.1.14 h1:9A9LHSqF/7dyVVX6g0U9cwm9pG3kP9gSzcuIPHPsaIE= 69 | github.com/mattn/go-colorable v0.1.14/go.mod h1:6LmQG8QLFO4G5z1gPvYEzlUgJ2wF+stgPZH1UqBm1s8= 70 | github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY= 71 | github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= 72 | github.com/mattn/go-localereader v0.0.1 h1:ygSAOl7ZXTx4RdPYinUpg6W99U8jWvWi9Ye2JC/oIi4= 73 | github.com/mattn/go-localereader v0.0.1/go.mod h1:8fBrzywKY7BI3czFoHkuzRoWE9C+EiG4R1k4Cjx5p88= 74 | github.com/mattn/go-runewidth v0.0.12/go.mod h1:RAqKPSqVFrSLVXbA8x7dzmKdmGzieGRCM46jaSJTDAk= 75 | github.com/mattn/go-runewidth v0.0.16 h1:E5ScNMtiwvlvB5paMFdw9p4kSQzbXFikJ5SQO6TULQc= 76 | github.com/mattn/go-runewidth v0.0.16/go.mod h1:Jdepj2loyihRzMpdS35Xk/zdY8IAYHsh153qUoGf23w= 77 | github.com/microcosm-cc/bluemonday v1.0.27 h1:MpEUotklkwCSLeH+Qdx1VJgNqLlpY2KXwXFM08ygZfk= 78 | github.com/microcosm-cc/bluemonday v1.0.27/go.mod h1:jFi9vgW+H7c3V0lb6nR74Ib/DIB5OBs92Dimizgw2cA= 79 | github.com/mitchellh/copystructure v1.2.0 h1:vpKXTN4ewci03Vljg/q9QvCGUDttBOGBIa15WveJJGw= 80 | github.com/mitchellh/copystructure v1.2.0/go.mod h1:qLl+cE2AmVv+CoeAwDPye/v+N2HKCj9FbZEVFJRxO9s= 81 | github.com/mitchellh/reflectwalk v1.0.2 h1:G2LzWKi524PWgd3mLHV8Y5k7s6XUvT0Gef6zxSIeXaQ= 82 | github.com/mitchellh/reflectwalk v1.0.2/go.mod h1:mSTlrgnPZtwu0c4WaC2kGObEpuNDbx0jmZXqmk4esnw= 83 | github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6 h1:ZK8zHtRHOkbHy6Mmr5D264iyp3TiX5OmNcI5cIARiQI= 84 | github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6/go.mod h1:CJlz5H+gyd6CUWT45Oy4q24RdLyn7Md9Vj2/ldJBSIo= 85 | github.com/muesli/cancelreader v0.2.2 h1:3I4Kt4BQjOR54NavqnDogx/MIoWBFa0StPA8ELUXHmA= 86 | github.com/muesli/cancelreader v0.2.2/go.mod h1:3XuTXfFS2VjM+HTLZY9Ak0l6eUKfijIfMUZ4EgX0QYo= 87 | github.com/muesli/reflow v0.3.0 h1:IFsN6K9NfGtjeggFP+68I4chLZV2yIKsXJFNZ+eWh6s= 88 | github.com/muesli/reflow v0.3.0/go.mod h1:pbwTDkVPibjO2kyvBQRBxTWEEGDGq0FlB1BIKtnHY/8= 89 | github.com/muesli/termenv v0.16.0 h1:S5AlUN9dENB57rsbnkPyfdGuWIlkmzJjbFf0Tf5FWUc= 90 | github.com/muesli/termenv v0.16.0/go.mod h1:ZRfOIKPFDYQoDFF4Olj7/QJbW60Ol/kL1pU3VfY/Cnk= 91 | github.com/pelletier/go-toml/v2 v2.2.4 h1:mye9XuhQ6gvn5h28+VilKrrPoQVanw5PMw/TB0t5Ec4= 92 | github.com/pelletier/go-toml/v2 v2.2.4/go.mod h1:2gIqNv+qfxSVS7cM2xJQKtLSTLUE9V8t9Stt+h56mCY= 93 | github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= 94 | github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= 95 | github.com/rivo/uniseg v0.1.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc= 96 | github.com/rivo/uniseg v0.2.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc= 97 | github.com/rivo/uniseg v0.4.7 h1:WUdvkW8uEhrYfLC4ZzdpI2ztxP1I582+49Oc5Mq64VQ= 98 | github.com/rivo/uniseg v0.4.7/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUcx88= 99 | github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM= 100 | github.com/sahilm/fuzzy v0.1.1 h1:ceu5RHF8DGgoi+/dR5PsECjCDH1BE3Fnmpo7aVXOdRA= 101 | github.com/sahilm/fuzzy v0.1.1/go.mod h1:VFvziUEIMCrT6A6tw2RFIXPXXmzXbOsSHF0DOI8ZK9Y= 102 | github.com/spf13/cobra v1.9.1 h1:CXSaggrXdbHK9CF+8ywj8Amf7PBRmPCOJugH954Nnlo= 103 | github.com/spf13/cobra v1.9.1/go.mod h1:nDyEzZ8ogv936Cinf6g1RU9MRY64Ir93oCnqb9wxYW0= 104 | github.com/spf13/pflag v1.0.6 h1:jFzHGLGAlb3ruxLB8MhbI6A8+AQX/2eW4qeyNZXNp2o= 105 | github.com/spf13/pflag v1.0.6/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg= 106 | github.com/stretchr/testify v1.9.0 h1:HtqpIVDClZ4nwg75+f6Lvsy/wHu+3BoSGCbBAcpTsTg= 107 | github.com/stretchr/testify v1.9.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY= 108 | github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e h1:JVG44RsyaB9T2KIHavMF/ppJZNG9ZpyihvCd0w101no= 109 | github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e/go.mod h1:RbqR21r5mrJuqunuUZ/Dhy/avygyECGrLceyNeo4LiM= 110 | github.com/yarlson/pin v0.9.1 h1:ZfbMMTSpZw9X7ebq9QS6FAUq66PTv56S4WN4puO2HK0= 111 | github.com/yarlson/pin v0.9.1/go.mod h1:FC/d9PacAtwh05XzSznZWhA447uvimitjgDDl5YaVLE= 112 | github.com/yuin/goldmark v1.7.12 h1:YwGP/rrea2/CnCtUHgjuolG/PnMxdQtPMO5PvaE2/nY= 113 | github.com/yuin/goldmark v1.7.12/go.mod h1:ip/1k0VRfGynBgxOz0yCqHrbZXhcjxyuS66Brc7iBKg= 114 | github.com/yuin/goldmark-emoji v1.0.6 h1:QWfF2FYaXwL74tfGOW5izeiZepUDroDJfWubQI9HTHs= 115 | github.com/yuin/goldmark-emoji v1.0.6/go.mod h1:ukxJDKFpdFb5x0a5HqbdlcKtebh086iJpI31LTKmWuA= 116 | golang.org/x/exp v0.0.0-20220909182711-5c715a9e8561 h1:MDc5xs78ZrZr3HMQugiXOAkSZtfTpbJLDr/lwfgO53E= 117 | golang.org/x/exp v0.0.0-20220909182711-5c715a9e8561/go.mod h1:cyybsKvd6eL0RnXn6p/Grxp8F5bW7iYuBgsNCOHpMYE= 118 | golang.org/x/net v0.40.0 h1:79Xs7wF06Gbdcg4kdCCIQArK11Z1hr5POQ6+fIYHNuY= 119 | golang.org/x/net v0.40.0/go.mod h1:y0hY0exeL2Pku80/zKK7tpntoX23cqL3Oa6njdgRtds= 120 | golang.org/x/sync v0.14.0 h1:woo0S4Yywslg6hp4eUFjTVOyKt0RookbpAHG4c1HmhQ= 121 | golang.org/x/sync v0.14.0/go.mod h1:1dzgHSNfp02xaA81J2MS99Qcpr2w7fw1gpm99rleRqA= 122 | golang.org/x/sys v0.0.0-20210809222454-d867a43fc93e/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= 123 | golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= 124 | golang.org/x/sys v0.33.0 h1:q3i8TbbEz+JRD9ywIRlyRAQbM0qF7hu24q3teo2hbuw= 125 | golang.org/x/sys v0.33.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k= 126 | golang.org/x/term v0.32.0 h1:DR4lr0TjUs3epypdhTOkMmuF5CDFJ/8pOnbzMZPQ7bg= 127 | golang.org/x/term v0.32.0/go.mod h1:uZG1FhGx848Sqfsq4/DlJr3xGGsYMu/L5GW4abiaEPQ= 128 | golang.org/x/text v0.25.0 h1:qVyWApTSYLk/drJRO5mDlNYskwQznZmkpV2c8q9zls4= 129 | golang.org/x/text v0.25.0/go.mod h1:WEdwpYrmk1qmdHvhkSTNPm3app7v4rsT8F2UD6+VHIA= 130 | gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= 131 | gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= 132 | gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= 133 | -------------------------------------------------------------------------------- /cmd/root.go: -------------------------------------------------------------------------------- 1 | package cmd 2 | 3 | import ( 4 | "bytes" 5 | "context" 6 | "encoding/json" 7 | "fmt" 8 | "os" 9 | "slices" 10 | "strings" 11 | "text/template" 12 | "unicode/utf8" 13 | 14 | "github.com/fatih/color" 15 | "github.com/sahilm/fuzzy" 16 | "github.com/spf13/cobra" 17 | buildOpts "snare.dev/optnix/internal/build" 18 | cmdUtils "snare.dev/optnix/internal/cmd/utils" 19 | "snare.dev/optnix/internal/config" 20 | "snare.dev/optnix/internal/logger" 21 | "snare.dev/optnix/internal/utils" 22 | "snare.dev/optnix/option" 23 | "snare.dev/optnix/tui" 24 | "github.com/yarlson/pin" 25 | ) 26 | 27 | const helpTemplate = `Usage:{{if .Runnable}} 28 | {{.UseLine}}{{end}}{{if .HasAvailableSubCommands}} 29 | {{.CommandPath}} [command]{{end}}{{if gt (len .Aliases) 0}} 30 | 31 | Aliases: 32 | {{.NameAndAliases}}{{end}}{{if .HasExample}} 33 | 34 | Examples: 35 | {{.Example}}{{end}}{{if .HasAvailableSubCommands}}{{$cmds := .Commands}}{{if eq (len .Groups) 0}} 36 | 37 | Commands:{{range $cmds}}{{if (or .IsAvailableCommand (eq .Name "help"))}} 38 | {{rpad .Name .NamePadding }} {{.Short}}{{end}}{{end}}{{else}}{{if not .AllChildCommandsHaveGroup}} 39 | 40 | Commands:{{range $cmds}}{{if (and (eq .GroupID "") (or .IsAvailableCommand (eq .Name "help")))}} 41 | {{rpad .Name .NamePadding }} {{.Short}}{{end}}{{end}}{{end}}{{range $group := .Groups}} 42 | 43 | {{.Title}}:{{range $cmds}}{{if (and (eq .GroupID $group.ID) (or .IsAvailableCommand (eq .Name "help")))}} 44 | {{rpad .Name .NamePadding }} {{.Short}}{{end}}{{end}}{{end}}{{end}}{{end}}{{if .HasAvailableLocalFlags}} 45 | 46 | Flags: 47 | {{.LocalFlags.FlagUsages | trimTrailingWhitespaces}}{{end}} 48 | ` 49 | 50 | type CmdOptions struct { 51 | NonInteractive bool 52 | Config []string 53 | JSON bool 54 | MinScore int64 55 | ValueOnly bool 56 | Scope string 57 | ListScopes bool 58 | GenerateCompletions string 59 | 60 | OptionInput string 61 | } 62 | 63 | func CreateCommand() *cobra.Command { 64 | opts := CmdOptions{} 65 | cmdCtx := context.Background() 66 | 67 | log := logger.NewLogger() 68 | cmdCtx = logger.WithLogger(cmdCtx, log) 69 | 70 | cmd := cobra.Command{ 71 | Use: "optnix -s [SCOPE] [OPTION-NAME]", 72 | Short: "optnix", 73 | Long: "optnix - a fast Nix module system options searcher", 74 | Args: func(cmd *cobra.Command, args []string) error { 75 | argc := len(args) 76 | 77 | if opts.GenerateCompletions != "" { 78 | switch opts.GenerateCompletions { 79 | case "bash", "zsh", "fish": 80 | default: 81 | return cmdUtils.ErrorWithHint{ 82 | Msg: fmt.Sprintf("unsupported shell '%v'", opts.GenerateCompletions), 83 | Hint: "supported shells for completion are bash, zsh, or fish", 84 | } 85 | } 86 | return nil 87 | } 88 | 89 | if argc > 0 { 90 | opts.OptionInput = args[0] 91 | } 92 | 93 | // Imply `--non-interactive` for scripting output if not specified 94 | if opts.JSON || opts.ValueOnly { 95 | if cmd.Flags().Changed("non-interactive") && !opts.NonInteractive { 96 | return cmdUtils.ErrorWithHint{Msg: "--non-interactive is required when using output format flags"} 97 | } 98 | 99 | opts.NonInteractive = true 100 | } 101 | 102 | if opts.JSON && opts.ValueOnly { 103 | return cmdUtils.ErrorWithHint{Msg: "--json and --value-only flags conflict"} 104 | } 105 | 106 | if opts.NonInteractive && argc < 1 { 107 | scopeName := opts.Scope 108 | if scopeName == "" { 109 | scopeName = "[SCOPE]" 110 | } 111 | 112 | return cmdUtils.ErrorWithHint{ 113 | Msg: "argument [OPTION-NAME] is required for non-interactive mode", 114 | Hint: fmt.Sprintf(`try running "optnix -s %v [OPTION-NAME]"`, scopeName), 115 | } 116 | } 117 | 118 | return nil 119 | }, 120 | Version: buildOpts.Version, 121 | SilenceUsage: true, 122 | SuggestionsMinimumDistance: 1, 123 | CompletionOptions: cobra.CompletionOptions{ 124 | DisableDefaultCmd: true, 125 | }, 126 | ValidArgsFunction: completeOptionsFromScope(&opts.Scope), 127 | PersistentPreRunE: func(cmd *cobra.Command, args []string) error { 128 | if opts.GenerateCompletions != "" { 129 | return nil 130 | } 131 | 132 | inCompletionMode := cmd.CalledAs() == cobra.ShellCompRequestCmd 133 | 134 | configLocations := append(config.DefaultConfigLocations, opts.Config...) 135 | 136 | cfg, err := config.ParseConfig(configLocations...) 137 | if err != nil { 138 | log.Errorf("failed to parse config: %v", err) 139 | return err 140 | } 141 | 142 | if !inCompletionMode { 143 | if err := cfg.Validate(); err != nil { 144 | return err 145 | } 146 | } 147 | 148 | if opts.Scope == "" { 149 | if cfg.DefaultScope == "" && !inCompletionMode && !opts.ListScopes { 150 | return cmdUtils.ErrorWithHint{ 151 | Msg: "no scope was provided and no default scope is set in the configuration", 152 | Hint: "either set a default configuration or specify one with -s", 153 | } 154 | } 155 | 156 | opts.Scope = cfg.DefaultScope 157 | } 158 | 159 | if opts.MinScore != 0 { 160 | cfg.MinScore = opts.MinScore 161 | } 162 | 163 | cmdCtx = config.WithConfig(cmd.Context(), cfg) 164 | cmd.SetContext(cmdCtx) 165 | 166 | return nil 167 | }, 168 | Run: func(cmd *cobra.Command, args []string) { 169 | if err := commandMain(cmd, &opts); err != nil { 170 | os.Exit(1) 171 | } 172 | }, 173 | } 174 | 175 | cmd.SetContext(cmdCtx) 176 | 177 | cmd.SetHelpCommand(&cobra.Command{Hidden: true}) 178 | cmd.SetUsageTemplate(helpTemplate) 179 | 180 | boldRed := color.New(color.FgRed).Add(color.Bold) 181 | cmd.SetErrPrefix(boldRed.Sprint("error:")) 182 | 183 | cmd.Flags().BoolP("help", "h", false, "Show this help menu") 184 | cmd.Flags().Bool("version", false, "Display version information") 185 | 186 | cmd.Flags().StringVarP(&opts.Scope, "scope", "s", "", "Scope `name` to use (required)") 187 | cmd.Flags().BoolVarP(&opts.NonInteractive, "non-interactive", "n", false, "Do not show search TUI for options") 188 | cmd.Flags().BoolVarP(&opts.JSON, "json", "j", false, "Output information in JSON format") 189 | cmd.Flags().BoolVarP(&opts.ListScopes, "list-scopes", "l", false, "List available scopes and exit") 190 | cmd.Flags().Int64VarP(&opts.MinScore, "min-score", "m", 0, "Minimum `score` threshold for matching") 191 | cmd.Flags().StringSliceVarP(&opts.Config, "config", "c", nil, "Path to extra configuration `files` to load") 192 | cmd.Flags().BoolVarP(&opts.ValueOnly, "value-only", "v", false, "Only show option values") 193 | 194 | cmd.Flags().StringVar(&opts.GenerateCompletions, "completion", "", "Generate completions for a shell") 195 | _ = cmd.Flags().MarkHidden("completion") 196 | 197 | _ = cmd.RegisterFlagCompletionFunc("scope", completeScopes) 198 | _ = cmd.RegisterFlagCompletionFunc("completion", completeCompletionShells) 199 | 200 | return &cmd 201 | } 202 | 203 | var scopesListHeader = []string{"Name", "Description", "Origin"} 204 | 205 | func centered(width int, s string) *bytes.Buffer { 206 | var b bytes.Buffer 207 | runeLen := utf8.RuneCountInString(s) 208 | 209 | if runeLen >= width { 210 | fmt.Fprint(&b, s) 211 | return &b 212 | } 213 | 214 | padding := width - runeLen 215 | left := padding / 2 216 | right := padding - left 217 | 218 | fmt.Fprintf(&b, "%s%s%s", strings.Repeat(" ", left), s, strings.Repeat(" ", right)) 219 | return &b 220 | } 221 | 222 | func listScopes(cfg *config.Config) { 223 | const separator = " | " 224 | 225 | data := make([][]string, 0, len(cfg.Scopes)) 226 | 227 | for s, v := range cfg.Scopes { 228 | origin := cfg.FieldOrigin(fmt.Sprintf("scopes.%v", s)) 229 | data = append(data, []string{s, v.Description, origin}) 230 | } 231 | 232 | colWidths := make([]int, len(scopesListHeader)) 233 | 234 | for _, row := range data { 235 | for i, col := range row { 236 | if len(col) > colWidths[i] { 237 | colWidths[i] = len(col) 238 | } 239 | } 240 | } 241 | 242 | totalWidth := 0 243 | 244 | for i, col := range scopesListHeader { 245 | fmt.Print(centered(colWidths[i], col)) 246 | totalWidth += colWidths[i] 247 | 248 | if i < len(scopesListHeader)-1 { 249 | fmt.Print(separator) 250 | totalWidth += len(separator) 251 | } 252 | } 253 | 254 | fmt.Println("\n" + strings.Repeat("-", totalWidth)) 255 | 256 | for _, row := range data { 257 | for i, col := range row { 258 | format := fmt.Sprintf("%%-%ds", colWidths[i]) 259 | fmt.Printf(format, col) 260 | if i < len(row)-1 { 261 | fmt.Print(separator) 262 | } 263 | } 264 | fmt.Println() 265 | } 266 | } 267 | 268 | func constructScopeFromConfig(scope *config.Scope, formatterCmd string) option.Scope { 269 | loader := func() (option.NixosOptionSource, error) { 270 | return scope.Load() 271 | } 272 | 273 | evaluator := constructEvaluatorFromScope(formatterCmd, scope) 274 | 275 | return option.Scope{ 276 | Name: scope.Name, 277 | Description: scope.Description, 278 | Loader: loader, 279 | Evaluator: evaluator, 280 | } 281 | } 282 | 283 | func constructEvaluatorFromScope(formatterCmd string, s *config.Scope) option.EvaluatorFunc { 284 | if s.EvaluatorCmd == "" { 285 | return nil 286 | } 287 | 288 | tmpl, err := template.New("eval").Parse(s.EvaluatorCmd) 289 | if err != nil { 290 | panic(fmt.Sprintf("evaluator should have been verified as valid at this point: %v", err)) 291 | } 292 | 293 | return func(optionName string) (string, error) { 294 | var buf bytes.Buffer 295 | 296 | err := tmpl.Execute(&buf, map[string]string{ 297 | "Option": optionName, 298 | }) 299 | if err != nil { 300 | return "", err 301 | } 302 | 303 | cmdOutput, err := utils.ExecShellAndCaptureOutput(buf.String()) 304 | if err != nil { 305 | return "", &option.AttributeEvaluationError{ 306 | Attribute: optionName, 307 | EvaluationOutput: strings.TrimSpace(cmdOutput.Stderr), 308 | } 309 | } 310 | 311 | output := cmdOutput.Stdout 312 | 313 | if formatterCmd != "" { 314 | if formatted, err := option.FormatNixValue(formatterCmd, output); err == nil { 315 | output = formatted 316 | } 317 | } 318 | 319 | value := strings.TrimSpace(output) 320 | 321 | return value, nil 322 | } 323 | } 324 | 325 | func commandMain(cmd *cobra.Command, opts *CmdOptions) error { 326 | if opts.GenerateCompletions != "" { 327 | GenerateCompletions(cmd, opts.GenerateCompletions) 328 | return nil 329 | } 330 | 331 | log := logger.FromContext(cmd.Context()) 332 | cfg := config.FromContext(cmd.Context()) 333 | 334 | if opts.ListScopes { 335 | listScopes(cfg) 336 | return nil 337 | } 338 | 339 | if !opts.NonInteractive { 340 | scopes := make([]option.Scope, 0, len(cfg.Scopes)) 341 | for _, scope := range cfg.Scopes { 342 | actualScope := constructScopeFromConfig(&scope, cfg.FormatterCmd) 343 | scopes = append(scopes, actualScope) 344 | } 345 | 346 | return tui.OptionTUI(tui.OptionTUIArgs{ 347 | Scopes: scopes, 348 | SelectedScopeName: opts.Scope, 349 | MinScore: cfg.MinScore, 350 | DebounceTime: cfg.DebounceTime, 351 | InitialInput: opts.OptionInput, 352 | LogFileName: "optnix", 353 | }) 354 | } 355 | 356 | var scope *option.Scope 357 | for _, s := range cfg.Scopes { 358 | if opts.Scope == s.Name { 359 | actualScope := constructScopeFromConfig(&s, cfg.FormatterCmd) 360 | scope = &actualScope 361 | break 362 | } 363 | } 364 | 365 | if scope == nil { 366 | err := fmt.Errorf("scope '%v' not found in configuration", opts.Scope) 367 | log.Errorf("%v", err) 368 | return err 369 | } 370 | 371 | spinner := pin.New("Loading...", 372 | pin.WithSpinnerColor(pin.ColorCyan), 373 | pin.WithTextColor(pin.ColorRed), 374 | pin.WithPosition(pin.PositionRight), 375 | pin.WithSpinnerFrames([]rune{'-', '\\', '|', '/'}), 376 | pin.WithWriter(os.Stderr), 377 | ) 378 | cancelSpinner := spinner.Start(context.Background()) 379 | defer cancelSpinner() 380 | 381 | spinner.UpdateMessage("Loading options...") 382 | 383 | options, err := scope.Loader() 384 | if err != nil { 385 | spinner.Stop() 386 | log.Errorf("%v", err) 387 | return err 388 | } 389 | 390 | spinner.UpdateMessage(fmt.Sprintf("Finding option %v...", opts.OptionInput)) 391 | 392 | exactOptionMatchIdx := slices.IndexFunc(options, func(o option.NixosOption) bool { 393 | return o.Name == opts.OptionInput 394 | }) 395 | if exactOptionMatchIdx != -1 { 396 | o := options[exactOptionMatchIdx] 397 | 398 | spinner.UpdateMessage("Evaluating option value...") 399 | var evaluatedValue string 400 | var evalErr error 401 | 402 | if scope.Evaluator != nil { 403 | evaluatedValue, evalErr = scope.Evaluator(o.Name) 404 | } else { 405 | evaluatedValue = "no evaluator configured for this scope" 406 | } 407 | 408 | spinner.Stop() 409 | 410 | if opts.JSON { 411 | displayOptionJson(&o, &evaluatedValue) 412 | } else if opts.ValueOnly { 413 | fmt.Printf("%v\n", evaluatedValue) 414 | } else { 415 | fmt.Print(o.PrettyPrint(&option.ValuePrinterInput{ 416 | Value: evaluatedValue, 417 | Err: evalErr, 418 | })) 419 | } 420 | 421 | return nil 422 | } 423 | 424 | spinner.Stop() 425 | 426 | msg := fmt.Sprintf("no exact match for query '%s' found", opts.OptionInput) 427 | err = fmt.Errorf("%v", msg) 428 | 429 | fuzzySearchResults := fuzzy.FindFrom(opts.OptionInput, options) 430 | if len(fuzzySearchResults) > 10 { 431 | fuzzySearchResults = fuzzySearchResults[:10] 432 | } 433 | 434 | fuzzySearchResults = utils.FilterMinimumScoreMatches(fuzzySearchResults, cfg.MinScore) 435 | 436 | if opts.JSON { 437 | displayErrorJson(msg, fuzzySearchResults) 438 | return err 439 | } 440 | 441 | log.Error(msg) 442 | if len(fuzzySearchResults) > 0 { 443 | log.Print("\nSome similar options were found:\n") 444 | for _, v := range fuzzySearchResults { 445 | log.Printf(" - %s\n", v.Str) 446 | } 447 | } else { 448 | log.Print("\nTry refining your search query.\n") 449 | } 450 | 451 | return err 452 | } 453 | 454 | type optionJsonOutput struct { 455 | Name string `json:"name"` 456 | Description string `json:"description"` 457 | Type string `json:"type"` 458 | Value *string `json:"value"` 459 | Default string `json:"default"` 460 | Example string `json:"example"` 461 | Location []string `json:"loc"` 462 | ReadOnly bool `json:"readOnly"` 463 | Declarations []string `json:"declarations"` 464 | } 465 | 466 | func displayOptionJson(o *option.NixosOption, evaluatedValue *string) { 467 | defaultText := "" 468 | if o.Default != nil { 469 | defaultText = o.Default.Text 470 | } 471 | 472 | exampleText := "" 473 | if o.Example != nil { 474 | exampleText = o.Example.Text 475 | } 476 | 477 | bytes, _ := json.MarshalIndent(optionJsonOutput{ 478 | Name: o.Name, 479 | Description: o.Description, 480 | Type: o.Type, 481 | Value: evaluatedValue, 482 | Default: defaultText, 483 | Example: exampleText, 484 | Location: o.Location, 485 | ReadOnly: o.ReadOnly, 486 | Declarations: o.Declarations, 487 | }, "", " ") 488 | fmt.Printf("%v\n", string(bytes)) 489 | } 490 | 491 | type errorJsonOutput struct { 492 | Message string `json:"message"` 493 | SimilarOptions []string `json:"similar_options"` 494 | } 495 | 496 | func displayErrorJson(msg string, matches fuzzy.Matches) { 497 | matchedStrings := make([]string, len(matches)) 498 | for i, match := range matches { 499 | matchedStrings[i] = match.Str 500 | } 501 | 502 | bytes, _ := json.MarshalIndent(errorJsonOutput{ 503 | Message: msg, 504 | SimilarOptions: matchedStrings, 505 | }, "", " ") 506 | fmt.Printf("%v\n", string(bytes)) 507 | } 508 | 509 | func Execute() { 510 | if err := CreateCommand().Execute(); err != nil { 511 | os.Exit(1) 512 | } 513 | } 514 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | GNU GENERAL PUBLIC LICENSE 2 | Version 3, 29 June 2007 3 | 4 | Copyright (C) 2007 Free Software Foundation, Inc. 5 | Everyone is permitted to copy and distribute verbatim copies 6 | of this license document, but changing it is not allowed. 7 | 8 | Preamble 9 | 10 | The GNU General Public License is a free, copyleft license for 11 | software and other kinds of works. 12 | 13 | The licenses for most software and other practical works are designed 14 | to take away your freedom to share and change the works. By contrast, 15 | the GNU General Public License is intended to guarantee your freedom to 16 | share and change all versions of a program--to make sure it remains free 17 | software for all its users. We, the Free Software Foundation, use the 18 | GNU General Public License for most of our software; it applies also to 19 | any other work released this way by its authors. You can apply it to 20 | your programs, too. 21 | 22 | When we speak of free software, we are referring to freedom, not 23 | price. Our General Public Licenses are designed to make sure that you 24 | have the freedom to distribute copies of free software (and charge for 25 | them if you wish), that you receive source code or can get it if you 26 | want it, that you can change the software or use pieces of it in new 27 | free programs, and that you know you can do these things. 28 | 29 | To protect your rights, we need to prevent others from denying you 30 | these rights or asking you to surrender the rights. Therefore, you have 31 | certain responsibilities if you distribute copies of the software, or if 32 | you modify it: responsibilities to respect the freedom of others. 33 | 34 | For example, if you distribute copies of such a program, whether 35 | gratis or for a fee, you must pass on to the recipients the same 36 | freedoms that you received. You must make sure that they, too, receive 37 | or can get the source code. And you must show them these terms so they 38 | know their rights. 39 | 40 | Developers that use the GNU GPL protect your rights with two steps: 41 | (1) assert copyright on the software, and (2) offer you this License 42 | giving you legal permission to copy, distribute and/or modify it. 43 | 44 | For the developers' and authors' protection, the GPL clearly explains 45 | that there is no warranty for this free software. For both users' and 46 | authors' sake, the GPL requires that modified versions be marked as 47 | changed, so that their problems will not be attributed erroneously to 48 | authors of previous versions. 49 | 50 | Some devices are designed to deny users access to install or run 51 | modified versions of the software inside them, although the manufacturer 52 | can do so. This is fundamentally incompatible with the aim of 53 | protecting users' freedom to change the software. The systematic 54 | pattern of such abuse occurs in the area of products for individuals to 55 | use, which is precisely where it is most unacceptable. Therefore, we 56 | have designed this version of the GPL to prohibit the practice for those 57 | products. If such problems arise substantially in other domains, we 58 | stand ready to extend this provision to those domains in future versions 59 | of the GPL, as needed to protect the freedom of users. 60 | 61 | Finally, every program is threatened constantly by software patents. 62 | States should not allow patents to restrict development and use of 63 | software on general-purpose computers, but in those that do, we wish to 64 | avoid the special danger that patents applied to a free program could 65 | make it effectively proprietary. To prevent this, the GPL assures that 66 | patents cannot be used to render the program non-free. 67 | 68 | The precise terms and conditions for copying, distribution and 69 | modification follow. 70 | 71 | TERMS AND CONDITIONS 72 | 73 | 0. Definitions. 74 | 75 | "This License" refers to version 3 of the GNU General Public License. 76 | 77 | "Copyright" also means copyright-like laws that apply to other kinds of 78 | works, such as semiconductor masks. 79 | 80 | "The Program" refers to any copyrightable work licensed under this 81 | License. Each licensee is addressed as "you". "Licensees" and 82 | "recipients" may be individuals or organizations. 83 | 84 | To "modify" a work means to copy from or adapt all or part of the work 85 | in a fashion requiring copyright permission, other than the making of an 86 | exact copy. The resulting work is called a "modified version" of the 87 | earlier work or a work "based on" the earlier work. 88 | 89 | A "covered work" means either the unmodified Program or a work based 90 | on the Program. 91 | 92 | To "propagate" a work means to do anything with it that, without 93 | permission, would make you directly or secondarily liable for 94 | infringement under applicable copyright law, except executing it on a 95 | computer or modifying a private copy. Propagation includes copying, 96 | distribution (with or without modification), making available to the 97 | public, and in some countries other activities as well. 98 | 99 | To "convey" a work means any kind of propagation that enables other 100 | parties to make or receive copies. Mere interaction with a user through 101 | a computer network, with no transfer of a copy, is not conveying. 102 | 103 | An interactive user interface displays "Appropriate Legal Notices" 104 | to the extent that it includes a convenient and prominently visible 105 | feature that (1) displays an appropriate copyright notice, and (2) 106 | tells the user that there is no warranty for the work (except to the 107 | extent that warranties are provided), that licensees may convey the 108 | work under this License, and how to view a copy of this License. If 109 | the interface presents a list of user commands or options, such as a 110 | menu, a prominent item in the list meets this criterion. 111 | 112 | 1. Source Code. 113 | 114 | The "source code" for a work means the preferred form of the work 115 | for making modifications to it. "Object code" means any non-source 116 | form of a work. 117 | 118 | A "Standard Interface" means an interface that either is an official 119 | standard defined by a recognized standards body, or, in the case of 120 | interfaces specified for a particular programming language, one that 121 | is widely used among developers working in that language. 122 | 123 | The "System Libraries" of an executable work include anything, other 124 | than the work as a whole, that (a) is included in the normal form of 125 | packaging a Major Component, but which is not part of that Major 126 | Component, and (b) serves only to enable use of the work with that 127 | Major Component, or to implement a Standard Interface for which an 128 | implementation is available to the public in source code form. A 129 | "Major Component", in this context, means a major essential component 130 | (kernel, window system, and so on) of the specific operating system 131 | (if any) on which the executable work runs, or a compiler used to 132 | produce the work, or an object code interpreter used to run it. 133 | 134 | The "Corresponding Source" for a work in object code form means all 135 | the source code needed to generate, install, and (for an executable 136 | work) run the object code and to modify the work, including scripts to 137 | control those activities. However, it does not include the work's 138 | System Libraries, or general-purpose tools or generally available free 139 | programs which are used unmodified in performing those activities but 140 | which are not part of the work. For example, Corresponding Source 141 | includes interface definition files associated with source files for 142 | the work, and the source code for shared libraries and dynamically 143 | linked subprograms that the work is specifically designed to require, 144 | such as by intimate data communication or control flow between those 145 | subprograms and other parts of the work. 146 | 147 | The Corresponding Source need not include anything that users 148 | can regenerate automatically from other parts of the Corresponding 149 | Source. 150 | 151 | The Corresponding Source for a work in source code form is that 152 | same work. 153 | 154 | 2. Basic Permissions. 155 | 156 | All rights granted under this License are granted for the term of 157 | copyright on the Program, and are irrevocable provided the stated 158 | conditions are met. This License explicitly affirms your unlimited 159 | permission to run the unmodified Program. The output from running a 160 | covered work is covered by this License only if the output, given its 161 | content, constitutes a covered work. This License acknowledges your 162 | rights of fair use or other equivalent, as provided by copyright law. 163 | 164 | You may make, run and propagate covered works that you do not 165 | convey, without conditions so long as your license otherwise remains 166 | in force. You may convey covered works to others for the sole purpose 167 | of having them make modifications exclusively for you, or provide you 168 | with facilities for running those works, provided that you comply with 169 | the terms of this License in conveying all material for which you do 170 | not control copyright. Those thus making or running the covered works 171 | for you must do so exclusively on your behalf, under your direction 172 | and control, on terms that prohibit them from making any copies of 173 | your copyrighted material outside their relationship with you. 174 | 175 | Conveying under any other circumstances is permitted solely under 176 | the conditions stated below. Sublicensing is not allowed; section 10 177 | makes it unnecessary. 178 | 179 | 3. Protecting Users' Legal Rights From Anti-Circumvention Law. 180 | 181 | No covered work shall be deemed part of an effective technological 182 | measure under any applicable law fulfilling obligations under article 183 | 11 of the WIPO copyright treaty adopted on 20 December 1996, or 184 | similar laws prohibiting or restricting circumvention of such 185 | measures. 186 | 187 | When you convey a covered work, you waive any legal power to forbid 188 | circumvention of technological measures to the extent such circumvention 189 | is effected by exercising rights under this License with respect to 190 | the covered work, and you disclaim any intention to limit operation or 191 | modification of the work as a means of enforcing, against the work's 192 | users, your or third parties' legal rights to forbid circumvention of 193 | technological measures. 194 | 195 | 4. Conveying Verbatim Copies. 196 | 197 | You may convey verbatim copies of the Program's source code as you 198 | receive it, in any medium, provided that you conspicuously and 199 | appropriately publish on each copy an appropriate copyright notice; 200 | keep intact all notices stating that this License and any 201 | non-permissive terms added in accord with section 7 apply to the code; 202 | keep intact all notices of the absence of any warranty; and give all 203 | recipients a copy of this License along with the Program. 204 | 205 | You may charge any price or no price for each copy that you convey, 206 | and you may offer support or warranty protection for a fee. 207 | 208 | 5. Conveying Modified Source Versions. 209 | 210 | You may convey a work based on the Program, or the modifications to 211 | produce it from the Program, in the form of source code under the 212 | terms of section 4, provided that you also meet all of these conditions: 213 | 214 | a) The work must carry prominent notices stating that you modified 215 | it, and giving a relevant date. 216 | 217 | b) The work must carry prominent notices stating that it is 218 | released under this License and any conditions added under section 219 | 7. This requirement modifies the requirement in section 4 to 220 | "keep intact all notices". 221 | 222 | c) You must license the entire work, as a whole, under this 223 | License to anyone who comes into possession of a copy. This 224 | License will therefore apply, along with any applicable section 7 225 | additional terms, to the whole of the work, and all its parts, 226 | regardless of how they are packaged. This License gives no 227 | permission to license the work in any other way, but it does not 228 | invalidate such permission if you have separately received it. 229 | 230 | d) If the work has interactive user interfaces, each must display 231 | Appropriate Legal Notices; however, if the Program has interactive 232 | interfaces that do not display Appropriate Legal Notices, your 233 | work need not make them do so. 234 | 235 | A compilation of a covered work with other separate and independent 236 | works, which are not by their nature extensions of the covered work, 237 | and which are not combined with it such as to form a larger program, 238 | in or on a volume of a storage or distribution medium, is called an 239 | "aggregate" if the compilation and its resulting copyright are not 240 | used to limit the access or legal rights of the compilation's users 241 | beyond what the individual works permit. Inclusion of a covered work 242 | in an aggregate does not cause this License to apply to the other 243 | parts of the aggregate. 244 | 245 | 6. Conveying Non-Source Forms. 246 | 247 | You may convey a covered work in object code form under the terms 248 | of sections 4 and 5, provided that you also convey the 249 | machine-readable Corresponding Source under the terms of this License, 250 | in one of these ways: 251 | 252 | a) Convey the object code in, or embodied in, a physical product 253 | (including a physical distribution medium), accompanied by the 254 | Corresponding Source fixed on a durable physical medium 255 | customarily used for software interchange. 256 | 257 | b) Convey the object code in, or embodied in, a physical product 258 | (including a physical distribution medium), accompanied by a 259 | written offer, valid for at least three years and valid for as 260 | long as you offer spare parts or customer support for that product 261 | model, to give anyone who possesses the object code either (1) a 262 | copy of the Corresponding Source for all the software in the 263 | product that is covered by this License, on a durable physical 264 | medium customarily used for software interchange, for a price no 265 | more than your reasonable cost of physically performing this 266 | conveying of source, or (2) access to copy the 267 | Corresponding Source from a network server at no charge. 268 | 269 | c) Convey individual copies of the object code with a copy of the 270 | written offer to provide the Corresponding Source. This 271 | alternative is allowed only occasionally and noncommercially, and 272 | only if you received the object code with such an offer, in accord 273 | with subsection 6b. 274 | 275 | d) Convey the object code by offering access from a designated 276 | place (gratis or for a charge), and offer equivalent access to the 277 | Corresponding Source in the same way through the same place at no 278 | further charge. You need not require recipients to copy the 279 | Corresponding Source along with the object code. If the place to 280 | copy the object code is a network server, the Corresponding Source 281 | may be on a different server (operated by you or a third party) 282 | that supports equivalent copying facilities, provided you maintain 283 | clear directions next to the object code saying where to find the 284 | Corresponding Source. Regardless of what server hosts the 285 | Corresponding Source, you remain obligated to ensure that it is 286 | available for as long as needed to satisfy these requirements. 287 | 288 | e) Convey the object code using peer-to-peer transmission, provided 289 | you inform other peers where the object code and Corresponding 290 | Source of the work are being offered to the general public at no 291 | charge under subsection 6d. 292 | 293 | A separable portion of the object code, whose source code is excluded 294 | from the Corresponding Source as a System Library, need not be 295 | included in conveying the object code work. 296 | 297 | A "User Product" is either (1) a "consumer product", which means any 298 | tangible personal property which is normally used for personal, family, 299 | or household purposes, or (2) anything designed or sold for incorporation 300 | into a dwelling. In determining whether a product is a consumer product, 301 | doubtful cases shall be resolved in favor of coverage. For a particular 302 | product received by a particular user, "normally used" refers to a 303 | typical or common use of that class of product, regardless of the status 304 | of the particular user or of the way in which the particular user 305 | actually uses, or expects or is expected to use, the product. A product 306 | is a consumer product regardless of whether the product has substantial 307 | commercial, industrial or non-consumer uses, unless such uses represent 308 | the only significant mode of use of the product. 309 | 310 | "Installation Information" for a User Product means any methods, 311 | procedures, authorization keys, or other information required to install 312 | and execute modified versions of a covered work in that User Product from 313 | a modified version of its Corresponding Source. The information must 314 | suffice to ensure that the continued functioning of the modified object 315 | code is in no case prevented or interfered with solely because 316 | modification has been made. 317 | 318 | If you convey an object code work under this section in, or with, or 319 | specifically for use in, a User Product, and the conveying occurs as 320 | part of a transaction in which the right of possession and use of the 321 | User Product is transferred to the recipient in perpetuity or for a 322 | fixed term (regardless of how the transaction is characterized), the 323 | Corresponding Source conveyed under this section must be accompanied 324 | by the Installation Information. But this requirement does not apply 325 | if neither you nor any third party retains the ability to install 326 | modified object code on the User Product (for example, the work has 327 | been installed in ROM). 328 | 329 | The requirement to provide Installation Information does not include a 330 | requirement to continue to provide support service, warranty, or updates 331 | for a work that has been modified or installed by the recipient, or for 332 | the User Product in which it has been modified or installed. Access to a 333 | network may be denied when the modification itself materially and 334 | adversely affects the operation of the network or violates the rules and 335 | protocols for communication across the network. 336 | 337 | Corresponding Source conveyed, and Installation Information provided, 338 | in accord with this section must be in a format that is publicly 339 | documented (and with an implementation available to the public in 340 | source code form), and must require no special password or key for 341 | unpacking, reading or copying. 342 | 343 | 7. Additional Terms. 344 | 345 | "Additional permissions" are terms that supplement the terms of this 346 | License by making exceptions from one or more of its conditions. 347 | Additional permissions that are applicable to the entire Program shall 348 | be treated as though they were included in this License, to the extent 349 | that they are valid under applicable law. If additional permissions 350 | apply only to part of the Program, that part may be used separately 351 | under those permissions, but the entire Program remains governed by 352 | this License without regard to the additional permissions. 353 | 354 | When you convey a copy of a covered work, you may at your option 355 | remove any additional permissions from that copy, or from any part of 356 | it. (Additional permissions may be written to require their own 357 | removal in certain cases when you modify the work.) You may place 358 | additional permissions on material, added by you to a covered work, 359 | for which you have or can give appropriate copyright permission. 360 | 361 | Notwithstanding any other provision of this License, for material you 362 | add to a covered work, you may (if authorized by the copyright holders of 363 | that material) supplement the terms of this License with terms: 364 | 365 | a) Disclaiming warranty or limiting liability differently from the 366 | terms of sections 15 and 16 of this License; or 367 | 368 | b) Requiring preservation of specified reasonable legal notices or 369 | author attributions in that material or in the Appropriate Legal 370 | Notices displayed by works containing it; or 371 | 372 | c) Prohibiting misrepresentation of the origin of that material, or 373 | requiring that modified versions of such material be marked in 374 | reasonable ways as different from the original version; or 375 | 376 | d) Limiting the use for publicity purposes of names of licensors or 377 | authors of the material; or 378 | 379 | e) Declining to grant rights under trademark law for use of some 380 | trade names, trademarks, or service marks; or 381 | 382 | f) Requiring indemnification of licensors and authors of that 383 | material by anyone who conveys the material (or modified versions of 384 | it) with contractual assumptions of liability to the recipient, for 385 | any liability that these contractual assumptions directly impose on 386 | those licensors and authors. 387 | 388 | All other non-permissive additional terms are considered "further 389 | restrictions" within the meaning of section 10. If the Program as you 390 | received it, or any part of it, contains a notice stating that it is 391 | governed by this License along with a term that is a further 392 | restriction, you may remove that term. If a license document contains 393 | a further restriction but permits relicensing or conveying under this 394 | License, you may add to a covered work material governed by the terms 395 | of that license document, provided that the further restriction does 396 | not survive such relicensing or conveying. 397 | 398 | If you add terms to a covered work in accord with this section, you 399 | must place, in the relevant source files, a statement of the 400 | additional terms that apply to those files, or a notice indicating 401 | where to find the applicable terms. 402 | 403 | Additional terms, permissive or non-permissive, may be stated in the 404 | form of a separately written license, or stated as exceptions; 405 | the above requirements apply either way. 406 | 407 | 8. Termination. 408 | 409 | You may not propagate or modify a covered work except as expressly 410 | provided under this License. Any attempt otherwise to propagate or 411 | modify it is void, and will automatically terminate your rights under 412 | this License (including any patent licenses granted under the third 413 | paragraph of section 11). 414 | 415 | However, if you cease all violation of this License, then your 416 | license from a particular copyright holder is reinstated (a) 417 | provisionally, unless and until the copyright holder explicitly and 418 | finally terminates your license, and (b) permanently, if the copyright 419 | holder fails to notify you of the violation by some reasonable means 420 | prior to 60 days after the cessation. 421 | 422 | Moreover, your license from a particular copyright holder is 423 | reinstated permanently if the copyright holder notifies you of the 424 | violation by some reasonable means, this is the first time you have 425 | received notice of violation of this License (for any work) from that 426 | copyright holder, and you cure the violation prior to 30 days after 427 | your receipt of the notice. 428 | 429 | Termination of your rights under this section does not terminate the 430 | licenses of parties who have received copies or rights from you under 431 | this License. If your rights have been terminated and not permanently 432 | reinstated, you do not qualify to receive new licenses for the same 433 | material under section 10. 434 | 435 | 9. Acceptance Not Required for Having Copies. 436 | 437 | You are not required to accept this License in order to receive or 438 | run a copy of the Program. Ancillary propagation of a covered work 439 | occurring solely as a consequence of using peer-to-peer transmission 440 | to receive a copy likewise does not require acceptance. However, 441 | nothing other than this License grants you permission to propagate or 442 | modify any covered work. These actions infringe copyright if you do 443 | not accept this License. Therefore, by modifying or propagating a 444 | covered work, you indicate your acceptance of this License to do so. 445 | 446 | 10. Automatic Licensing of Downstream Recipients. 447 | 448 | Each time you convey a covered work, the recipient automatically 449 | receives a license from the original licensors, to run, modify and 450 | propagate that work, subject to this License. You are not responsible 451 | for enforcing compliance by third parties with this License. 452 | 453 | An "entity transaction" is a transaction transferring control of an 454 | organization, or substantially all assets of one, or subdividing an 455 | organization, or merging organizations. If propagation of a covered 456 | work results from an entity transaction, each party to that 457 | transaction who receives a copy of the work also receives whatever 458 | licenses to the work the party's predecessor in interest had or could 459 | give under the previous paragraph, plus a right to possession of the 460 | Corresponding Source of the work from the predecessor in interest, if 461 | the predecessor has it or can get it with reasonable efforts. 462 | 463 | You may not impose any further restrictions on the exercise of the 464 | rights granted or affirmed under this License. For example, you may 465 | not impose a license fee, royalty, or other charge for exercise of 466 | rights granted under this License, and you may not initiate litigation 467 | (including a cross-claim or counterclaim in a lawsuit) alleging that 468 | any patent claim is infringed by making, using, selling, offering for 469 | sale, or importing the Program or any portion of it. 470 | 471 | 11. Patents. 472 | 473 | A "contributor" is a copyright holder who authorizes use under this 474 | License of the Program or a work on which the Program is based. The 475 | work thus licensed is called the contributor's "contributor version". 476 | 477 | A contributor's "essential patent claims" are all patent claims 478 | owned or controlled by the contributor, whether already acquired or 479 | hereafter acquired, that would be infringed by some manner, permitted 480 | by this License, of making, using, or selling its contributor version, 481 | but do not include claims that would be infringed only as a 482 | consequence of further modification of the contributor version. For 483 | purposes of this definition, "control" includes the right to grant 484 | patent sublicenses in a manner consistent with the requirements of 485 | this License. 486 | 487 | Each contributor grants you a non-exclusive, worldwide, royalty-free 488 | patent license under the contributor's essential patent claims, to 489 | make, use, sell, offer for sale, import and otherwise run, modify and 490 | propagate the contents of its contributor version. 491 | 492 | In the following three paragraphs, a "patent license" is any express 493 | agreement or commitment, however denominated, not to enforce a patent 494 | (such as an express permission to practice a patent or covenant not to 495 | sue for patent infringement). To "grant" such a patent license to a 496 | party means to make such an agreement or commitment not to enforce a 497 | patent against the party. 498 | 499 | If you convey a covered work, knowingly relying on a patent license, 500 | and the Corresponding Source of the work is not available for anyone 501 | to copy, free of charge and under the terms of this License, through a 502 | publicly available network server or other readily accessible means, 503 | then you must either (1) cause the Corresponding Source to be so 504 | available, or (2) arrange to deprive yourself of the benefit of the 505 | patent license for this particular work, or (3) arrange, in a manner 506 | consistent with the requirements of this License, to extend the patent 507 | license to downstream recipients. "Knowingly relying" means you have 508 | actual knowledge that, but for the patent license, your conveying the 509 | covered work in a country, or your recipient's use of the covered work 510 | in a country, would infringe one or more identifiable patents in that 511 | country that you have reason to believe are valid. 512 | 513 | If, pursuant to or in connection with a single transaction or 514 | arrangement, you convey, or propagate by procuring conveyance of, a 515 | covered work, and grant a patent license to some of the parties 516 | receiving the covered work authorizing them to use, propagate, modify 517 | or convey a specific copy of the covered work, then the patent license 518 | you grant is automatically extended to all recipients of the covered 519 | work and works based on it. 520 | 521 | A patent license is "discriminatory" if it does not include within 522 | the scope of its coverage, prohibits the exercise of, or is 523 | conditioned on the non-exercise of one or more of the rights that are 524 | specifically granted under this License. You may not convey a covered 525 | work if you are a party to an arrangement with a third party that is 526 | in the business of distributing software, under which you make payment 527 | to the third party based on the extent of your activity of conveying 528 | the work, and under which the third party grants, to any of the 529 | parties who would receive the covered work from you, a discriminatory 530 | patent license (a) in connection with copies of the covered work 531 | conveyed by you (or copies made from those copies), or (b) primarily 532 | for and in connection with specific products or compilations that 533 | contain the covered work, unless you entered into that arrangement, 534 | or that patent license was granted, prior to 28 March 2007. 535 | 536 | Nothing in this License shall be construed as excluding or limiting 537 | any implied license or other defenses to infringement that may 538 | otherwise be available to you under applicable patent law. 539 | 540 | 12. No Surrender of Others' Freedom. 541 | 542 | If conditions are imposed on you (whether by court order, agreement or 543 | otherwise) that contradict the conditions of this License, they do not 544 | excuse you from the conditions of this License. If you cannot convey a 545 | covered work so as to satisfy simultaneously your obligations under this 546 | License and any other pertinent obligations, then as a consequence you may 547 | not convey it at all. For example, if you agree to terms that obligate you 548 | to collect a royalty for further conveying from those to whom you convey 549 | the Program, the only way you could satisfy both those terms and this 550 | License would be to refrain entirely from conveying the Program. 551 | 552 | 13. Use with the GNU Affero General Public License. 553 | 554 | Notwithstanding any other provision of this License, you have 555 | permission to link or combine any covered work with a work licensed 556 | under version 3 of the GNU Affero General Public License into a single 557 | combined work, and to convey the resulting work. The terms of this 558 | License will continue to apply to the part which is the covered work, 559 | but the special requirements of the GNU Affero General Public License, 560 | section 13, concerning interaction through a network will apply to the 561 | combination as such. 562 | 563 | 14. Revised Versions of this License. 564 | 565 | The Free Software Foundation may publish revised and/or new versions of 566 | the GNU General Public License from time to time. Such new versions will 567 | be similar in spirit to the present version, but may differ in detail to 568 | address new problems or concerns. 569 | 570 | Each version is given a distinguishing version number. If the 571 | Program specifies that a certain numbered version of the GNU General 572 | Public License "or any later version" applies to it, you have the 573 | option of following the terms and conditions either of that numbered 574 | version or of any later version published by the Free Software 575 | Foundation. If the Program does not specify a version number of the 576 | GNU General Public License, you may choose any version ever published 577 | by the Free Software Foundation. 578 | 579 | If the Program specifies that a proxy can decide which future 580 | versions of the GNU General Public License can be used, that proxy's 581 | public statement of acceptance of a version permanently authorizes you 582 | to choose that version for the Program. 583 | 584 | Later license versions may give you additional or different 585 | permissions. However, no additional obligations are imposed on any 586 | author or copyright holder as a result of your choosing to follow a 587 | later version. 588 | 589 | 15. Disclaimer of Warranty. 590 | 591 | THERE IS NO WARRANTY FOR THE PROGRAM, TO THE EXTENT PERMITTED BY 592 | APPLICABLE LAW. EXCEPT WHEN OTHERWISE STATED IN WRITING THE COPYRIGHT 593 | HOLDERS AND/OR OTHER PARTIES PROVIDE THE PROGRAM "AS IS" WITHOUT WARRANTY 594 | OF ANY KIND, EITHER EXPRESSED OR IMPLIED, INCLUDING, BUT NOT LIMITED TO, 595 | THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR 596 | PURPOSE. THE ENTIRE RISK AS TO THE QUALITY AND PERFORMANCE OF THE PROGRAM 597 | IS WITH YOU. SHOULD THE PROGRAM PROVE DEFECTIVE, YOU ASSUME THE COST OF 598 | ALL NECESSARY SERVICING, REPAIR OR CORRECTION. 599 | 600 | 16. Limitation of Liability. 601 | 602 | IN NO EVENT UNLESS REQUIRED BY APPLICABLE LAW OR AGREED TO IN WRITING 603 | WILL ANY COPYRIGHT HOLDER, OR ANY OTHER PARTY WHO MODIFIES AND/OR CONVEYS 604 | THE PROGRAM AS PERMITTED ABOVE, BE LIABLE TO YOU FOR DAMAGES, INCLUDING ANY 605 | GENERAL, SPECIAL, INCIDENTAL OR CONSEQUENTIAL DAMAGES ARISING OUT OF THE 606 | USE OR INABILITY TO USE THE PROGRAM (INCLUDING BUT NOT LIMITED TO LOSS OF 607 | DATA OR DATA BEING RENDERED INACCURATE OR LOSSES SUSTAINED BY YOU OR THIRD 608 | PARTIES OR A FAILURE OF THE PROGRAM TO OPERATE WITH ANY OTHER PROGRAMS), 609 | EVEN IF SUCH HOLDER OR OTHER PARTY HAS BEEN ADVISED OF THE POSSIBILITY OF 610 | SUCH DAMAGES. 611 | 612 | 17. Interpretation of Sections 15 and 16. 613 | 614 | If the disclaimer of warranty and limitation of liability provided 615 | above cannot be given local legal effect according to their terms, 616 | reviewing courts shall apply local law that most closely approximates 617 | an absolute waiver of all civil liability in connection with the 618 | Program, unless a warranty or assumption of liability accompanies a 619 | copy of the Program in return for a fee. 620 | 621 | END OF TERMS AND CONDITIONS 622 | 623 | How to Apply These Terms to Your New Programs 624 | 625 | If you develop a new program, and you want it to be of the greatest 626 | possible use to the public, the best way to achieve this is to make it 627 | free software which everyone can redistribute and change under these terms. 628 | 629 | To do so, attach the following notices to the program. It is safest 630 | to attach them to the start of each source file to most effectively 631 | state the exclusion of warranty; and each file should have at least 632 | the "copyright" line and a pointer to where the full notice is found. 633 | 634 | 635 | Copyright (C) 636 | 637 | This program is free software: you can redistribute it and/or modify 638 | it under the terms of the GNU General Public License as published by 639 | the Free Software Foundation, either version 3 of the License, or 640 | (at your option) any later version. 641 | 642 | This program is distributed in the hope that it will be useful, 643 | but WITHOUT ANY WARRANTY; without even the implied warranty of 644 | MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the 645 | GNU General Public License for more details. 646 | 647 | You should have received a copy of the GNU General Public License 648 | along with this program. If not, see . 649 | 650 | Also add information on how to contact you by electronic and paper mail. 651 | 652 | If the program does terminal interaction, make it output a short 653 | notice like this when it starts in an interactive mode: 654 | 655 | Copyright (C) 656 | This program comes with ABSOLUTELY NO WARRANTY; for details type `show w'. 657 | This is free software, and you are welcome to redistribute it 658 | under certain conditions; type `show c' for details. 659 | 660 | The hypothetical commands `show w' and `show c' should show the appropriate 661 | parts of the General Public License. Of course, your program's commands 662 | might be different; for a GUI interface, you would use an "about box". 663 | 664 | You should also get your employer (if you work as a programmer) or school, 665 | if any, to sign a "copyright disclaimer" for the program, if necessary. 666 | For more information on this, and how to apply and follow the GNU GPL, see 667 | . 668 | 669 | The GNU General Public License does not permit incorporating your program 670 | into proprietary programs. If your program is a subroutine library, you 671 | may consider it more useful to permit linking proprietary applications with 672 | the library. If this is what you want to do, use the GNU Lesser General 673 | Public License instead of this License. But first, please read 674 | . 675 | --------------------------------------------------------------------------------