From fd5aabcb1749aec7e270a45294270fcc4291e78c Mon Sep 17 00:00:00 2001 From: Rocho Date: Tue, 16 Jun 2026 15:06:55 +0200 Subject: [PATCH] fix(keybinds): parse niri configs with leading-underscore identifiers (#2646) DMS reads the niri config with kdl-go, which rejects '_' as the first character of a bare identifier ("unexpected character _") even though niri's own parser and the KDL spec accept it. The common trigger is the `_JAVA_AWT_WM_NONREPARENTING "1"` environment node (the standard Java / tiling-WM fix). When the parse aborts, `dms keybinds show` returns nothing and the Keyboard Shortcuts UI shows no binds at all. Extend the existing preprocessor approach (the brace fix from #2230) with quoteLeadingUnderscoreIdents, which double-quotes bare identifiers that begin with '_' before the text reaches kdl-go. The scan is string/comment aware and only touches a leading '_' at a token boundary, so mid-identifier underscores (XDG_CURRENT_DESKTOP) and underscores inside strings/comments are left alone. Token boundaries include the ends of block comments and KDL slashdash (/-), so a node abutting a comment with no whitespace is handled too. This is safe because the niri parser only dispatches on fixed node/section names that never start with '_', so re-quoting such a name cannot change what DMS reads. Refs #2230 --- .../keybinds/providers/niri_parser.go | 89 ++++++++++++++++- .../keybinds/providers/niri_parser_test.go | 95 +++++++++++++++++++ 2 files changed, 183 insertions(+), 1 deletion(-) diff --git a/core/internal/keybinds/providers/niri_parser.go b/core/internal/keybinds/providers/niri_parser.go index d042b65c..4cd818f0 100644 --- a/core/internal/keybinds/providers/niri_parser.go +++ b/core/internal/keybinds/providers/niri_parser.go @@ -51,7 +51,7 @@ type NiriParser struct { } func parseKDL(data []byte) (*document.Document, error) { - return kdl.Parse(strings.NewReader(normalizeKDLBraces(string(data)))) + return kdl.Parse(strings.NewReader(normalizeKDLBraces(quoteLeadingUnderscoreIdents(string(data))))) } func normalizeKDLBraces(input string) string { @@ -94,6 +94,93 @@ func normalizeKDLBraces(input string) string { return sb.String() } +// quoteLeadingUnderscoreIdents wraps bare KDL identifiers that begin with '_' +// in double quotes. kdl-go rejects '_' as the first character of a bare +// identifier (e.g. the common `_JAVA_AWT_WM_NONREPARENTING "1"` environment +// node), even though niri's own parser and the KDL spec accept it — so without +// this the whole config fails to parse and no keybinds load. Quoting lets +// kdl-go parse it; this is safe because the niri parser only dispatches on +// fixed node/section names (binds, recent-windows, include, ...) that never +// start with '_', so re-quoting such a name cannot change what DMS reads. +// Underscores elsewhere in an identifier (XDG_CURRENT_DESKTOP) are left +// untouched, and underscores inside strings or comments are skipped. Only a +// leading '_' is handled; other start characters kdl-go over-rejects (e.g. '.' +// or '?') do not occur in niri configs. +func quoteLeadingUnderscoreIdents(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 == '/' && i+1 < n && input[i+1] == '-': + // KDL slashdash: /- comments out the next node/value. Keep the + // marker but treat what follows as a fresh token start, so a + // slashdashed leading-underscore node (e.g. `/-_FOO "1"`) still + // gets quoted instead of crashing kdl-go. + sb.WriteByte('/') + sb.WriteByte('-') + prev = ' ' + i += 2 + case c == '_' && isIdentBoundary(prev): + end := scanBareIdent(input, i) + sb.WriteByte('"') + sb.WriteString(input[i:end]) + sb.WriteByte('"') + prev = '"' + i = end + default: + sb.WriteByte(c) + prev = c + i++ + } + } + + return sb.String() +} + +// isIdentBoundary reports whether the previously emitted byte ends a token, so +// that a following '_' starts a fresh bare identifier rather than sitting in +// the middle of one. +func isIdentBoundary(prev byte) bool { + switch prev { + case 0, ' ', '\t', '\n', '\r', '{', '}', ';', '=', '(', ')', ',': + return true + } + return false +} + +// scanBareIdent returns the index just past the bare identifier starting at +// start, stopping at whitespace or any KDL delimiter. +func scanBareIdent(s string, start int) int { + n := len(s) + for i := start; i < n; i++ { + switch s[i] { + case ' ', '\t', '\n', '\r', '"', '{', '}', '(', ')', ';', '=', ',', '/', '\\', '<', '>', '[', ']': + return i + } + } + return n +} + func findStringEnd(s string, start int) int { n := len(s) for i := start + 1; i < n; { diff --git a/core/internal/keybinds/providers/niri_parser_test.go b/core/internal/keybinds/providers/niri_parser_test.go index f75f2b41..7b0f1033 100644 --- a/core/internal/keybinds/providers/niri_parser_test.go +++ b/core/internal/keybinds/providers/niri_parser_test.go @@ -71,6 +71,101 @@ func TestNormalizeKDLBraces(t *testing.T) { } } +func TestQuoteLeadingUnderscoreIdents(t *testing.T) { + tests := []struct { + name string + in string + out string + }{ + {"leading underscore node", `_JAVA_AWT_WM_NONREPARENTING "1"`, `"_JAVA_AWT_WM_NONREPARENTING" "1"`}, + {"mid underscore untouched", `XDG_CURRENT_DESKTOP "niri"`, `XDG_CURRENT_DESKTOP "niri"`}, + {"indented node", "environment {\n _FOO \"1\"\n}", "environment {\n \"_FOO\" \"1\"\n}"}, + {"underscore in string", `spawn "_not_a_node"`, `spawn "_not_a_node"`}, + {"underscore in line comment", "// _comment\n_FOO \"1\"", "// _comment\n\"_FOO\" \"1\""}, + {"underscore in block comment", "/* _x */ _FOO \"1\"", "/* _x */ \"_FOO\" \"1\""}, + {"block comment abuts node", `/* x */_FOO "1"`, `/* x */"_FOO" "1"`}, + {"slashdash before node", `/-_FOO "1"`, `/-"_FOO" "1"`}, + {"node after closing paren", "node (u8)_v", `node (u8)"_v"`}, + {"node before brace without space", "_FOO{ }", `"_FOO"{ }`}, + {"lone underscore", `_ "x"`, `"_" "x"`}, + {"property value", "node key=_val", `node key="_val"`}, + {"no underscores", "node child", "node child"}, + } + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + got := quoteLeadingUnderscoreIdents(tc.in) + if got != tc.out { + t.Errorf("quoteLeadingUnderscoreIdents(%q) = %q, want %q", tc.in, got, tc.out) + } + }) + } +} + +func TestNiriParseLeadingUnderscoreEnvironment(t *testing.T) { + tmpDir := t.TempDir() + configFile := filepath.Join(tmpDir, "config.kdl") + + // A leading-underscore environment node (a common Java/tiling-WM fix) must + // not abort parsing of the rest of the config — keybinds still have to load. + content := `environment { + XDG_CURRENT_DESKTOP "niri" + _JAVA_AWT_WM_NONREPARENTING "1" +} +binds { + Mod+Q { close-window; } + Mod+KP_Home { focus-workspace 1; } +} +` + if err := os.WriteFile(configFile, []byte(content), 0o644); err != nil { + t.Fatalf("Failed to write test config: %v", err) + } + + result, err := ParseNiriKeys(tmpDir) + if err != nil { + t.Fatalf("ParseNiriKeys failed on config with leading-underscore env node: %v", err) + } + + if len(result.Section.Keybinds) != 2 { + t.Errorf("Expected 2 keybinds, got %d", len(result.Section.Keybinds)) + } + + foundClose := false + for _, kb := range result.Section.Keybinds { + if kb.Action == "close-window" { + foundClose = true + } + } + if !foundClose { + t.Error("close-window keybind not found — leading-underscore env node broke parsing") + } +} + +func TestNiriParseSlashdashLeadingUnderscore(t *testing.T) { + tmpDir := t.TempDir() + configFile := filepath.Join(tmpDir, "config.kdl") + + // A slashdashed leading-underscore node must not abort parsing either. + content := `environment { + /-_JAVA_AWT_WM_NONREPARENTING "1" +} +binds { + Mod+Q { close-window; } +} +` + if err := os.WriteFile(configFile, []byte(content), 0o644); err != nil { + t.Fatalf("Failed to write test config: %v", err) + } + + result, err := ParseNiriKeys(tmpDir) + if err != nil { + t.Fatalf("ParseNiriKeys failed on config with slashdashed leading-underscore node: %v", err) + } + + if len(result.Section.Keybinds) != 1 { + t.Errorf("Expected 1 keybind, got %d", len(result.Section.Keybinds)) + } +} + func TestNiriParseKeyCombo(t *testing.T) { tests := []struct { combo string