Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion cmd/lx/testdata/golden/203_mixed_dots_file.golden
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
--- STDOUT ---
./../README.md (2 rows)
../README.md (2 rows)
---
```markdown
# Project
Expand Down
10 changes: 10 additions & 0 deletions cmd/lx/testdata/golden/203_mixed_dots_file.golden.actual
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
--- STDOUT ---
../README.md (2 rows)
---
```markdown
# Project
Documentation here.
```


--- STDERR ---
2 changes: 1 addition & 1 deletion cmd/lx/testdata/golden/206_zig_zag_file.golden
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
--- STDOUT ---
../pkg/../README.md (2 rows)
../README.md (2 rows)
---
```markdown
# Project
Expand Down
10 changes: 10 additions & 0 deletions cmd/lx/testdata/golden/206_zig_zag_file.golden.actual
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
--- STDOUT ---
../README.md (2 rows)
---
```markdown
# Project
Documentation here.
```


--- STDERR ---
2 changes: 1 addition & 1 deletion cmd/lx/testdata/golden/207_current_dir_explicit.golden
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
--- STDOUT ---
./script.py (1 rows)
script.py (1 rows)
---
```python
print('hello')
Expand Down
9 changes: 9 additions & 0 deletions cmd/lx/testdata/golden/207_current_dir_explicit.golden.actual
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
--- STDOUT ---
script.py (1 rows)
---
```python
print('hello')
```


--- STDERR ---
23 changes: 14 additions & 9 deletions internal/cli/app.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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)
}
Expand Down Expand Up @@ -636,3 +640,4 @@ func reorderTrailingOps(ops []Op) []Op {
newOps = append(newOps, others...)
return newOps
}

117 changes: 80 additions & 37 deletions pkg/lx/walk.go
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand All @@ -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,
Expand All @@ -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
Expand All @@ -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
Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -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 {
Expand Down
Loading