From d7fb75f7f99abf0c8520eae037a24b3261b319f2 Mon Sep 17 00:00:00 2001 From: bbedward Date: Thu, 16 Apr 2026 10:36:55 -0400 Subject: [PATCH] keybinds(niri): add preprocessors to KDL parsing fixes #2230 --- core/internal/keybinds/providers/niri.go | 3 +- .../keybinds/providers/niri_parser.go | 101 +++++++++++++++++- .../keybinds/providers/niri_parser_test.go | 65 +++++++++++ 3 files changed, 165 insertions(+), 4 deletions(-) diff --git a/core/internal/keybinds/providers/niri.go b/core/internal/keybinds/providers/niri.go index aec20b59..c6a0f157 100644 --- a/core/internal/keybinds/providers/niri.go +++ b/core/internal/keybinds/providers/niri.go @@ -10,7 +10,6 @@ import ( "strings" "github.com/AvengeMedia/DankMaterialShell/core/internal/keybinds" - "github.com/sblinch/kdl-go" "github.com/sblinch/kdl-go/document" ) @@ -292,7 +291,7 @@ func (n *NiriProvider) loadOverrideBinds() (map[string]*overrideBind, error) { parser := NewNiriParser(filepath.Dir(overridePath)) parser.currentSource = overridePath - doc, err := kdl.Parse(strings.NewReader(string(data))) + doc, err := parseKDL(data) if err != nil { return nil, err } diff --git a/core/internal/keybinds/providers/niri_parser.go b/core/internal/keybinds/providers/niri_parser.go index 46520ff3..b273df24 100644 --- a/core/internal/keybinds/providers/niri_parser.go +++ b/core/internal/keybinds/providers/niri_parser.go @@ -50,6 +50,103 @@ type NiriParser struct { conflictingConfigs map[string]*NiriKeyBinding } +func parseKDL(data []byte) (*document.Document, error) { + return kdl.Parse(strings.NewReader(normalizeKDLBraces(string(data)))) +} + +func normalizeKDLBraces(input string) string { + var sb strings.Builder + sb.Grow(len(input)) + + var prev byte + n := len(input) + for i := 0; i < n; { + c := input[i] + + switch { + case c == '"': + end := findStringEnd(input, i) + sb.WriteString(input[i:end]) + prev = '"' + i = end + case c == '/' && i+1 < n && input[i+1] == '/': + end := findLineCommentEnd(input, i) + sb.WriteString(input[i:end]) + prev = '\n' + i = end + case c == '/' && i+1 < n && input[i+1] == '*': + end := findBlockCommentEnd(input, i) + sb.WriteString(input[i:end]) + prev = '/' + i = end + case c == '{' && prev != 0 && !isBraceAdjacentSpace(prev): + sb.WriteByte(' ') + sb.WriteByte(c) + prev = c + i++ + default: + sb.WriteByte(c) + prev = c + i++ + } + } + + return sb.String() +} + +func findStringEnd(s string, start int) int { + n := len(s) + for i := start + 1; i < n; { + switch s[i] { + case '\\': + i += 2 + case '"': + return i + 1 + default: + i++ + } + } + return n +} + +func findLineCommentEnd(s string, start int) int { + for i := start + 2; i < len(s); i++ { + if s[i] == '\n' { + return i + } + } + return len(s) +} + +func findBlockCommentEnd(s string, start int) int { + n := len(s) + depth := 1 + for i := start + 2; i < n && depth > 0; { + switch { + case i+1 < n && s[i] == '/' && s[i+1] == '*': + depth++ + i += 2 + case i+1 < n && s[i] == '*' && s[i+1] == '/': + depth-- + i += 2 + if depth == 0 { + return i + } + default: + i++ + } + } + return n +} + +func isBraceAdjacentSpace(b byte) bool { + switch b { + case ' ', '\t', '\n', '\r', '{': + return true + } + return false +} + func NewNiriParser(configDir string) *NiriParser { return &NiriParser{ configDir: configDir, @@ -91,7 +188,7 @@ func (p *NiriParser) parseDMSBindsDirectly(dmsBindsPath string, section *NiriSec return } - doc, err := kdl.Parse(strings.NewReader(string(data))) + doc, err := parseKDL(data) if err != nil { return } @@ -159,7 +256,7 @@ func (p *NiriParser) parseFile(filePath, sectionName string) (*NiriSection, erro return nil, fmt.Errorf("failed to read %s: %w", absPath, err) } - doc, err := kdl.Parse(strings.NewReader(string(data))) + doc, err := parseKDL(data) if err != nil { return nil, fmt.Errorf("failed to parse KDL in %s: %w", absPath, err) } diff --git a/core/internal/keybinds/providers/niri_parser_test.go b/core/internal/keybinds/providers/niri_parser_test.go index e21fde56..a665f95d 100644 --- a/core/internal/keybinds/providers/niri_parser_test.go +++ b/core/internal/keybinds/providers/niri_parser_test.go @@ -3,9 +3,74 @@ package providers import ( "os" "path/filepath" + "slices" "testing" ) +func TestNiriParse_NoSpaceBeforeBrace(t *testing.T) { + config := `recent-windows { + binds { + Alt+Tab { next-window scope="output"; } + Alt+Shift+Tab { previous-window scope="output"; } + Alt+grave { next-window filter="app-id"; } + Alt+Shift+grave { previous-window filter="app-id"; } + Alt+Escape { next-window scope="all"; } + Alt+Shift+Escape{ previous-window scope="all"; } + } +} +` + tmpDir := t.TempDir() + if err := os.WriteFile(filepath.Join(tmpDir, "config.kdl"), []byte(config), 0o644); err != nil { + t.Fatalf("Failed to write test config: %v", err) + } + + result, err := ParseNiriKeys(tmpDir) + if err != nil { + t.Fatalf("ParseNiriKeys failed on valid niri config: %v", err) + } + + var found *NiriKeyBinding + for i := range result.Section.Keybinds { + kb := &result.Section.Keybinds[i] + if kb.Key == "Escape" && slices.Contains(kb.Mods, "Alt") && slices.Contains(kb.Mods, "Shift") { + found = kb + break + } + } + if found == nil { + t.Fatal("Alt+Shift+Escape bind missing — '{' without preceding space was not handled") + } + if found.Action != "previous-window" { + t.Errorf("Action = %q, want %q", found.Action, "previous-window") + } +} + +func TestNormalizeKDLBraces(t *testing.T) { + tests := []struct { + name string + in string + out string + }{ + {"already spaced", "node { child }\n", "node { child }\n"}, + {"missing space", "node{ child }\n", "node { child }\n"}, + {"niri keybind", "Alt+Shift+Escape{ previous-window; }", "Alt+Shift+Escape { previous-window; }"}, + {"brace inside string", `node "a{b" { child }`, `node "a{b" { child }`}, + {"brace in line comment", "// foo{bar\nnode { }", "// foo{bar\nnode { }"}, + {"brace in block comment", "/* foo{bar */ node{ }", "/* foo{bar */ node { }"}, + {"escaped quote in string", `node "a\"b{c" { }`, `node "a\"b{c" { }`}, + {"leading brace", "{ child }", "{ child }"}, + {"nested missing space", "a{b{ c }}", "a {b { c }}"}, + } + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + got := normalizeKDLBraces(tc.in) + if got != tc.out { + t.Errorf("normalizeKDLBraces(%q) = %q, want %q", tc.in, got, tc.out) + } + }) + } +} + func TestNiriParseKeyCombo(t *testing.T) { tests := []struct { combo string