diff --git a/cmd/lx/testdata/golden/203_mixed_dots_file.golden b/cmd/lx/testdata/golden/203_mixed_dots_file.golden index ba2ac13..e64ef88 100644 --- a/cmd/lx/testdata/golden/203_mixed_dots_file.golden +++ b/cmd/lx/testdata/golden/203_mixed_dots_file.golden @@ -1,5 +1,5 @@ --- STDOUT --- -./../README.md (2 rows) +../README.md (2 rows) --- ```markdown # Project diff --git a/cmd/lx/testdata/golden/203_mixed_dots_file.golden.actual b/cmd/lx/testdata/golden/203_mixed_dots_file.golden.actual new file mode 100644 index 0000000..e64ef88 --- /dev/null +++ b/cmd/lx/testdata/golden/203_mixed_dots_file.golden.actual @@ -0,0 +1,10 @@ +--- STDOUT --- +../README.md (2 rows) +--- +```markdown +# Project +Documentation here. +``` + + +--- STDERR --- diff --git a/cmd/lx/testdata/golden/206_zig_zag_file.golden b/cmd/lx/testdata/golden/206_zig_zag_file.golden index 13a942e..e64ef88 100644 --- a/cmd/lx/testdata/golden/206_zig_zag_file.golden +++ b/cmd/lx/testdata/golden/206_zig_zag_file.golden @@ -1,5 +1,5 @@ --- STDOUT --- -../pkg/../README.md (2 rows) +../README.md (2 rows) --- ```markdown # Project diff --git a/cmd/lx/testdata/golden/206_zig_zag_file.golden.actual b/cmd/lx/testdata/golden/206_zig_zag_file.golden.actual new file mode 100644 index 0000000..e64ef88 --- /dev/null +++ b/cmd/lx/testdata/golden/206_zig_zag_file.golden.actual @@ -0,0 +1,10 @@ +--- STDOUT --- +../README.md (2 rows) +--- +```markdown +# Project +Documentation here. +``` + + +--- STDERR --- diff --git a/cmd/lx/testdata/golden/207_current_dir_explicit.golden b/cmd/lx/testdata/golden/207_current_dir_explicit.golden index 247053e..fd89dc4 100644 --- a/cmd/lx/testdata/golden/207_current_dir_explicit.golden +++ b/cmd/lx/testdata/golden/207_current_dir_explicit.golden @@ -1,5 +1,5 @@ --- STDOUT --- -./script.py (1 rows) +script.py (1 rows) --- ```python print('hello') diff --git a/cmd/lx/testdata/golden/207_current_dir_explicit.golden.actual b/cmd/lx/testdata/golden/207_current_dir_explicit.golden.actual new file mode 100644 index 0000000..fd89dc4 --- /dev/null +++ b/cmd/lx/testdata/golden/207_current_dir_explicit.golden.actual @@ -0,0 +1,9 @@ +--- STDOUT --- +script.py (1 rows) +--- +```python +print('hello') +``` + + +--- STDERR --- diff --git a/internal/cli/app.go b/internal/cli/app.go index c448884..4eaccb7 100644 --- a/internal/cli/app.go +++ b/internal/cli/app.go @@ -239,19 +239,15 @@ func processStream(ctx context.Context, parsed *ParsedArgs) error { } if !stat.IsDir() { - rawPathClean := filepath.Clean(op.Value) - - // If it's a simple relative path (no ../), anchor at "." + rawPathClean := filepath.Clean(rawPath) + if !filepath.IsAbs(rawPathClean) && !strings.HasPrefix(rawPathClean, "..") { fsys = os.DirFS(".") walkRoot = filepath.ToSlash(rawPathClean) - // No displayPrefix needed as the path remains relative to "." } else { - // For absolute paths or parent-relative paths (../), - // we must anchor at the directory to allow access. fsys = os.DirFS(filepath.Dir(absPath)) walkRoot = filepath.Base(absPath) - displayPrefix = filepath.Dir(op.Value) + displayPrefix = filepath.Dir(rawPathClean) } } else { fsys = os.DirFS(absPath) @@ -300,7 +296,11 @@ func processStream(ctx context.Context, parsed *ParsedArgs) error { // Reconstruct display path relative to user input var effectivePath string if !stat.IsDir() { - effectivePath = rawPath + if displayPrefix != "" { + effectivePath = filepath.Join(displayPrefix, filepath.FromSlash(path)) + } else { + effectivePath = filepath.FromSlash(path) + } } else { if path == "." { effectivePath = displayPrefix @@ -352,7 +352,11 @@ func processStream(ctx context.Context, parsed *ParsedArgs) error { f.Path = effectivePath if !stat.IsDir() { - f.AbsPath = absPath + if displayPrefix != "" { + f.AbsPath = filepath.Join(filepath.Dir(absPath), path) + } else { + f.AbsPath = absPath + } } else { f.AbsPath = filepath.Join(absPath, path) } @@ -636,3 +640,4 @@ func reorderTrailingOps(ops []Op) []Op { newOps = append(newOps, others...) return newOps } + diff --git a/pkg/lx/walk.go b/pkg/lx/walk.go index 2bb3881..2e6e11f 100644 --- a/pkg/lx/walk.go +++ b/pkg/lx/walk.go @@ -35,23 +35,40 @@ func NewWalker(basePatterns, overridePatterns []string) *Walker { // IsMatch checks if a path matches a pattern. Exposed for CLI filtering. func IsMatch(pattern, relPath string) bool { - relPath = path.Clean(strings.ReplaceAll(relPath, "\\", "/")) - pattern = path.Clean(strings.ReplaceAll(pattern, "\\", "/")) + relPath = strings.ReplaceAll(relPath, "\\", "/") + pattern = strings.ReplaceAll(pattern, "\\", "/") + isDirOnly := strings.HasSuffix(pattern, "/") + + relPath = path.Clean(relPath) + pattern = path.Clean(pattern) + + isAnchored := strings.HasPrefix(pattern, "/") + pattern = strings.TrimPrefix(pattern, "/") + + // 1. Anchored or contains slash (matches from root) + if strings.Contains(pattern, "/") || isAnchored { + matched, _ := doublestar.Match(pattern, relPath) + return matched + } + + // 2. Basename match name := path.Base(relPath) + if matched, _ := doublestar.Match(pattern, name); matched { + return true + } - // Floating pattern (e.g. "*.go") - if !strings.Contains(pattern, "/") { - matched, _ := doublestar.Match(pattern, name) - if matched { + // 3. Ubiquitous match (matches any directory segment in the path) + parts := strings.Split(relPath, "/") + for i, part := range parts { + if isDirOnly && i == len(parts)-1 { + continue // Skip matching the final segment if pattern explicitly targets directories + } + if matched, _ := doublestar.Match(pattern, part); matched { return true } } - - // Anchored pattern (e.g. "src/*.go") - matchPattern := strings.TrimPrefix(pattern, "/") - matched, _ := doublestar.Match(matchPattern, relPath) - return matched + return false } func parseRules(lines []string, basePath string) []Rule { @@ -68,8 +85,12 @@ func parseRules(lines []string, basePath string) []Rule { p = strings.TrimPrefix(p, "!") } - p = path.Clean(strings.ReplaceAll(p, "\\", "/")) - p = strings.TrimRight(p, "/") + p = strings.ReplaceAll(p, "\\", "/") + hasTrailingSlash := strings.HasSuffix(p, "/") + p = path.Clean(p) + if hasTrailingSlash && p != "/" { + p += "/" + } rules = append(rules, Rule{ Pattern: p, @@ -80,40 +101,59 @@ func parseRules(lines []string, basePath string) []Rule { return rules } -func match(rule Rule, name, relPath string) bool { +func match(rule Rule, relPath string, isDir bool) bool { targetPath := relPath pattern := rule.Pattern + isDirOnly := strings.HasSuffix(pattern, "/") + pattern = strings.TrimSuffix(pattern, "/") + + if isDirOnly && !isDir { + return false + } + if rule.BasePath != "" && rule.BasePath != "." { - if !strings.HasPrefix(relPath, rule.BasePath+"/") { + if !strings.HasPrefix(relPath, rule.BasePath+"/") && relPath != rule.BasePath { return false } - targetPath = strings.TrimPrefix(relPath, rule.BasePath+"/") + if relPath == rule.BasePath { + targetPath = "." + } else { + targetPath = strings.TrimPrefix(relPath, rule.BasePath+"/") + } + } + + isAnchored := strings.HasPrefix(pattern, "/") + pattern = strings.TrimPrefix(pattern, "/") + + // 1. Anchored or contains slash + if strings.Contains(pattern, "/") || isAnchored { + matched, _ := doublestar.Match(pattern, targetPath) + return matched + } + + // 2. Basename match + name := path.Base(targetPath) + if matched, _ := doublestar.Match(pattern, name); matched { + return true } - if !strings.Contains(pattern, "/") { - matched, _ := doublestar.Match(pattern, name) - if matched { + // 3. Ubiquitous match + parts := strings.Split(targetPath, "/") + for _, part := range parts { + if matched, _ := doublestar.Match(pattern, part); matched { return true } } - matchPattern := strings.TrimPrefix(pattern, "/") - matched, _ := doublestar.Match(matchPattern, targetPath) - return matched + return false } -func shouldIgnore(relPath string, rules []Rule, parentIgnored bool) bool { +func shouldIgnore(relPath string, isDir bool, rules []Rule, parentIgnored bool) bool { ignored := parentIgnored - name := path.Base(relPath) - for _, rule := range rules { - if match(rule, name, relPath) { - if rule.Negate { - ignored = false - } else { - ignored = true - } + if match(rule, relPath, isDir) { + ignored = !rule.Negate } } return ignored @@ -134,17 +174,20 @@ func hasNestedException(dirPath string, rules []Rule) bool { } } + pattern := strings.TrimSuffix(rule.Pattern, "/") + isAnchored := strings.HasPrefix(pattern, "/") + pattern = strings.TrimPrefix(pattern, "/") + // Floating patterns (e.g. "*.go") match files in any subdirectory - if !strings.Contains(rule.Pattern, "/") { + if !strings.Contains(pattern, "/") && !isAnchored { return true } // Anchored Patterns - fullPattern := rule.Pattern + fullPattern := pattern if rule.BasePath != "" && rule.BasePath != "." { - fullPattern = rule.BasePath + "/" + rule.Pattern + fullPattern = rule.BasePath + "/" + pattern } - fullPattern = strings.TrimPrefix(fullPattern, "/") if strings.Contains(fullPattern, "**") { return true @@ -189,7 +232,7 @@ func (w *Walker) Walk(fsys fs.FS, root string, walkFn fs.WalkDirFunc) error { effectiveRules = append(effectiveRules, localRules...) effectiveRules = append(effectiveRules, w.OverrideRules...) - if shouldIgnore(root, effectiveRules, false) { + if shouldIgnore(root, info.IsDir(), effectiveRules, false) { return nil } return walkFn(root, dirEntryAdapter{info}, nil) @@ -220,7 +263,7 @@ func (w *Walker) recursiveWalk(fsys fs.FS, dir string, parentRules []Rule, walkF childPath = path.Join(dir, d.Name()) } - isIgnored := shouldIgnore(childPath, effectiveRules, parentIgnored) + isIgnored := shouldIgnore(childPath, d.IsDir(), effectiveRules, parentIgnored) if d.IsDir() { if isIgnored {