fix(lsp): allow directories as root markers (#1117)

Amolith and Crush created

* test(fsext): add GlobWithDoubleStar tests

Covers
- Basic file and directory matching
- Nested directory patterns
- Limit parameter behavior
- Modification time sorting
- Empty and non-existent directory handling
- Ignore pattern respect

Co-Authored-By: Crush <crush@charm.land>

* fix: allow directories as root markers

The removed line was preventing users from setting directories
as root markers, which should be allowed according to the
configuration specification.

Co-Authored-By: Crush <crush@charm.land>

* fix: normalize slashes for Windows glob matching

Normalize both paths and patterns to forward slashes for consistent
cross-platform glob matching:
- Normalize relative paths from filepath.Rel before pattern matching
- Normalize input glob patterns on Windows at function entry
- Fixes Windows test failures where backslashes don't match forward
  slash patterns

Co-Authored-By: Crush <crush@charm.land>

* test(fsext): remove unnecessary GOOS check

Co-Authored-By: Crush <crush@charm.land>

* test(fsext): more concise error checking

Co-Authored-By: Crush <crush@charm.land>

* test(fsext): simplify assertions

Co-Authored-By: Crush <crush@charm.land>

* test(fsext): use deterministic mod times

Co-Authored-By: Crush <crush@charm.land>

* test(fsext): further simplifications

Co-Authored-By: Crush <crush@charm.land>

* test(fsext): improve readability

and yeet useless LLM comments

---------

Co-authored-by: Crush <crush@charm.land>

Change summary

internal/fsext/fileutil.go      |  10 +
internal/fsext/fileutil_test.go | 273 +++++++++++++++++++++++++++++++++++
2 files changed, 281 insertions(+), 2 deletions(-)

Detailed changes

internal/fsext/fileutil.go 🔗

@@ -75,6 +75,10 @@ func (w *FastGlobWalker) ShouldSkip(path string) bool {
 }
 
 func GlobWithDoubleStar(pattern, searchPath string, limit int) ([]string, bool, error) {
+	// Normalize pattern to forward slashes on Windows so their config can use
+	// backslashes
+	pattern = filepath.ToSlash(pattern)
+
 	walker := NewFastGlobWalker(searchPath)
 	var matches []FileInfo
 	conf := fastwalk.Config{
@@ -92,19 +96,21 @@ func GlobWithDoubleStar(pattern, searchPath string, limit int) ([]string, bool,
 			if walker.ShouldSkip(path) {
 				return filepath.SkipDir
 			}
-			return nil
 		}
 
 		if walker.ShouldSkip(path) {
 			return nil
 		}
 
-		// Check if path matches the pattern
 		relPath, err := filepath.Rel(searchPath, path)
 		if err != nil {
 			relPath = path
 		}
 
+		// Normalize separators to forward slashes
+		relPath = filepath.ToSlash(relPath)
+
+		// Check if path matches the pattern
 		matched, err := doublestar.Match(pattern, relPath)
 		if err != nil || !matched {
 			return nil

internal/fsext/fileutil_test.go 🔗

@@ -0,0 +1,273 @@
+package fsext
+
+import (
+	"fmt"
+	"os"
+	"path/filepath"
+	"testing"
+	"testing/synctest"
+	"time"
+
+	"github.com/stretchr/testify/require"
+)
+
+func TestGlobWithDoubleStar(t *testing.T) {
+	t.Run("finds files matching pattern", func(t *testing.T) {
+		testDir := t.TempDir()
+
+		mainGo := filepath.Join(testDir, "src", "main.go")
+		utilsGo := filepath.Join(testDir, "src", "utils.go")
+		helperGo := filepath.Join(testDir, "pkg", "helper.go")
+		readmeMd := filepath.Join(testDir, "README.md")
+
+		for _, file := range []string{mainGo, utilsGo, helperGo, readmeMd} {
+			require.NoError(t, os.MkdirAll(filepath.Dir(file), 0o755))
+			require.NoError(t, os.WriteFile(file, []byte("test content"), 0o644))
+		}
+
+		matches, truncated, err := GlobWithDoubleStar("**/main.go", testDir, 0)
+		require.NoError(t, err)
+		require.False(t, truncated)
+
+		require.Equal(t, matches, []string{mainGo})
+	})
+
+	t.Run("finds directories matching pattern", func(t *testing.T) {
+		testDir := t.TempDir()
+
+		srcDir := filepath.Join(testDir, "src")
+		pkgDir := filepath.Join(testDir, "pkg")
+		internalDir := filepath.Join(testDir, "internal")
+		cmdDir := filepath.Join(testDir, "cmd")
+		pkgFile := filepath.Join(testDir, "pkg.txt")
+
+		for _, dir := range []string{srcDir, pkgDir, internalDir, cmdDir} {
+			require.NoError(t, os.MkdirAll(dir, 0o755))
+		}
+
+		require.NoError(t, os.WriteFile(filepath.Join(srcDir, "main.go"), []byte("package main"), 0o644))
+		require.NoError(t, os.WriteFile(pkgFile, []byte("test"), 0o644))
+
+		matches, truncated, err := GlobWithDoubleStar("pkg", testDir, 0)
+		require.NoError(t, err)
+		require.False(t, truncated)
+
+		require.Equal(t, matches, []string{pkgDir})
+	})
+
+	t.Run("finds nested directories with wildcard patterns", func(t *testing.T) {
+		testDir := t.TempDir()
+
+		srcPkgDir := filepath.Join(testDir, "src", "pkg")
+		libPkgDir := filepath.Join(testDir, "lib", "pkg")
+		mainPkgDir := filepath.Join(testDir, "pkg")
+		otherDir := filepath.Join(testDir, "other")
+
+		for _, dir := range []string{srcPkgDir, libPkgDir, mainPkgDir, otherDir} {
+			require.NoError(t, os.MkdirAll(dir, 0o755))
+		}
+
+		matches, truncated, err := GlobWithDoubleStar("**/pkg", testDir, 0)
+		require.NoError(t, err)
+		require.False(t, truncated)
+
+		var relativeMatches []string
+		for _, match := range matches {
+			rel, err := filepath.Rel(testDir, match)
+			require.NoError(t, err)
+			relativeMatches = append(relativeMatches, filepath.ToSlash(rel))
+		}
+
+		require.ElementsMatch(t, relativeMatches, []string{"pkg", "src/pkg", "lib/pkg"})
+	})
+
+	t.Run("finds directory contents with recursive patterns", func(t *testing.T) {
+		testDir := t.TempDir()
+
+		pkgDir := filepath.Join(testDir, "pkg")
+		pkgFile1 := filepath.Join(pkgDir, "main.go")
+		pkgFile2 := filepath.Join(pkgDir, "utils.go")
+		pkgSubdir := filepath.Join(pkgDir, "internal")
+		pkgSubfile := filepath.Join(pkgSubdir, "helper.go")
+
+		require.NoError(t, os.MkdirAll(pkgSubdir, 0o755))
+
+		for _, file := range []string{pkgFile1, pkgFile2, pkgSubfile} {
+			require.NoError(t, os.WriteFile(file, []byte("package main"), 0o644))
+		}
+
+		matches, truncated, err := GlobWithDoubleStar("pkg/**", testDir, 0)
+		require.NoError(t, err)
+		require.False(t, truncated)
+
+		var relativeMatches []string
+		for _, match := range matches {
+			rel, err := filepath.Rel(testDir, match)
+			require.NoError(t, err)
+			relativeMatches = append(relativeMatches, filepath.ToSlash(rel))
+		}
+
+		require.ElementsMatch(t, relativeMatches, []string{
+			"pkg",
+			"pkg/main.go",
+			"pkg/utils.go",
+			"pkg/internal",
+			"pkg/internal/helper.go",
+		})
+	})
+
+	t.Run("respects limit parameter", func(t *testing.T) {
+		testDir := t.TempDir()
+
+		for i := range 10 {
+			file := filepath.Join(testDir, "file", fmt.Sprintf("test%d.txt", i))
+			require.NoError(t, os.MkdirAll(filepath.Dir(file), 0o755))
+			require.NoError(t, os.WriteFile(file, []byte("test"), 0o644))
+		}
+
+		matches, truncated, err := GlobWithDoubleStar("**/*.txt", testDir, 5)
+		require.NoError(t, err)
+		require.True(t, truncated, "Expected truncation with limit")
+		require.Len(t, matches, 5, "Expected exactly 5 matches with limit")
+	})
+
+	t.Run("handles nested directory patterns", func(t *testing.T) {
+		testDir := t.TempDir()
+
+		file1 := filepath.Join(testDir, "a", "b", "c", "file1.txt")
+		file2 := filepath.Join(testDir, "a", "b", "file2.txt")
+		file3 := filepath.Join(testDir, "a", "file3.txt")
+		file4 := filepath.Join(testDir, "file4.txt")
+
+		for _, file := range []string{file1, file2, file3, file4} {
+			require.NoError(t, os.MkdirAll(filepath.Dir(file), 0o755))
+			require.NoError(t, os.WriteFile(file, []byte("test"), 0o644))
+		}
+
+		matches, truncated, err := GlobWithDoubleStar("a/b/c/file1.txt", testDir, 0)
+		require.NoError(t, err)
+		require.False(t, truncated)
+
+		require.Equal(t, matches, []string{file1})
+	})
+
+	t.Run("returns results sorted by modification time (newest first)", func(t *testing.T) {
+		synctest.Test(t, func(t *testing.T) {
+			testDir := t.TempDir()
+
+			file1 := filepath.Join(testDir, "file1.txt")
+			require.NoError(t, os.WriteFile(file1, []byte("first"), 0o644))
+
+			file2 := filepath.Join(testDir, "file2.txt")
+			require.NoError(t, os.WriteFile(file2, []byte("second"), 0o644))
+
+			file3 := filepath.Join(testDir, "file3.txt")
+			require.NoError(t, os.WriteFile(file3, []byte("third"), 0o644))
+
+			base := time.Now()
+			m1 := base
+			m2 := base.Add(1 * time.Millisecond)
+			m3 := base.Add(2 * time.Millisecond)
+
+			require.NoError(t, os.Chtimes(file1, m1, m1))
+			require.NoError(t, os.Chtimes(file2, m2, m2))
+			require.NoError(t, os.Chtimes(file3, m3, m3))
+
+			matches, truncated, err := GlobWithDoubleStar("*.txt", testDir, 0)
+			require.NoError(t, err)
+			require.False(t, truncated)
+
+			require.Equal(t, matches, []string{file3, file2, file1})
+		})
+	})
+
+	t.Run("handles empty directory", func(t *testing.T) {
+		testDir := t.TempDir()
+
+		matches, truncated, err := GlobWithDoubleStar("**", testDir, 0)
+		require.NoError(t, err)
+		require.False(t, truncated)
+		// Even empty directories should return the directory itself
+		require.Equal(t, matches, []string{testDir})
+	})
+
+	t.Run("handles non-existent search path", func(t *testing.T) {
+		nonExistentDir := filepath.Join(t.TempDir(), "does", "not", "exist")
+
+		matches, truncated, err := GlobWithDoubleStar("**", nonExistentDir, 0)
+		require.Error(t, err, "Should return error for non-existent search path")
+		require.False(t, truncated)
+		require.Empty(t, matches)
+	})
+
+	t.Run("respects basic ignore patterns", func(t *testing.T) {
+		testDir := t.TempDir()
+
+		rootIgnore := filepath.Join(testDir, ".crushignore")
+
+		require.NoError(t, os.WriteFile(rootIgnore, []byte("*.tmp\nbackup/\n"), 0o644))
+
+		goodFile := filepath.Join(testDir, "good.txt")
+		require.NoError(t, os.WriteFile(goodFile, []byte("content"), 0o644))
+
+		badFile := filepath.Join(testDir, "bad.tmp")
+		require.NoError(t, os.WriteFile(badFile, []byte("temp content"), 0o644))
+
+		goodDir := filepath.Join(testDir, "src")
+		require.NoError(t, os.MkdirAll(goodDir, 0o755))
+
+		ignoredDir := filepath.Join(testDir, "backup")
+		require.NoError(t, os.MkdirAll(ignoredDir, 0o755))
+
+		ignoredFileInDir := filepath.Join(testDir, "backup", "old.txt")
+		require.NoError(t, os.WriteFile(ignoredFileInDir, []byte("old content"), 0o644))
+
+		matches, truncated, err := GlobWithDoubleStar("*.tmp", testDir, 0)
+		require.NoError(t, err)
+		require.False(t, truncated)
+		require.Empty(t, matches, "Expected no matches for '*.tmp' pattern (should be ignored)")
+
+		matches, truncated, err = GlobWithDoubleStar("backup", testDir, 0)
+		require.NoError(t, err)
+		require.False(t, truncated)
+		require.Empty(t, matches, "Expected no matches for 'backup' pattern (should be ignored)")
+
+		matches, truncated, err = GlobWithDoubleStar("*.txt", testDir, 0)
+		require.NoError(t, err)
+		require.False(t, truncated)
+		require.Equal(t, matches, []string{goodFile})
+	})
+
+	t.Run("handles mixed file and directory matching with sorting", func(t *testing.T) {
+		synctest.Test(t, func(t *testing.T) {
+			testDir := t.TempDir()
+
+			oldestFile := filepath.Join(testDir, "old.test")
+			require.NoError(t, os.WriteFile(oldestFile, []byte("old"), 0o644))
+
+			middleDir := filepath.Join(testDir, "mid.test")
+			require.NoError(t, os.MkdirAll(middleDir, 0o755))
+
+			newestFile := filepath.Join(testDir, "new.test")
+			require.NoError(t, os.WriteFile(newestFile, []byte("new"), 0o644))
+
+			base := time.Now()
+			tOldest := base
+			tMiddle := base.Add(1 * time.Millisecond)
+			tNewest := base.Add(2 * time.Millisecond)
+
+			// Reverse the expected order
+			require.NoError(t, os.Chtimes(newestFile, tOldest, tOldest))
+			require.NoError(t, os.Chtimes(middleDir, tMiddle, tMiddle))
+			require.NoError(t, os.Chtimes(oldestFile, tNewest, tNewest))
+
+			matches, truncated, err := GlobWithDoubleStar("*.test", testDir, 0)
+			require.NoError(t, err)
+			require.False(t, truncated)
+
+			// Results should be sorted by mod time, but we set the oldestFile
+			// to have the most recent mod time
+			require.Equal(t, matches, []string{oldestFile, middleDir, newestFile})
+		})
+	})
+}