package providers import ( "fmt" "os" "path/filepath" "strings" "github.com/sblinch/kdl-go" "github.com/sblinch/kdl-go/document" ) type NiriKeyBinding struct { Mods []string Key string Action string Args []string Description string } type NiriSection struct { Name string Keybinds []NiriKeyBinding Children []NiriSection } type NiriParser struct { configDir string processedFiles map[string]bool bindMap map[string]*NiriKeyBinding bindOrder []string } func NewNiriParser(configDir string) *NiriParser { return &NiriParser{ configDir: configDir, processedFiles: make(map[string]bool), bindMap: make(map[string]*NiriKeyBinding), bindOrder: []string{}, } } func (p *NiriParser) Parse() (*NiriSection, error) { configPath := filepath.Join(p.configDir, "config.kdl") section, err := p.parseFile(configPath, "") if err != nil { return nil, err } section.Keybinds = p.finalizeBinds() return section, nil } func (p *NiriParser) finalizeBinds() []NiriKeyBinding { binds := make([]NiriKeyBinding, 0, len(p.bindOrder)) for _, key := range p.bindOrder { if kb, ok := p.bindMap[key]; ok { binds = append(binds, *kb) } } return binds } func (p *NiriParser) addBind(kb *NiriKeyBinding) { key := p.formatBindKey(kb) if _, exists := p.bindMap[key]; !exists { p.bindOrder = append(p.bindOrder, key) } p.bindMap[key] = kb } func (p *NiriParser) formatBindKey(kb *NiriKeyBinding) string { parts := make([]string, 0, len(kb.Mods)+1) parts = append(parts, kb.Mods...) parts = append(parts, kb.Key) return strings.Join(parts, "+") } func (p *NiriParser) parseFile(filePath, sectionName string) (*NiriSection, error) { absPath, err := filepath.Abs(filePath) if err != nil { return nil, fmt.Errorf("failed to resolve path %s: %w", filePath, err) } if p.processedFiles[absPath] { return &NiriSection{Name: sectionName}, nil } p.processedFiles[absPath] = true data, err := os.ReadFile(absPath) if err != nil { return nil, fmt.Errorf("failed to read %s: %w", absPath, err) } doc, err := kdl.Parse(strings.NewReader(string(data))) if err != nil { return nil, fmt.Errorf("failed to parse KDL in %s: %w", absPath, err) } section := &NiriSection{ Name: sectionName, } baseDir := filepath.Dir(absPath) p.processNodes(doc.Nodes, section, baseDir) return section, nil } func (p *NiriParser) processNodes(nodes []*document.Node, section *NiriSection, baseDir string) { for _, node := range nodes { name := node.Name.String() switch name { case "include": p.handleInclude(node, section, baseDir) case "binds": p.extractBinds(node, section, "") case "recent-windows": p.handleRecentWindows(node, section) } } } func (p *NiriParser) handleInclude(node *document.Node, section *NiriSection, baseDir string) { if len(node.Arguments) == 0 { return } includePath := node.Arguments[0].String() includePath = strings.Trim(includePath, "\"") var fullPath string if filepath.IsAbs(includePath) { fullPath = includePath } else { fullPath = filepath.Join(baseDir, includePath) } includedSection, err := p.parseFile(fullPath, "") if err != nil { return } section.Children = append(section.Children, includedSection.Children...) } func (p *NiriParser) handleRecentWindows(node *document.Node, section *NiriSection) { if node.Children == nil { return } for _, child := range node.Children { if child.Name.String() != "binds" { continue } p.extractBinds(child, section, "Alt-Tab") } } func (p *NiriParser) extractBinds(node *document.Node, section *NiriSection, subcategory string) { if node.Children == nil { return } for _, child := range node.Children { kb := p.parseKeybindNode(child, subcategory) if kb == nil { continue } p.addBind(kb) } } func (p *NiriParser) parseKeybindNode(node *document.Node, subcategory string) *NiriKeyBinding { keyCombo := node.Name.String() if keyCombo == "" { return nil } mods, key := p.parseKeyCombo(keyCombo) var action string var args []string if len(node.Children) > 0 { actionNode := node.Children[0] action = actionNode.Name.String() for _, arg := range actionNode.Arguments { args = append(args, strings.Trim(arg.String(), "\"")) } } description := "" if node.Properties != nil { if val, ok := node.Properties.Get("hotkey-overlay-title"); ok { description = strings.Trim(val.String(), "\"") } } return &NiriKeyBinding{ Mods: mods, Key: key, Action: action, Args: args, Description: description, } } func (p *NiriParser) parseKeyCombo(combo string) ([]string, string) { parts := strings.Split(combo, "+") if len(parts) == 0 { return nil, combo } if len(parts) == 1 { return nil, parts[0] } mods := parts[:len(parts)-1] key := parts[len(parts)-1] return mods, key } func ParseNiriKeys(configDir string) (*NiriSection, error) { parser := NewNiriParser(configDir) return parser.Parse() }