From e61fa45444a4c63e830614bf0a3e4699b5d3c625 Mon Sep 17 00:00:00 2001 From: bbedward Date: Thu, 1 Oct 2026 18:06:50 -0400 Subject: [PATCH] config: resolve nested includes against the including file fixes #3647 --- core/cmd/dms/commands_config.go | 72 ++++++++----------- core/cmd/dms/commands_config_test.go | 44 ++++++++++++ core/internal/configfrag/walk.go | 6 +- core/internal/configfrag/walk_test.go | 21 +++++- .../keybinds/providers/niri_parser.go | 14 ++-- .../keybinds/providers/niri_parser_test.go | 30 ++++++++ .../windowrules/providers/niri_parser.go | 11 ++- .../windowrules/providers/niri_parser_test.go | 30 ++++++++ 8 files changed, 166 insertions(+), 62 deletions(-) create mode 100644 core/cmd/dms/commands_config_test.go diff --git a/core/cmd/dms/commands_config.go b/core/cmd/dms/commands_config.go index 30eea90f3..2a5033a9b 100644 --- a/core/cmd/dms/commands_config.go +++ b/core/cmd/dms/commands_config.go @@ -104,8 +104,6 @@ func checkHyprlandInclude(filename string) (IncludeResult, error) { return result, err } - targetRel := filepath.ToSlash(filepath.Join("dms", filename)) - mainLua := filepath.Join(configDir, "hyprland.lua") if _, err := os.Stat(mainLua); err == nil { result.ConfigFormat = "lua" @@ -124,7 +122,7 @@ func checkHyprlandInclude(filename string) (IncludeResult, error) { result.ReadOnly = true } processed := make(map[string]bool) - if hyprlandFindIncludeHyprlang(mainConf, targetRel, processed) { + if hyprlandFindIncludeHyprlang(mainConf, targetAbs, processed) { result.Included = true return result, nil } @@ -167,22 +165,12 @@ func hyprlandFindIncludeHyprlang(filePath, target string, processed map[string]b continue } - sourcePath := strings.TrimSpace(parts[1]) - if matchesTarget(sourcePath, target) { - return true - } - - fullPath := sourcePath - if !filepath.IsAbs(sourcePath) { - fullPath = filepath.Join(baseDir, sourcePath) - } - - expanded, err := utils.ExpandPath(fullPath) + fullPath, err := resolveSourcePath(baseDir, strings.TrimSpace(parts[1])) if err != nil { continue } - if hyprlandFindIncludeHyprlang(expanded, target, processed) { + if fullPath == target || hyprlandFindIncludeHyprlang(fullPath, target, processed) { return true } } @@ -205,8 +193,13 @@ func checkNiriInclude(filename string) (IncludeResult, error) { return result, nil } + targetAbs, err := filepath.Abs(targetPath) + if err != nil { + return result, err + } + processed := make(map[string]bool) - result.Included = niriFindInclude(mainConfig, "dms/"+filename, processed) + result.Included = niriFindInclude(mainConfig, targetAbs, processed) return result, nil } @@ -248,17 +241,12 @@ func niriFindInclude(filePath, target string, processed map[string]bool) bool { continue } - includePath := trimmed[startQuote+1 : endQuote] - if matchesTarget(includePath, target) { - return true + fullPath, err := resolveSourcePath(baseDir, trimmed[startQuote+1:endQuote]) + if err != nil { + continue } - fullPath := includePath - if !filepath.IsAbs(includePath) { - fullPath = filepath.Join(baseDir, includePath) - } - - if niriFindInclude(fullPath, target, processed) { + if fullPath == target || niriFindInclude(fullPath, target, processed) { return true } } @@ -284,8 +272,13 @@ func checkMangoWCInclude(filename string) (IncludeResult, error) { return result, nil } + targetAbs, err := filepath.Abs(targetPath) + if err != nil { + return result, err + } + processed := make(map[string]bool) - result.Included = mangowcFindInclude(mainConfig, "dms/"+filename, processed) + result.Included = mangowcFindInclude(mainConfig, targetAbs, processed) return result, nil } @@ -323,22 +316,12 @@ func mangowcFindInclude(filePath, target string, processed map[string]bool) bool continue } - sourcePath := strings.TrimSpace(parts[1]) - if matchesTarget(sourcePath, target) { - return true - } - - fullPath := sourcePath - if !filepath.IsAbs(sourcePath) { - fullPath = filepath.Join(baseDir, sourcePath) - } - - expanded, err := utils.ExpandPath(fullPath) + fullPath, err := resolveSourcePath(baseDir, strings.TrimSpace(parts[1])) if err != nil { continue } - if mangowcFindInclude(expanded, target, processed) { + if fullPath == target || mangowcFindInclude(fullPath, target, processed) { return true } } @@ -346,8 +329,13 @@ func mangowcFindInclude(filePath, target string, processed map[string]bool) bool return false } -func matchesTarget(path, target string) bool { - path = strings.TrimPrefix(path, "./") - target = strings.TrimPrefix(target, "./") - return path == target || strings.HasSuffix(path, "/"+target) +func resolveSourcePath(baseDir, raw string) (string, error) { + expanded, err := utils.ExpandPath(raw) + if err != nil { + return "", err + } + if !filepath.IsAbs(expanded) { + expanded = filepath.Join(baseDir, expanded) + } + return filepath.Abs(expanded) } diff --git a/core/cmd/dms/commands_config_test.go b/core/cmd/dms/commands_config_test.go new file mode 100644 index 000000000..4a0ff4a8b --- /dev/null +++ b/core/cmd/dms/commands_config_test.go @@ -0,0 +1,44 @@ +package main + +import ( + "os" + "path/filepath" + "testing" +) + +func writeConfigFile(t *testing.T, path, content string) { + t.Helper() + if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(path, []byte(content), 0o644); err != nil { + t.Fatal(err) + } +} + +func TestNiriFindIncludeNestedRelativeToIncludingFile(t *testing.T) { + dir := t.TempDir() + writeConfigFile(t, filepath.Join(dir, "config.kdl"), "include \"dms/dms.kdl\"\n") + writeConfigFile(t, filepath.Join(dir, "dms", "dms.kdl"), "include \"binds.kdl\"\ninclude \"./outputs.kdl\"\n") + + for _, name := range []string{"binds.kdl", "outputs.kdl"} { + target := filepath.Join(dir, "dms", name) + if !niriFindInclude(filepath.Join(dir, "config.kdl"), target, map[string]bool{}) { + t.Errorf("%s included through dms/dms.kdl not detected", name) + } + } + + if niriFindInclude(filepath.Join(dir, "config.kdl"), filepath.Join(dir, "dms", "layout.kdl"), map[string]bool{}) { + t.Error("layout.kdl reported included without an include line") + } +} + +func TestMangowcFindIncludeNestedRelativeToIncludingFile(t *testing.T) { + dir := t.TempDir() + writeConfigFile(t, filepath.Join(dir, "config.conf"), "source=./dms/dms.conf\n") + writeConfigFile(t, filepath.Join(dir, "dms", "dms.conf"), "source = binds.conf\n") + + if !mangowcFindInclude(filepath.Join(dir, "config.conf"), filepath.Join(dir, "dms", "binds.conf"), map[string]bool{}) { + t.Error("binds.conf sourced through dms/dms.conf not detected") + } +} diff --git a/core/internal/configfrag/walk.go b/core/internal/configfrag/walk.go index a8265ac02..fb49713b8 100644 --- a/core/internal/configfrag/walk.go +++ b/core/internal/configfrag/walk.go @@ -49,12 +49,12 @@ func (w *Walker) Record(sourcePath string) bool { } func (w *Walker) Include(baseDir, sourcePath string, visit func(absPath string) error) bool { - matched := w.Record(sourcePath) - resolved, err := Resolve(baseDir, sourcePath) if err != nil { - return matched + return w.Record(sourcePath) } + + matched := w.Record(resolved) if visit == nil { return matched } diff --git a/core/internal/configfrag/walk_test.go b/core/internal/configfrag/walk_test.go index b56791add..b69dd08a8 100644 --- a/core/internal/configfrag/walk_test.go +++ b/core/internal/configfrag/walk_test.go @@ -3,9 +3,14 @@ package configfrag import ( "os" "path/filepath" + "strings" "testing" ) +func isDMSBindsConf(path string) bool { + return strings.HasSuffix(path, "/dms/binds.conf") +} + func writeFile(t *testing.T, path, content string) { t.Helper() if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil { @@ -76,7 +81,7 @@ func TestATildeSourceExpandsToHomeAndIsNotJoinedToTheBaseDir(t *testing.T) { func TestTheDMSFragmentPositionIsItsOrdinalAmongIncludes(t *testing.T) { dir := t.TempDir() - walker, _, record := recordingWalker(t, func(path string) bool { return path == "dms/binds.conf" }) + walker, _, record := recordingWalker(t, isDMSBindsConf) walker.Include(dir, "first.conf", record) walker.Include(dir, "dms/binds.conf", record) @@ -154,7 +159,7 @@ func TestAnAssignmentWithoutAnEqualsIsNotAnInclude(t *testing.T) { func TestAnAssignmentTrimsSpaceAroundThePath(t *testing.T) { dir := t.TempDir() - walker, visited, record := recordingWalker(t, func(path string) bool { return path == "dms/binds.conf" }) + walker, visited, record := recordingWalker(t, isDMSBindsConf) if !walker.IncludeAssignment(dir, "source = dms/binds.conf ", record) { t.Error("the spaced mango form must still match the dms fragment") @@ -192,3 +197,15 @@ func TestRecordCountsAnIncludeTheCallerResolvesItself(t *testing.T) { t.Errorf("scan = %+v, want count 2, position 2, seen", scan) } } + +func TestTheMatcherSeesTheSourceResolvedAgainstTheIncludingFile(t *testing.T) { + dir := t.TempDir() + walker, _, record := recordingWalker(t, isDMSBindsConf) + + if !walker.Include(filepath.Join(dir, "dms"), "binds.conf", record) { + t.Error("binds.conf sourced from inside dms/ must match the dms fragment") + } + if walker.Include(dir, "binds.conf", record) { + t.Error("binds.conf sourced from the config root must not match") + } +} diff --git a/core/internal/keybinds/providers/niri_parser.go b/core/internal/keybinds/providers/niri_parser.go index 11b807a8a..276fae589 100644 --- a/core/internal/keybinds/providers/niri_parser.go +++ b/core/internal/keybinds/providers/niri_parser.go @@ -414,21 +414,17 @@ func (p *NiriParser) handleInclude(node *document.Node, section *NiriSection, ba } includePath := strings.Trim(node.Arguments[0].String(), "\"") - isDMSInclude := includePath == "dms/binds.kdl" || strings.HasSuffix(includePath, "/dms/binds.kdl") + fullPath := filepath.Join(baseDir, includePath) + if filepath.IsAbs(includePath) { + fullPath = includePath + } + isDMSInclude := strings.HasSuffix(fullPath, "/dms/binds.kdl") p.includeCount++ if isDMSInclude { p.dmsBindsIncluded = true p.dmsIncludePos = p.includeCount p.bindsBeforeDMS = len(p.bindMap) - } - - fullPath := filepath.Join(baseDir, includePath) - if filepath.IsAbs(includePath) { - fullPath = includePath - } - - if isDMSInclude { p.dmsProcessed = true } diff --git a/core/internal/keybinds/providers/niri_parser_test.go b/core/internal/keybinds/providers/niri_parser_test.go index 7b285fa30..0adb78366 100644 --- a/core/internal/keybinds/providers/niri_parser_test.go +++ b/core/internal/keybinds/providers/niri_parser_test.go @@ -864,3 +864,33 @@ func TestNiriParseActionWithProperties(t *testing.T) { } } } + +func TestNiriParseIncludeNestedDMSBinds(t *testing.T) { + tmpDir := t.TempDir() + subDir := filepath.Join(tmpDir, "dms") + if err := os.MkdirAll(subDir, 0o755); err != nil { + t.Fatal(err) + } + + files := map[string]string{ + filepath.Join(tmpDir, "config.kdl"): "include \"dms/dms.kdl\"\n", + filepath.Join(subDir, "dms.kdl"): "include \"binds.kdl\"\n", + filepath.Join(subDir, "binds.kdl"): "binds {\n Mod+T { spawn \"kitty\"; }\n}\n", + } + for path, content := range files { + if err := os.WriteFile(path, []byte(content), 0o644); err != nil { + t.Fatal(err) + } + } + + result, err := ParseNiriKeys(tmpDir) + if err != nil { + t.Fatalf("ParseNiriKeys failed: %v", err) + } + if !result.DMSBindsIncluded { + t.Error("dms/binds.kdl included via dms/dms.kdl not detected") + } + if len(result.Section.Keybinds) != 1 { + t.Errorf("expected 1 keybind from the nested include, got %d", len(result.Section.Keybinds)) + } +} diff --git a/core/internal/windowrules/providers/niri_parser.go b/core/internal/windowrules/providers/niri_parser.go index 82dfd9736..f77c64a41 100644 --- a/core/internal/windowrules/providers/niri_parser.go +++ b/core/internal/windowrules/providers/niri_parser.go @@ -182,7 +182,11 @@ func (p *NiriRulesParser) handleInclude(node *document.Node, baseDir string) { } includePath := strings.Trim(node.Arguments[0].String(), "\"") - isDMSInclude := includePath == "dms/windowrules.kdl" || strings.HasSuffix(includePath, "/dms/windowrules.kdl") + fullPath := filepath.Join(baseDir, includePath) + if filepath.IsAbs(includePath) { + fullPath = includePath + } + isDMSInclude := strings.HasSuffix(fullPath, "/dms/windowrules.kdl") p.includeCount++ if isDMSInclude { @@ -191,11 +195,6 @@ func (p *NiriRulesParser) handleInclude(node *document.Node, baseDir string) { p.dmsProcessed = true } - fullPath := filepath.Join(baseDir, includePath) - if filepath.IsAbs(includePath) { - fullPath = includePath - } - _ = p.parseFile(fullPath) } diff --git a/core/internal/windowrules/providers/niri_parser_test.go b/core/internal/windowrules/providers/niri_parser_test.go index cbd39078c..9582f0f40 100644 --- a/core/internal/windowrules/providers/niri_parser_test.go +++ b/core/internal/windowrules/providers/niri_parser_test.go @@ -372,3 +372,33 @@ window-rule { t.Error("edited rule should have open-floating false") } } + +func TestNiriParseIncludeNestedDMSRules(t *testing.T) { + tmpDir := t.TempDir() + subDir := filepath.Join(tmpDir, "dms") + if err := os.MkdirAll(subDir, 0o755); err != nil { + t.Fatal(err) + } + + files := map[string]string{ + filepath.Join(tmpDir, "config.kdl"): "include \"dms/dms.kdl\"\n", + filepath.Join(subDir, "dms.kdl"): "include \"windowrules.kdl\"\n", + filepath.Join(subDir, "windowrules.kdl"): "window-rule {\n match app-id=\"nested\"\n opacity 0.9\n}\n", + } + for path, content := range files { + if err := os.WriteFile(path, []byte(content), 0o644); err != nil { + t.Fatal(err) + } + } + + result, err := ParseNiriWindowRules(tmpDir) + if err != nil { + t.Fatalf("ParseNiriWindowRules failed: %v", err) + } + if !result.DMSRulesIncluded { + t.Error("dms/windowrules.kdl included via dms/dms.kdl not detected") + } + if len(result.Rules) != 1 { + t.Errorf("expected 1 rule from the nested include, got %d", len(result.Rules)) + } +}