package nixos import ( "encoding/json" "fmt" "io" ) // ParseOptions parses the options.json file from nixpkgs. // The options.json structure is a map from option name to option definition. func ParseOptions(r io.Reader) (map[string]*ParsedOption, error) { var raw map[string]json.RawMessage if err := json.NewDecoder(r).Decode(&raw); err != nil { return nil, fmt.Errorf("failed to decode options.json: %w", err) } options := make(map[string]*ParsedOption, len(raw)) for name, data := range raw { opt, err := parseOption(name, data) if err != nil { // Log but don't fail - some options might have unusual formats continue } options[name] = opt } return options, nil } // ParsedOption represents a parsed NixOS option with all its metadata. type ParsedOption struct { Name string Type string Description string Default string // JSON-encoded value Example string // JSON-encoded value ReadOnly bool Declarations []string } // optionJSON is the internal structure for parsing options.json entries. type optionJSON struct { Declarations json.RawMessage `json:"declarations"` // Can be []string or []{name, url} Default json.RawMessage `json:"default,omitempty"` Description interface{} `json:"description"` // Can be string or object Example json.RawMessage `json:"example,omitempty"` ReadOnly bool `json:"readOnly"` Type string `json:"type"` } // parseOption parses a single option entry. func parseOption(name string, data json.RawMessage) (*ParsedOption, error) { var opt optionJSON if err := json.Unmarshal(data, &opt); err != nil { return nil, fmt.Errorf("failed to parse option %s: %w", name, err) } // Handle description which can be a string or an object with _type: "mdDoc" description := extractDescription(opt.Description) // Parse declarations - can be []string (NixOS) or []{name, url} (Home Manager) declarations := parseDeclarations(opt.Declarations) return &ParsedOption{ Name: name, Type: opt.Type, Description: description, Default: string(opt.Default), Example: string(opt.Example), ReadOnly: opt.ReadOnly, Declarations: declarations, }, nil } // parseDeclarations handles both NixOS format ([]string) and Home Manager format ([]{name, url}). func parseDeclarations(raw json.RawMessage) []string { if len(raw) == 0 { return nil } // Try []string first (NixOS format) var stringDecls []string if err := json.Unmarshal(raw, &stringDecls); err == nil { result := make([]string, 0, len(stringDecls)) for _, d := range stringDecls { result = append(result, normalizeDeclarationPath(d)) } return result } // Try []{name, url} format (Home Manager format) var objectDecls []struct { Name string `json:"name"` URL string `json:"url"` } if err := json.Unmarshal(raw, &objectDecls); err == nil { result := make([]string, 0, len(objectDecls)) for _, d := range objectDecls { // Use name field, normalize the path result = append(result, normalizeDeclarationPath(d.Name)) } return result } return nil } // extractDescription extracts the description string from various formats. func extractDescription(desc interface{}) string { switch v := desc.(type) { case string: return v case map[string]interface{}: // Handle mdDoc format: {"_type": "mdDoc", "text": "..."} if text, ok := v["text"].(string); ok { return text } // Try "description" key if text, ok := v["description"].(string); ok { return text } } return "" } // normalizeDeclarationPath converts a full store path to a relative path. // NixOS input: "/nix/store/xxx-source/nixos/modules/services/web-servers/nginx/default.nix" // NixOS output: "nixos/modules/services/web-servers/nginx/default.nix" // HM input: "" // HM output: "modules/programs/git.nix" func normalizeDeclarationPath(path string) string { // Handle Home Manager format: or if len(path) > 2 && path[0] == '<' && path[len(path)-1] == '>' { inner := path[1 : len(path)-1] // Strip the prefix (home-manager/, nixpkgs/, etc.) if idx := findSubstring(inner, "/"); idx >= 0 { return inner[idx+1:] } return inner } // Look for common prefixes and strip them (NixOS store paths) markers := []string{ "/nixos/", "/pkgs/", "/lib/", "/maintainers/", "/modules/", // For home-manager paths } for _, marker := range markers { if idx := findSubstring(path, marker); idx >= 0 { return path[idx+1:] // +1 to skip the leading / } } // If no marker found, return as-is return path } // findSubstring returns the index of the first occurrence of substr in s, or -1. func findSubstring(s, substr string) int { for i := 0; i <= len(s)-len(substr); i++ { if s[i:i+len(substr)] == substr { return i } } return -1 }