diff --git a/internal/project/discovertypings.go b/internal/project/discovertypings.go index f36400f13d..9fd91730e8 100644 --- a/internal/project/discovertypings.go +++ b/internal/project/discovertypings.go @@ -13,6 +13,7 @@ import ( "github.com/microsoft/typescript-go/internal/semver" "github.com/microsoft/typescript-go/internal/tspath" "github.com/microsoft/typescript-go/internal/vfs" + "github.com/microsoft/typescript-go/internal/vfs/vfsmatch" ) type CachedTyping struct { @@ -220,7 +221,7 @@ func addTypingNamesAndGetFilesToWatch( } else { // And #2. Depth = 3 because scoped packages look like `node_modules/@foo/bar/package.json` depth := 3 - for _, manifestPath := range vfs.ReadDirectory(fs, projectRootPath, packagesFolderPath, []string{tspath.ExtensionJson}, nil, nil, &depth) { + for _, manifestPath := range vfsmatch.ReadDirectory(fs, projectRootPath, packagesFolderPath, []string{tspath.ExtensionJson}, nil, nil, &depth) { if tspath.GetBaseFileName(manifestPath) != manifestName { continue } diff --git a/internal/tsoptions/tsconfigparsing.go b/internal/tsoptions/tsconfigparsing.go index 8a90a3475b..35553b7733 100644 --- a/internal/tsoptions/tsconfigparsing.go +++ b/internal/tsoptions/tsconfigparsing.go @@ -1,13 +1,11 @@ package tsoptions import ( - "fmt" "reflect" "regexp" "slices" "strings" - "github.com/dlclark/regexp2" "github.com/microsoft/typescript-go/internal/ast" "github.com/microsoft/typescript-go/internal/collections" "github.com/microsoft/typescript-go/internal/core" @@ -18,6 +16,7 @@ import ( "github.com/microsoft/typescript-go/internal/scanner" "github.com/microsoft/typescript-go/internal/tspath" "github.com/microsoft/typescript-go/internal/vfs" + "github.com/microsoft/typescript-go/internal/vfs/vfsmatch" ) type extendsResult struct { @@ -99,36 +98,11 @@ type configFileSpecs struct { } func (c *configFileSpecs) matchesExclude(fileName string, comparePathsOptions tspath.ComparePathsOptions) bool { - if len(c.validatedExcludeSpecs) == 0 { - return false - } - excludePattern := vfs.GetRegularExpressionForWildcard(c.validatedExcludeSpecs, comparePathsOptions.CurrentDirectory, "exclude") - excludeRegex := vfs.GetRegexFromPattern(excludePattern, comparePathsOptions.UseCaseSensitiveFileNames) - if match, err := excludeRegex.MatchString(fileName); err == nil && match { - return true - } - if !tspath.HasExtension(fileName) { - if match, err := excludeRegex.MatchString(tspath.EnsureTrailingDirectorySeparator(fileName)); err == nil && match { - return true - } - } - return false + return vfsmatch.MatchesExclude(fileName, c.validatedExcludeSpecs, comparePathsOptions.CurrentDirectory, comparePathsOptions.UseCaseSensitiveFileNames) } func (c *configFileSpecs) matchesInclude(fileName string, comparePathsOptions tspath.ComparePathsOptions) bool { - if len(c.validatedIncludeSpecs) == 0 { - return false - } - for _, spec := range c.validatedIncludeSpecs { - includePattern := vfs.GetPatternFromSpec(spec, comparePathsOptions.CurrentDirectory, "files") - if includePattern != "" { - includeRegex := vfs.GetRegexFromPattern(includePattern, comparePathsOptions.UseCaseSensitiveFileNames) - if match, err := includeRegex.MatchString(fileName); err == nil && match { - return true - } - } - } - return false + return vfsmatch.MatchesInclude(fileName, c.validatedIncludeSpecs, comparePathsOptions.CurrentDirectory, comparePathsOptions.UseCaseSensitiveFileNames) } type FileExtensionInfo struct { @@ -1571,24 +1545,15 @@ func getFileNamesFromConfigSpecs( literalFileMap.Set(keyMappper(fileName), file) } - var jsonOnlyIncludeRegexes []*regexp2.Regexp + var jsonOnlyIncludeSpecs []string if len(validatedIncludeSpecs) > 0 { - files := vfs.ReadDirectory(host, basePath, basePath, core.Flatten(supportedExtensionsWithJsonIfResolveJsonModule), validatedExcludeSpecs, validatedIncludeSpecs, nil) + files := vfsmatch.ReadDirectory(host, basePath, basePath, core.Flatten(supportedExtensionsWithJsonIfResolveJsonModule), validatedExcludeSpecs, validatedIncludeSpecs, nil) for _, file := range files { if tspath.FileExtensionIs(file, tspath.ExtensionJson) { - if jsonOnlyIncludeRegexes == nil { - includes := core.Filter(validatedIncludeSpecs, func(include string) bool { return strings.HasSuffix(include, tspath.ExtensionJson) }) - includeFilePatterns := core.Map(vfs.GetRegularExpressionsForWildcards(includes, basePath, "files"), func(pattern string) string { return fmt.Sprintf("^%s$", pattern) }) - if includeFilePatterns != nil { - jsonOnlyIncludeRegexes = core.Map(includeFilePatterns, func(pattern string) *regexp2.Regexp { - return vfs.GetRegexFromPattern(pattern, host.UseCaseSensitiveFileNames()) - }) - } else { - jsonOnlyIncludeRegexes = nil - } + if jsonOnlyIncludeSpecs == nil { + jsonOnlyIncludeSpecs = core.Filter(validatedIncludeSpecs, func(include string) bool { return strings.HasSuffix(include, tspath.ExtensionJson) }) } - includeIndex := core.FindIndex(jsonOnlyIncludeRegexes, func(re *regexp2.Regexp) bool { return core.Must(re.MatchString(file)) }) - if includeIndex != -1 { + if vfsmatch.MatchesIncludeWithJsonOnly(file, jsonOnlyIncludeSpecs, basePath, host.UseCaseSensitiveFileNames()) { key := keyMappper(file) if !literalFileMap.Has(key) && !wildCardJsonFileMap.Has(key) { wildCardJsonFileMap.Set(key, file) diff --git a/internal/tsoptions/wildcarddirectories.go b/internal/tsoptions/wildcarddirectories.go index 33068745ac..13869a254e 100644 --- a/internal/tsoptions/wildcarddirectories.go +++ b/internal/tsoptions/wildcarddirectories.go @@ -1,12 +1,10 @@ package tsoptions import ( - "regexp" "strings" - "github.com/dlclark/regexp2" "github.com/microsoft/typescript-go/internal/tspath" - "github.com/microsoft/typescript-go/internal/vfs" + "github.com/microsoft/typescript-go/internal/vfs/vfsmatch" ) func getWildcardDirectories(include []string, exclude []string, comparePathsOptions tspath.ComparePathsOptions) map[string]bool { @@ -27,16 +25,6 @@ func getWildcardDirectories(include []string, exclude []string, comparePathsOpti return nil } - rawExcludeRegex := vfs.GetRegularExpressionForWildcard(exclude, comparePathsOptions.CurrentDirectory, "exclude") - var excludeRegex *regexp.Regexp - if rawExcludeRegex != "" { - options := "" - if !comparePathsOptions.UseCaseSensitiveFileNames { - options = "(?i)" - } - excludeRegex = regexp.MustCompile(options + rawExcludeRegex) - } - wildcardDirectories := make(map[string]bool) wildCardKeyToPath := make(map[string]string) @@ -44,7 +32,7 @@ func getWildcardDirectories(include []string, exclude []string, comparePathsOpti for _, file := range include { spec := tspath.NormalizeSlashes(tspath.CombinePaths(comparePathsOptions.CurrentDirectory, file)) - if excludeRegex != nil && excludeRegex.MatchString(spec) { + if vfsmatch.MatchesExclude(spec, exclude, comparePathsOptions.CurrentDirectory, comparePathsOptions.UseCaseSensitiveFileNames) { continue } @@ -99,9 +87,6 @@ func toCanonicalKey(path string, useCaseSensitiveFileNames bool) string { return strings.ToLower(path) } -// wildcardDirectoryPattern matches paths with wildcard characters -var wildcardDirectoryPattern = regexp2.MustCompile(`^[^*?]*(?=\/[^/]*[*?])`, 0) - // wildcardDirectoryMatch represents the result of a wildcard directory match type wildcardDirectoryMatch struct { Key string @@ -110,27 +95,55 @@ type wildcardDirectoryMatch struct { } func getWildcardDirectoryFromSpec(spec string, useCaseSensitiveFileNames bool) *wildcardDirectoryMatch { - match, _ := wildcardDirectoryPattern.FindStringMatch(spec) - if match != nil { - // We check this with a few `Index` calls because it's more efficient than complex regex - questionWildcardIndex := strings.Index(spec, "?") - starWildcardIndex := strings.Index(spec, "*") - lastDirectorySeparatorIndex := strings.LastIndexByte(spec, tspath.DirectorySeparator) - - // Determine if this should be watched recursively - recursive := (questionWildcardIndex != -1 && questionWildcardIndex < lastDirectorySeparatorIndex) || - (starWildcardIndex != -1 && starWildcardIndex < lastDirectorySeparatorIndex) - - return &wildcardDirectoryMatch{ - Key: toCanonicalKey(match.String(), useCaseSensitiveFileNames), - Path: match.String(), - Recursive: recursive, + // Find the first occurrence of wildcards (* or ?) + questionWildcardIndex := strings.IndexByte(spec, '?') + starWildcardIndex := strings.IndexByte(spec, '*') + + // Find the earliest wildcard + var firstWildcardIndex int + if questionWildcardIndex == -1 || starWildcardIndex == -1 { + firstWildcardIndex = max(questionWildcardIndex, starWildcardIndex) + } else { + firstWildcardIndex = min(questionWildcardIndex, starWildcardIndex) + } + + if firstWildcardIndex != -1 { + // Find the last directory separator before the first wildcard + prefixBeforeWildcard := spec[:firstWildcardIndex] + lastSepIndex := strings.LastIndexByte(prefixBeforeWildcard, tspath.DirectorySeparator) + + if lastSepIndex != -1 { + // Check if there are wildcards in the segment after this directory separator + segmentStart := lastSepIndex + 1 + segmentEnd := strings.IndexByte(spec[segmentStart:], tspath.DirectorySeparator) + if segmentEnd == -1 { + segmentEnd = len(spec) + } else { + segmentEnd += segmentStart + } + + segment := spec[segmentStart:segmentEnd] + if strings.ContainsAny(segment, "*?") { + path := spec[:lastSepIndex] + + lastDirectorySeparatorIndex := strings.LastIndexByte(spec, tspath.DirectorySeparator) + + // Determine if this should be watched recursively + lastWildcardIndex := max(questionWildcardIndex, starWildcardIndex) + recursive := 0 <= lastWildcardIndex && lastWildcardIndex < lastDirectorySeparatorIndex + + return &wildcardDirectoryMatch{ + Key: toCanonicalKey(path, useCaseSensitiveFileNames), + Path: path, + Recursive: recursive, + } + } } } if lastSepIndex := strings.LastIndexByte(spec, tspath.DirectorySeparator); lastSepIndex != -1 { lastSegment := spec[lastSepIndex+1:] - if vfs.IsImplicitGlob(lastSegment) { + if vfsmatch.IsImplicitGlob(lastSegment) { path := tspath.RemoveTrailingDirectorySeparator(spec) return &wildcardDirectoryMatch{ Key: toCanonicalKey(path, useCaseSensitiveFileNames), diff --git a/internal/tspath/path.go b/internal/tspath/path.go index a0d722b233..8d968c0174 100644 --- a/internal/tspath/path.go +++ b/internal/tspath/path.go @@ -129,9 +129,19 @@ func GetPathComponents(path string, currentDirectory string) []string { func pathComponents(path string, rootLength int) []string { root := path[:rootLength] - rest := strings.Split(path[rootLength:], "/") - if len(rest) > 0 && rest[len(rest)-1] == "" { - rest = rest[:len(rest)-1] + restStr := path[rootLength:] + var rest []string + start := 0 + for i := 0; i <= len(restStr); i++ { + if i == len(restStr) || restStr[i] == '/' { + if i > start { + seg := restStr[start:i] + if seg != "" { + rest = append(rest, seg) + } + } + start = i + 1 + } } return append([]string{root}, rest...) } @@ -279,21 +289,17 @@ func reducePathComponents(components []string) []string { if len(components) == 0 { return []string{} } - reduced := []string{components[0]} + reduced := make([]string, 0, len(components)) + reduced = append(reduced, components[0]) for i := 1; i < len(components); i++ { component := components[i] - if component == "" { - continue - } - if component == "." { + if component == "" || component == "." { continue } if component == ".." { - if len(reduced) > 1 { - if reduced[len(reduced)-1] != ".." { - reduced = reduced[:len(reduced)-1] - continue - } + if len(reduced) > 1 && reduced[len(reduced)-1] != ".." { + reduced = reduced[:len(reduced)-1] + continue } else if reduced[0] != "" { continue } diff --git a/internal/vfs/vfsmatch/bench_test.go b/internal/vfs/vfsmatch/bench_test.go new file mode 100644 index 0000000000..1d7196027c --- /dev/null +++ b/internal/vfs/vfsmatch/bench_test.go @@ -0,0 +1,274 @@ +package vfsmatch + +import ( + "fmt" + "testing" + + "github.com/microsoft/typescript-go/internal/vfs" + "github.com/microsoft/typescript-go/internal/vfs/cachedvfs" + "github.com/microsoft/typescript-go/internal/vfs/vfstest" +) + +func BenchmarkMatchFiles(b *testing.B) { + currentDirectory := "/" + var depth *int = nil + + benchCases := []struct { + name string + path string + exts []string + excludes []string + includes []string + useFS func(bool) vfs.FS + }{ + { + name: "CommonPattern", + path: "/", + exts: []string{".ts", ".tsx"}, + excludes: []string{"**/node_modules/**", "**/dist/**", "**/.hidden/**", "**/*.min.js"}, + includes: []string{"src/**/*", "test/**/*.spec.*"}, + useFS: setupComplexTestFS, + }, + { + name: "SimpleInclude", + path: "/src", + exts: []string{".ts", ".tsx"}, + excludes: nil, + includes: []string{"**/*.ts"}, + useFS: setupComplexTestFS, + }, + { + name: "EmptyIncludes", + path: "/src", + exts: []string{".ts", ".tsx"}, + excludes: []string{"**/node_modules/**"}, + includes: []string{}, + useFS: setupComplexTestFS, + }, + { + name: "HiddenDirectories", + path: "/", + exts: []string{".json"}, + excludes: nil, + includes: []string{"**/*", ".vscode/*.json"}, + useFS: setupComplexTestFS, + }, + { + name: "NodeModulesSearch", + path: "/", + exts: []string{".ts", ".tsx", ".js"}, + excludes: []string{"**/node_modules/m2/**/*"}, + includes: []string{"**/*", "**/node_modules/**/*"}, + useFS: setupComplexTestFS, + }, + { + name: "LargeFileSystem", + path: "/", + exts: []string{".ts", ".tsx", ".js"}, + excludes: []string{"**/node_modules/**", "**/dist/**", "**/.hidden/**"}, + includes: []string{"src/**/*", "tests/**/*.spec.*"}, + useFS: setupLargeTestFS, + }, + } + + for _, bc := range benchCases { + // Create the appropriate file system for this benchmark case + fs := bc.useFS(true) + // Wrap with cached FS for the benchmark + fs = cachedvfs.From(fs) + + b.Run(bc.name+"/Original", func(b *testing.B) { + b.ReportAllocs() + + for b.Loop() { + matchFilesOld(bc.path, bc.exts, bc.excludes, bc.includes, fs.UseCaseSensitiveFileNames(), currentDirectory, depth, fs) + } + }) + + b.Run(bc.name+"/New", func(b *testing.B) { + b.ReportAllocs() + + for b.Loop() { + matchFilesNew(bc.path, bc.exts, bc.excludes, bc.includes, fs.UseCaseSensitiveFileNames(), currentDirectory, depth, fs) + } + }) + } +} + +// setupTestFS creates a test file system with a specific structure for testing glob patterns +func setupTestFS(useCaseSensitiveFileNames bool) vfs.FS { + return vfstest.FromMap(map[string]any{ + "/src/foo.ts": "export const foo = 1;", + "/src/bar.ts": "export const bar = 2;", + "/src/baz.tsx": "export const baz = 3;", + "/src/subfolder/qux.ts": "export const qux = 4;", + "/src/subfolder/quux.tsx": "export const quux = 5;", + "/src/node_modules/lib.ts": "export const lib = 6;", + "/src/.hidden/secret.ts": "export const secret = 7;", + "/src/test.min.js": "console.log('minified');", + "/dist/output.js": "console.log('output');", + "/build/temp.ts": "export const temp = 8;", + "/test/test1.spec.ts": "describe('test1', () => {});", + "/test/test2.spec.tsx": "describe('test2', () => {});", + "/test/subfolder/test3.spec.ts": "describe('test3', () => {});", + }, useCaseSensitiveFileNames) +} + +// setupComplexTestFS creates a more complex test file system for additional pattern testing +func setupComplexTestFS(useCaseSensitiveFileNames bool) vfs.FS { + return vfstest.FromMap(map[string]any{ + // Regular source files + "/src/index.ts": "export * from './utils';", + "/src/utils.ts": "export function add(a: number, b: number): number { return a + b; }", + "/src/utils.d.ts": "export declare function add(a: number, b: number): number;", + "/src/models/user.ts": "export interface User { id: string; name: string; }", + "/src/models/product.ts": "export interface Product { id: string; price: number; }", + + // Nested directories + "/src/components/button/index.tsx": "export const Button = () => ;", + "/src/components/input/index.tsx": "export const Input = () => ;", + "/src/components/form/index.tsx": "export const Form = () =>
;", + + // Test files + "/tests/unit/utils.test.ts": "import { add } from '../../src/utils';", + "/tests/integration/app.test.ts": "import { app } from '../../src/app';", + + // Node modules + "/node_modules/lodash/index.js": "// lodash package", + "/node_modules/react/index.js": "// react package", + "/node_modules/typescript/lib/typescript.js": "// typescript package", + "/node_modules/@types/react/index.d.ts": "// react types", + + // Various file types + "/build/index.js": "console.log('built')", + "/assets/logo.png": "binary content", + "/assets/images/banner.jpg": "binary content", + "/assets/fonts/roboto.ttf": "binary content", + "/.git/HEAD": "ref: refs/heads/main", + "/.vscode/settings.json": "{ \"typescript.enable\": true }", + "/package.json": "{ \"name\": \"test-project\" }", + "/README.md": "# Test Project", + + // Files with special characters + "/src/special-case.ts": "export const special = 'case';", + "/src/[id].ts": "export const dynamic = (id) => id;", + "/src/weird.name.ts": "export const weird = 'name';", + "/src/problem?.ts": "export const problem = 'maybe';", + "/src/with space.ts": "export const withSpace = 'test';", + }, useCaseSensitiveFileNames) +} + +// setupLargeTestFS creates a test file system with thousands of files for benchmarking +func setupLargeTestFS(useCaseSensitiveFileNames bool) vfs.FS { + // Create a map to hold all the files + files := make(map[string]any) + + // Add some standard structure + files["/src/index.ts"] = "export * from './lib';" + files["/src/lib.ts"] = "export const VERSION = '1.0.0';" + files["/package.json"] = "{ \"name\": \"large-test-project\" }" + files["/.vscode/settings.json"] = "{ \"typescript.enable\": true }" + files["/node_modules/typescript/package.json"] = "{ \"name\": \"typescript\", \"version\": \"5.0.0\" }" + + // Add 1000 TypeScript files in src/components + for i := range 1000 { + files[fmt.Sprintf("/src/components/component%d.ts", i)] = fmt.Sprintf("export const Component%d = () => null;", i) + } + + // Add 500 TypeScript files in src/utils with nested structure + for i := range 500 { + folder := i % 10 // Create 10 different folders + files[fmt.Sprintf("/src/utils/folder%d/util%d.ts", folder, i)] = fmt.Sprintf("export function util%d() { return %d; }", i, i) + } + + // Add 500 test files + for i := range 500 { + files[fmt.Sprintf("/tests/unit/test%d.spec.ts", i)] = fmt.Sprintf("describe('test%d', () => { it('works', () => {}) });", i) + } + + // Add 200 files in node_modules with various extensions + for i := range 200 { + pkg := i % 20 // Create 20 different packages + files[fmt.Sprintf("/node_modules/pkg%d/file%d.js", pkg, i)] = fmt.Sprintf("module.exports = { value: %d };", i) + + // Add some .d.ts files + if i < 50 { + files[fmt.Sprintf("/node_modules/pkg%d/types/file%d.d.ts", pkg, i)] = "export declare const value: number;" + } + } + + // Add 100 files in dist directory (build output) + for i := range 100 { + files[fmt.Sprintf("/dist/file%d.js", i)] = fmt.Sprintf("console.log(%d);", i) + } + + // Add some hidden files + for i := range 50 { + files[fmt.Sprintf("/.hidden/file%d.ts", i)] = fmt.Sprintf("// Hidden file %d", i) + } + + return vfstest.FromMap(files, useCaseSensitiveFileNames) +} + +func BenchmarkMatchFilesLarge(b *testing.B) { + fs := setupLargeTestFS(true) + // Wrap with cached FS for the benchmark + fs = cachedvfs.From(fs) + currentDirectory := "/" + var depth *int = nil + + benchCases := []struct { + name string + path string + exts []string + excludes []string + includes []string + }{ + { + name: "AllFiles", + path: "/", + exts: []string{".ts", ".tsx", ".js"}, + excludes: []string{"**/node_modules/**", "**/dist/**"}, + includes: []string{"**/*"}, + }, + { + name: "Components", + path: "/src/components", + exts: []string{".ts"}, + excludes: nil, + includes: []string{"**/*.ts"}, + }, + { + name: "TestFiles", + path: "/tests", + exts: []string{".ts"}, + excludes: nil, + includes: []string{"**/*.spec.ts"}, + }, + { + name: "NestedUtilsWithPattern", + path: "/src/utils", + exts: []string{".ts"}, + excludes: nil, + includes: []string{"**/folder*/*.ts"}, + }, + } + + for _, bc := range benchCases { + b.Run(bc.name+"/Original", func(b *testing.B) { + b.ReportAllocs() + + for b.Loop() { + matchFilesOld(bc.path, bc.exts, bc.excludes, bc.includes, fs.UseCaseSensitiveFileNames(), currentDirectory, depth, fs) + } + }) + + b.Run(bc.name+"/New", func(b *testing.B) { + b.ReportAllocs() + + for b.Loop() { + matchFilesNew(bc.path, bc.exts, bc.excludes, bc.includes, fs.UseCaseSensitiveFileNames(), currentDirectory, depth, fs) + } + }) + } +} diff --git a/internal/vfs/vfsmatch/new.go b/internal/vfs/vfsmatch/new.go new file mode 100644 index 0000000000..987364aae3 --- /dev/null +++ b/internal/vfs/vfsmatch/new.go @@ -0,0 +1,785 @@ +package vfsmatch + +import ( + "slices" + "strings" + + "github.com/microsoft/typescript-go/internal/collections" + "github.com/microsoft/typescript-go/internal/core" + "github.com/microsoft/typescript-go/internal/tspath" + "github.com/microsoft/typescript-go/internal/vfs" +) + +const ( + // minJsExtension is the file extension for minified JavaScript files + // These files should be excluded from wildcard matching unless explicitly included + minJsExtension = ".min.js" +) + +// parsePathSegments splits a path into segments and removes empty segments +func parsePathSegments(path string) []string { + if path == "" { + return []string{} + } + + segments := strings.Split(path, "/") + // Remove empty segments + filteredSegments := segments[:0] + for _, seg := range segments { + if seg != "" { + filteredSegments = append(filteredSegments, seg) + } + } + return filteredSegments +} + +// convertToRelativePath converts an absolute path to a relative path from the given base path +func convertToRelativePath(absolutePath, basePath string) string { + if basePath == "" || !strings.HasPrefix(absolutePath, basePath) { + return absolutePath + } + + relativePath := absolutePath[len(basePath):] + if strings.HasPrefix(relativePath, "/") { + relativePath = relativePath[1:] + } + return relativePath +} + +// buildPath efficiently builds a path by combining base and current components +func buildPath(base, current string) string { + var builder strings.Builder + builder.Grow(len(base) + len(current) + 2) + + if base == "" { + builder.WriteString(current) + } else { + builder.WriteString(base) + if base[len(base)-1] != '/' { + builder.WriteByte('/') + } + builder.WriteString(current) + } + return builder.String() +} + +// applyImplicitGlob applies implicit glob expansion to segments if needed +// If the last component has no extension and no wildcards, adds **/* +func applyImplicitGlob(segments []string) []string { + if len(segments) > 0 { + lastComponent := segments[len(segments)-1] + if IsImplicitGlob(lastComponent) { + return append(segments, "**", "*") + } + } + return segments +} + +// isImplicitlyExcluded checks if a file or directory should be implicitly excluded +// based on TypeScript's default behavior (dotted files/folders and common package folders) +func isImplicitlyExcluded(name string, isDirectory bool) bool { + // Exclude files/directories that start with a dot + if strings.HasPrefix(name, ".") { + return true + } + + // For directories, exclude common package folders + if isDirectory && slices.Contains(commonPackageFolders, name) { + return true + } + + return false +} + +// shouldImplicitlyExcludeRelativePath checks if a relative path should be implicitly excluded +func shouldImplicitlyExcludeRelativePath(relativePath string) bool { + if relativePath == "" { + return false + } + + // Split path into segments and check each segment + for segment := range strings.SplitSeq(relativePath, "/") { + if isImplicitlyExcluded(segment, true) { // Check as directory since it's a path segment + return true + } + } + + return false +} + +// matchFilesNew is the regex-free implementation of file matching +func matchFilesNew(path string, extensions []string, excludes []string, includes []string, useCaseSensitiveFileNames bool, currentDirectory string, depth *int, host vfs.FS) []string { + path = tspath.NormalizePath(path) + currentDirectory = tspath.NormalizePath(currentDirectory) + absolutePath := tspath.CombinePaths(currentDirectory, path) + + basePaths := getBasePaths(path, includes, useCaseSensitiveFileNames) + + // If no base paths found, return nil (consistent with original implementation) + if len(basePaths) == 0 { + return nil + } + + // Create relative pattern matchers + includeMatchers := make([]globMatcher, len(includes)) + for i, include := range includes { + includeMatchers[i] = globMatcherForPatternRelative(include, useCaseSensitiveFileNames) + } + + excludeMatchers := make([]globMatcher, len(excludes)) + for i, exclude := range excludes { + excludeMatchers[i] = globMatcherForPatternAbsolute(exclude, absolutePath, useCaseSensitiveFileNames) + } + + // Associate an array of results with each include matcher. This keeps results in order of the "include" order. + // If there are no "includes", then just put everything in results[0]. + var results [][]string + if len(includeMatchers) > 0 { + tempResults := make([][]string, len(includeMatchers)) + for i := range includeMatchers { + tempResults[i] = []string{} + } + results = tempResults + } else { + results = [][]string{{}} + } + + visitor := newRelativeGlobVisitor{ + useCaseSensitiveFileNames: useCaseSensitiveFileNames, + host: host, + includeMatchers: includeMatchers, + excludeMatchers: excludeMatchers, + extensions: extensions, + results: results, + visited: *collections.NewSetWithSizeHint[string](0), + basePath: absolutePath, + } + + for _, basePath := range basePaths { + visitor.visitDirectory(basePath, tspath.CombinePaths(currentDirectory, basePath), depth) + } + + flattened := core.Flatten(results) + if len(flattened) == 0 { + return nil // Consistent with original implementation + } + return flattened +} + +// globMatcher represents a glob pattern matcher without using regex +type globMatcher struct { + pattern string + basePath string + useCaseSensitiveFileNames bool + segments []string +} + +// couldMatchInSubdirectoryRecursive checks if the pattern could match files under the given path +func (gm globMatcher) couldMatchInSubdirectoryRecursive(patternSegments []string, pathSegments []string) bool { + if len(patternSegments) == 0 { + return false + } + + pattern := patternSegments[0] + remainingPattern := patternSegments[1:] + + if pattern == "**" { + // Double asterisk can match anywhere + return true + } + + if len(pathSegments) == 0 { + // We've run out of path but still have pattern segments + // This means we could match files in the current directory + return true + } + + pathSegment := pathSegments[0] + remainingPath := pathSegments[1:] + + // Check if this segment matches + if gm.matchSegment(pattern, pathSegment) { + // If we match and have more pattern segments, continue + if len(remainingPattern) > 0 { + return gm.couldMatchInSubdirectoryRecursive(remainingPattern, remainingPath) + } + // If no more pattern segments, we could match files in this directory + return true + } + + return false +} + +// matchSegments recursively matches glob pattern segments against path segments +func (gm globMatcher) matchSegments(patternSegments []string, pathSegments []string, isDirectory bool) bool { + pi, ti := 0, 0 + plen, tlen := len(patternSegments), len(pathSegments) + + // Special case: standalone "**" pattern matches everything + if plen == 1 && patternSegments[0] == "**" { + return true + } + + // Special case for directory matching: if the path is a prefix of the pattern (ignoring final wildcards), + // then it matches. This handles cases like pattern "LICENSE/**/*" matching directory "LICENSE/" + if isDirectory && tlen < plen { + // Check if path segments match the beginning of pattern segments + matchesPrefix := true + for i := 0; i < tlen && i < plen; i++ { + if patternSegments[i] == "**" { + // If we hit ** in the pattern, we're done - this directory could contain matching files + break + } + if !gm.matchSegment(patternSegments[i], pathSegments[i]) { + matchesPrefix = false + break + } + } + + if matchesPrefix && tlen < plen { + // Check if the remaining pattern segments are wildcards that could match files in this directory + remainingPattern := patternSegments[tlen:] + if len(remainingPattern) > 0 && (remainingPattern[0] == "**" || + (len(remainingPattern) >= 2 && remainingPattern[0] == "**" && remainingPattern[1] == "*")) { + return true + } + } + } + + for pi < plen { + pattern := patternSegments[pi] + if pattern == "**" { + // Try matching remaining pattern at current position + if gm.matchSegments(patternSegments[pi+1:], pathSegments[ti:], isDirectory) { + return true + } + // Try consuming one path segment and continue with ** + for ti < tlen && (isDirectory || tlen-ti > 1) { + ti++ + if gm.matchSegments(patternSegments[pi+1:], pathSegments[ti:], isDirectory) { + return true + } + } + return false + } + if ti >= tlen { + return false + } + pathSegment := pathSegments[ti] + isFinalSegment := (pi == plen-1) && (ti == tlen-1) + isFileSegment := !isDirectory && isFinalSegment + var segmentMatches bool + if isFileSegment { + segmentMatches = gm.matchSegmentForFile(pattern, pathSegment) + } else { + segmentMatches = gm.matchSegment(pattern, pathSegment) + } + if !segmentMatches { + return false + } + pi++ + ti++ + } + return ti == tlen +} + +// normalizeForCaseSensitivity converts pattern and segment to lowercase if case-insensitive matching is enabled +func (gm globMatcher) normalizeForCaseSensitivity(pattern, segment string) (string, string) { + if !gm.useCaseSensitiveFileNames { + return strings.ToLower(pattern), strings.ToLower(segment) + } + return pattern, segment +} + +// matchSegment matches a single glob pattern segment against a path segment +func (gm globMatcher) matchSegment(pattern, segment string) bool { + pattern, segment = gm.normalizeForCaseSensitivity(pattern, segment) + return gm.matchGlobPattern(pattern, segment, false) +} + +func (gm globMatcher) matchSegmentForFile(pattern, segment string) bool { + pattern, segment = gm.normalizeForCaseSensitivity(pattern, segment) + return gm.matchGlobPattern(pattern, segment, true) +} + +// matchGlobPattern implements glob pattern matching for a single segment +func (gm globMatcher) matchGlobPattern(pattern, text string, isFileMatch bool) bool { + pi, ti := 0, 0 + starIdx, match := -1, 0 + + for ti < len(text) { + if pi < len(pattern) && (pattern[pi] == '?' || pattern[pi] == text[ti]) { + pi++ + ti++ + } else if pi < len(pattern) && pattern[pi] == '*' { + // For file matching, * should not match .min.js files UNLESS the pattern explicitly ends with .min.js + if isFileMatch && strings.HasSuffix(text, minJsExtension) && !strings.HasSuffix(pattern, minJsExtension) { + return false + } + starIdx = pi + match = ti + pi++ + } else if starIdx != -1 { + pi = starIdx + 1 + match++ + ti = match + } else { + return false + } + } + + // Handle remaining '*' in pattern + for pi < len(pattern) && pattern[pi] == '*' { + pi++ + } + + return pi == len(pattern) +} + +type newRelativeGlobVisitor struct { + includeMatchers []globMatcher + excludeMatchers []globMatcher + extensions []string + useCaseSensitiveFileNames bool + host vfs.FS + visited collections.Set[string] + results [][]string + basePath string // The absolute base path for the search +} + +func (v *newRelativeGlobVisitor) visitDirectory(path string, absolutePath string, depth *int) { + canonicalPath := tspath.GetCanonicalFileName(absolutePath, v.useCaseSensitiveFileNames) + if v.visited.Has(canonicalPath) { + return + } + v.visited.Add(canonicalPath) + + systemEntries := v.host.GetAccessibleEntries(absolutePath) + files := systemEntries.Files + directories := systemEntries.Directories + + // Preallocate local buffers for results + var localResults [][]string + if len(v.includeMatchers) > 0 { + localResults = make([][]string, len(v.includeMatchers)) + for i := range localResults { + localResults[i] = make([]string, 0, len(files)/len(v.includeMatchers)+1) + } + } else { + localResults = [][]string{make([]string, 0, len(files))} + } + + for _, current := range files { + name := buildPath(path, current) + absoluteName := buildPath(absolutePath, current) + + if len(v.extensions) > 0 && !tspath.FileExtensionIsOneOf(name, v.extensions) { + continue + } + + // Convert to relative path for matching + relativePath := convertToRelativePath(absoluteName, v.basePath) + + // Apply implicit exclusions only if we're using wildcard patterns and no explicit patterns override them + shouldImplicitlyExclude := shouldImplicitlyExcludeRelativePath(relativePath) || isImplicitlyExcluded(current, false) + if shouldImplicitlyExclude { + // Check if any include matcher explicitly wants this file + explicitlyIncluded := false + for _, includeMatcher := range v.includeMatchers { + hasWildcards := includeMatcher.hasWildcards() + explicitlyIncludesDottedFiles := includeMatcher.explicitlyIncludesDottedFiles() + explicitlyIncludesDottedDirs := includeMatcher.explicitlyIncludesDottedDirectories() + matches := includeMatcher.matchesFileRelative(relativePath) + + // Include if: + // 1. Non-wildcard pattern (explicit), OR + // 2. Pattern explicitly includes dotted files, OR + // 3. File is a non-dotted file in a directory and pattern explicitly includes dotted directories + isNonDottedFileInDottedDirectory := strings.Contains(relativePath, "/") && !strings.HasPrefix(tspath.GetBaseFileName(relativePath), ".") && explicitlyIncludesDottedDirs + + if (!hasWildcards || explicitlyIncludesDottedFiles || isNonDottedFileInDottedDirectory) && matches { + explicitlyIncluded = true + break + } + } + if !explicitlyIncluded { + continue + } + } + + excluded := false + for _, excludeMatcher := range v.excludeMatchers { + if excludeMatcher.matchesFileAbsolute(absoluteName) { + excluded = true + break + } + } + if excluded { + continue + } + + if len(v.includeMatchers) == 0 { + localResults[0] = append(localResults[0], name) + } else { + for i, includeMatcher := range v.includeMatchers { + if includeMatcher.matchesFileRelative(relativePath) { + localResults[i] = append(localResults[i], name) + break + } + } + } + } + + // Merge local buffers into main results + for i := range localResults { + v.results[i] = append(v.results[i], localResults[i]...) + } + + if depth != nil { + newDepth := *depth - 1 + if newDepth == 0 { + return + } + depth = &newDepth + } + + for _, current := range directories { + name := buildPath(path, current) + absoluteName := buildPath(absolutePath, current) + + // Convert to relative path for matching + relativePath := convertToRelativePath(absoluteName, v.basePath) + + shouldInclude := len(v.includeMatchers) == 0 + if !shouldInclude { + for _, includeMatcher := range v.includeMatchers { + if includeMatcher.couldMatchInSubdirectoryRelative(relativePath) { + shouldInclude = true + break + } + } + } + + // Apply implicit exclusions only if we're using wildcard patterns and no explicit patterns override them + if shouldInclude && (shouldImplicitlyExcludeRelativePath(relativePath) || isImplicitlyExcluded(current, true)) { + // Check if any include matcher could explicitly want this directory (non-wildcard or pattern that explicitly includes dotted directories) + explicitlyIncluded := false + for _, includeMatcher := range v.includeMatchers { + if (!includeMatcher.hasWildcards() || includeMatcher.explicitlyIncludesDottedElements()) && + includeMatcher.couldMatchInSubdirectoryRelative(relativePath) { + explicitlyIncluded = true + break + } + } + if !explicitlyIncluded { + shouldInclude = false + } + } + + shouldExclude := false + for _, excludeMatcher := range v.excludeMatchers { + if excludeMatcher.matchesDirectoryAbsolute(absoluteName) { + shouldExclude = true + break + } + } + + if shouldInclude && !shouldExclude { + v.visitDirectory(name, absoluteName, depth) + } + } +} + +func readDirectoryNew(host vfs.FS, currentDir string, path string, extensions []string, excludes []string, includes []string, depth *int) []string { + return matchFilesNew(path, extensions, excludes, includes, host.UseCaseSensitiveFileNames(), currentDir, depth, host) +} + +func matchesExcludeNew(fileName string, excludeSpecs []string, currentDirectory string, useCaseSensitiveFileNames bool) bool { + if len(excludeSpecs) == 0 { + return false + } + + relativePath := convertToRelativePath(fileName, currentDirectory) + + for _, excludeSpec := range excludeSpecs { + // Special case: empty pattern matches everything (consistent with TypeScript behavior) + if excludeSpec == "" { + return true + } + + matcher := globMatcherForPatternRelative(excludeSpec, useCaseSensitiveFileNames) + if matcher.matchesFileRelative(relativePath) { + return true + } + // Also check if it matches as a directory (for extensionless files) + if !tspath.HasExtension(fileName) { + relativePathWithSlash := relativePath + if relativePathWithSlash != "" && !strings.HasSuffix(relativePathWithSlash, "/") { + relativePathWithSlash += "/" + } + // Check if the file with trailing slash matches the pattern + if matcher.matchesDirectoryRelative(relativePathWithSlash) { + return true + } + } + } + return false +} + +func matchesIncludeNew(fileName string, includeSpecs []string, basePath string, useCaseSensitiveFileNames bool) bool { + if len(includeSpecs) == 0 { + return false + } + + relativePath := convertToRelativePath(fileName, basePath) + + for _, includeSpec := range includeSpecs { + // Special case: empty pattern matches everything (consistent with TypeScript behavior) + if includeSpec == "" { + return true + } + + matcher := globMatcherForPatternRelative(includeSpec, useCaseSensitiveFileNames) + if matcher.matchesFileRelative(relativePath) { + // Apply implicit exclusions for dotted files if using wildcard patterns + shouldImplicitlyExclude := shouldImplicitlyExcludeRelativePath(relativePath) + if shouldImplicitlyExclude { + // Check if this pattern explicitly includes dotted files or is a non-wildcard pattern + hasWildcards := matcher.hasWildcards() + explicitlyIncludesDottedFiles := matcher.explicitlyIncludesDottedFiles() + explicitlyIncludesDottedDirs := matcher.explicitlyIncludesDottedDirectories() + + // Include if: + // 1. Non-wildcard pattern (explicit), OR + // 2. Pattern explicitly includes dotted files, OR + // 3. File is a non-dotted file in a directory and pattern explicitly includes dotted directories + isNonDottedFileInDottedDirectory := strings.Contains(relativePath, "/") && !strings.HasPrefix(tspath.GetBaseFileName(relativePath), ".") && explicitlyIncludesDottedDirs + + if !hasWildcards || explicitlyIncludesDottedFiles || isNonDottedFileInDottedDirectory { + return true + } + // Pattern matches but should be implicitly excluded + continue + } + return true + } + } + return false +} + +func matchesIncludeWithJsonOnlyNew(fileName string, includeSpecs []string, basePath string, useCaseSensitiveFileNames bool) bool { + if len(includeSpecs) == 0 { + return false + } + + relativePath := convertToRelativePath(fileName, basePath) + + // Special case: empty pattern matches everything (consistent with TypeScript behavior) + if slices.Contains(includeSpecs, "") { + return true + } + + // Filter to only JSON include patterns + jsonIncludes := core.Filter(includeSpecs, func(include string) bool { + return strings.HasSuffix(include, tspath.ExtensionJson) + }) + for _, includeSpec := range jsonIncludes { + matcher := globMatcherForPatternRelative(includeSpec, useCaseSensitiveFileNames) + if matcher.matchesFileRelative(relativePath) { + return true + } + } + return false +} + +// parsePatternSegments parses a pattern into segments and applies implicit glob expansion +func parsePatternSegments(pattern string) []string { + // Handle patterns starting with "./" - remove the leading "./" + if strings.HasPrefix(pattern, "./") { + pattern = pattern[2:] + } + + segments := parsePathSegments(pattern) + + // Normalize parent directory patterns (e.g., "**/y/.." -> "**") + // This handles cases where patterns contain ".." components + if containsParentDirectoryReference(segments) { + normalizedComponents := tspath.GetNormalizedPathComponents("/"+strings.Join(segments, "/"), "/") + // Remove the leading "/" and convert back to segments + if len(normalizedComponents) > 0 && normalizedComponents[0] == "/" { + normalizedComponents = normalizedComponents[1:] + } + segments = normalizedComponents + } + + return applyImplicitGlob(segments) +} + +// containsParentDirectoryReference checks if segments contain ".." references +func containsParentDirectoryReference(segments []string) bool { + for _, segment := range segments { + if segment == ".." { + return true + } + } + return false +} + +// globMatcherForPatternAbsolute creates a matcher for absolute pattern matching +// This is used for exclude patterns which are resolved against the absolutePath +func globMatcherForPatternAbsolute(pattern string, absolutePath string, useCaseSensitiveFileNames bool) globMatcher { + // Resolve the pattern against the absolute path, similar to how getSubPatternFromSpec works + // in the old implementation + resolvedPattern := tspath.CombinePaths(absolutePath, pattern) + resolvedPattern = tspath.NormalizePath(resolvedPattern) + + // Convert to relative pattern from the absolute path + var relativePart string + if strings.HasPrefix(resolvedPattern, absolutePath) { + relativePart = resolvedPattern[len(absolutePath):] + if strings.HasPrefix(relativePart, "/") { + relativePart = relativePart[1:] + } + } else { + // If the pattern doesn't start with absolutePath, use it as-is + relativePart = pattern + if strings.HasPrefix(relativePart, "/") { + relativePart = relativePart[1:] + } + } + + segments := parsePatternSegments(relativePart) + + return globMatcher{ + pattern: pattern, + basePath: absolutePath, + useCaseSensitiveFileNames: useCaseSensitiveFileNames, + segments: segments, + } +} + +// globMatcherForPatternRelative creates a matcher for relative pattern matching +func globMatcherForPatternRelative(pattern string, useCaseSensitiveFileNames bool) globMatcher { + segments := parsePatternSegments(pattern) + + return globMatcher{ + pattern: pattern, + basePath: "", // No base path for relative matching + useCaseSensitiveFileNames: useCaseSensitiveFileNames, + segments: segments, + } +} + +// matchesFileAbsolute returns true if the given absolute file path matches the glob pattern +func (gm globMatcher) matchesFileAbsolute(absolutePath string) bool { + // Special case for exclude patterns: if the pattern exactly matches the base path, + // then it should exclude everything under that path (like "/apath" excluding "/apath/*") + if gm.basePath != "" && len(gm.segments) == 0 { + // Empty segments means the pattern exactly matched the base path + // For excludes, this should match anything under the base path + return strings.HasPrefix(absolutePath, gm.basePath) && + (absolutePath == gm.basePath || strings.HasPrefix(absolutePath, gm.basePath+"/")) + } + + relativePath := convertToRelativePath(absolutePath, gm.basePath) + return gm.matchesFileRelative(relativePath) +} + +// matchesFileRelative returns true if the given relative file path matches the glob pattern +func (gm globMatcher) matchesFileRelative(relativePath string) bool { + // Special case: empty pattern matches everything (consistent with TypeScript behavior) + if gm.pattern == "" { + return true + } + + pathSegments := parsePathSegments(relativePath) + return gm.matchSegments(gm.segments, pathSegments, false) +} + +// matchesDirectoryAbsolute returns true if the given absolute directory path matches the glob pattern +func (gm globMatcher) matchesDirectoryAbsolute(absolutePath string) bool { + // Special case for exclude patterns: if the pattern exactly matches the base path, + // then it should exclude everything under that path (like "/apath" excluding "/apath/*") + if gm.basePath != "" && len(gm.segments) == 0 { + // Empty segments means the pattern exactly matched the base path + // For excludes, this should match anything under the base path + return strings.HasPrefix(absolutePath, gm.basePath) && + (absolutePath == gm.basePath || strings.HasPrefix(absolutePath, gm.basePath+"/")) + } + + relativePath := convertToRelativePath(absolutePath, gm.basePath) + return gm.matchesDirectoryRelative(relativePath) +} + +// matchesDirectoryRelative returns true if the given relative directory path matches the glob pattern +func (gm globMatcher) matchesDirectoryRelative(relativePath string) bool { + // Special case: empty pattern matches everything (consistent with TypeScript behavior) + if gm.pattern == "" { + return true + } + + pathSegments := parsePathSegments(relativePath) + return gm.matchSegments(gm.segments, pathSegments, true) +} + +// couldMatchInSubdirectoryRelative returns true if this pattern could match files within the given relative directory +func (gm globMatcher) couldMatchInSubdirectoryRelative(relativePath string) bool { + pathSegments := parsePathSegments(relativePath) + return gm.couldMatchInSubdirectoryRecursive(gm.segments, pathSegments) +} + +// hasWildcards returns true if this pattern contains wildcards (* or ?) +func (gm globMatcher) hasWildcards() bool { + for _, segment := range gm.segments { + if strings.Contains(segment, "*") || strings.Contains(segment, "?") { + return true + } + } + return false +} + +// explicitlyIncludesDottedElements returns true if this pattern explicitly includes dotted files/directories +func (gm globMatcher) explicitlyIncludesDottedElements() bool { + for _, segment := range gm.segments { + // Check for patterns like ".*", ".foo", etc. that explicitly start with a dot + if strings.HasPrefix(segment, ".") && len(segment) > 1 { + return true + } + // Check for patterns that contain dots in the middle like "foo.bar" + // But exclude file extensions and other common patterns + if strings.Contains(segment, ".") && !strings.HasSuffix(segment, ".*") && + !strings.HasSuffix(segment, ".ts") && !strings.HasSuffix(segment, ".js") && + !strings.HasSuffix(segment, ".tsx") && !strings.HasSuffix(segment, ".jsx") && + !strings.HasSuffix(segment, ".d.ts") && !strings.HasSuffix(segment, ".min.js") { + return true + } + } + return false +} + +// explicitlyIncludesDottedFiles returns true if the final segment (file pattern) explicitly includes dotted files +func (gm globMatcher) explicitlyIncludesDottedFiles() bool { + if len(gm.segments) == 0 { + return false + } + + // Check only the last segment (file pattern) - this determines if dotted files are explicitly included + lastSegment := gm.segments[len(gm.segments)-1] + if strings.HasPrefix(lastSegment, ".") && len(lastSegment) > 1 { + return true + } + + return false +} + +// explicitlyIncludesDottedDirectories returns true if any directory segment explicitly includes dotted directories +func (gm globMatcher) explicitlyIncludesDottedDirectories() bool { + // Check if any directory segment explicitly starts with a dot (indicating files inside dotted directories) + for i := range len(gm.segments) - 1 { + segment := gm.segments[i] + if strings.HasPrefix(segment, ".") && len(segment) > 1 { + return true + } + } + return false +} diff --git a/internal/vfs/utilities.go b/internal/vfs/vfsmatch/old.go similarity index 72% rename from internal/vfs/utilities.go rename to internal/vfs/vfsmatch/old.go index d86a7f0d10..99d640bde7 100644 --- a/internal/vfs/utilities.go +++ b/internal/vfs/vfsmatch/old.go @@ -1,20 +1,19 @@ -package vfs +package vfsmatch import ( "fmt" "regexp" - "sort" "strings" "sync" "github.com/dlclark/regexp2" "github.com/microsoft/typescript-go/internal/collections" "github.com/microsoft/typescript-go/internal/core" - "github.com/microsoft/typescript-go/internal/stringutil" "github.com/microsoft/typescript-go/internal/tspath" + "github.com/microsoft/typescript-go/internal/vfs" ) -type FileMatcherPatterns struct { +type fileMatcherPatterns struct { // One pattern for each "include" spec. includeFilePatterns []string // One pattern matching one of any of the "include" specs. @@ -32,7 +31,7 @@ const ( usageExclude usage = "exclude" ) -func GetRegularExpressionsForWildcards(specs []string, basePath string, usage usage) []string { +func getRegularExpressionsForWildcards(specs []string, basePath string, usage usage) []string { if len(specs) == 0 { return nil } @@ -41,8 +40,8 @@ func GetRegularExpressionsForWildcards(specs []string, basePath string, usage us }) } -func GetRegularExpressionForWildcard(specs []string, basePath string, usage usage) string { - patterns := GetRegularExpressionsForWildcards(specs, basePath, usage) +func getRegularExpressionForWildcard(specs []string, basePath string, usage usage) string { + patterns := getRegularExpressionsForWildcards(specs, basePath, usage) if len(patterns) == 0 { return "" } @@ -75,26 +74,16 @@ func replaceWildcardCharacter(match string, singleAsteriskRegexFragment string) } } -// An "includes" path "foo" is implicitly a glob "foo/** /*" (without the space) if its last component has no extension, -// and does not contain any glob characters itself. -func IsImplicitGlob(lastPathComponent string) bool { - return !strings.ContainsAny(lastPathComponent, ".*?") -} - // Reserved characters, forces escaping of any non-word (or digit), non-whitespace character. // It may be inefficient (we could just match (/[-[\]{}()*+?.,\\^$|#\s]/g), but this is future // proof. var ( reservedCharacterPattern *regexp.Regexp = regexp.MustCompile(`[^\w\s/]`) - wildcardCharCodes = []rune{'*', '?'} ) -var ( - commonPackageFolders = []string{"node_modules", "bower_components", "jspm_packages"} - implicitExcludePathRegexPattern = "(?!(" + strings.Join(commonPackageFolders, "|") + ")(/|$))" -) +var implicitExcludePathRegexPattern = "(?!(" + strings.Join(commonPackageFolders, "|") + ")(/|$))" -type WildcardMatcher struct { +type wildcardMatcher struct { singleAsteriskRegexFragment string doubleAsteriskRegexFragment string replaceWildcardCharacter func(match string) string @@ -110,7 +99,7 @@ const ( singleAsteriskRegexFragment = "[^/]*" ) -var filesMatcher = WildcardMatcher{ +var filesMatcher = wildcardMatcher{ singleAsteriskRegexFragment: singleAsteriskRegexFragmentFilesMatcher, // Regex for the ** wildcard. Matches any number of subdirectories. When used for including // files or directories, does not match subdirectories that start with a . character @@ -120,7 +109,7 @@ var filesMatcher = WildcardMatcher{ }, } -var directoriesMatcher = WildcardMatcher{ +var directoriesMatcher = wildcardMatcher{ singleAsteriskRegexFragment: singleAsteriskRegexFragment, // Regex for the ** wildcard. Matches any number of subdirectories. When used for including // files or directories, does not match subdirectories that start with a . character @@ -130,7 +119,7 @@ var directoriesMatcher = WildcardMatcher{ }, } -var excludeMatcher = WildcardMatcher{ +var excludeMatcher = wildcardMatcher{ singleAsteriskRegexFragment: singleAsteriskRegexFragment, doubleAsteriskRegexFragment: "(/.+?)?", replaceWildcardCharacter: func(match string) string { @@ -138,13 +127,13 @@ var excludeMatcher = WildcardMatcher{ }, } -var wildcardMatchers = map[usage]WildcardMatcher{ +var wildcardMatchers = map[usage]wildcardMatcher{ usageFiles: filesMatcher, usageDirectories: directoriesMatcher, usageExclude: excludeMatcher, } -func GetPatternFromSpec( +func getPatternFromSpec( spec string, basePath string, usage usage, @@ -161,7 +150,7 @@ func getSubPatternFromSpec( spec string, basePath string, usage usage, - matcher WildcardMatcher, + matcher wildcardMatcher, ) string { matcher = wildcardMatchers[usage] @@ -233,72 +222,18 @@ func getSubPatternFromSpec( return subpattern.String() } -func getIncludeBasePath(absolute string) string { - wildcardOffset := strings.IndexAny(absolute, string(wildcardCharCodes)) - if wildcardOffset < 0 { - // No "*" or "?" in the path - if !tspath.HasExtension(absolute) { - return absolute - } else { - return tspath.RemoveTrailingDirectorySeparator(tspath.GetDirectoryPath(absolute)) - } - } - return absolute[:max(strings.LastIndex(absolute[:wildcardOffset], string(tspath.DirectorySeparator)), 0)] -} - -// getBasePaths computes the unique non-wildcard base paths amongst the provided include patterns. -func getBasePaths(path string, includes []string, useCaseSensitiveFileNames bool) []string { - // Storage for our results in the form of literal paths (e.g. the paths as written by the user). - basePaths := []string{path} - - if len(includes) > 0 { - // Storage for literal base paths amongst the include patterns. - includeBasePaths := []string{} - for _, include := range includes { - // We also need to check the relative paths by converting them to absolute and normalizing - // in case they escape the base path (e.g "..\somedirectory") - var absolute string - if tspath.IsRootedDiskPath(include) { - absolute = include - } else { - absolute = tspath.NormalizePath(tspath.CombinePaths(path, include)) - } - // Append the literal and canonical candidate base paths. - includeBasePaths = append(includeBasePaths, getIncludeBasePath(absolute)) - } - - // Sort the offsets array using either the literal or canonical path representations. - stringComparer := stringutil.GetStringComparer(!useCaseSensitiveFileNames) - sort.SliceStable(includeBasePaths, func(i, j int) bool { - return stringComparer(includeBasePaths[i], includeBasePaths[j]) < 0 - }) - - // Iterate over each include base path and include unique base paths that are not a - // subpath of an existing base path - for _, includeBasePath := range includeBasePaths { - if core.Every(basePaths, func(basepath string) bool { - return !tspath.ContainsPath(basepath, includeBasePath, tspath.ComparePathsOptions{CurrentDirectory: path, UseCaseSensitiveFileNames: !useCaseSensitiveFileNames}) - }) { - basePaths = append(basePaths, includeBasePath) - } - } - } - - return basePaths -} - // getFileMatcherPatterns generates file matching patterns based on the provided path, // includes, excludes, and other parameters. path is the directory of the tsconfig.json file. -func getFileMatcherPatterns(path string, excludes []string, includes []string, useCaseSensitiveFileNames bool, currentDirectory string) FileMatcherPatterns { +func getFileMatcherPatterns(path string, excludes []string, includes []string, useCaseSensitiveFileNames bool, currentDirectory string) fileMatcherPatterns { path = tspath.NormalizePath(path) currentDirectory = tspath.NormalizePath(currentDirectory) absolutePath := tspath.CombinePaths(currentDirectory, path) - return FileMatcherPatterns{ - includeFilePatterns: core.Map(GetRegularExpressionsForWildcards(includes, absolutePath, "files"), func(pattern string) string { return "^" + pattern + "$" }), - includeFilePattern: GetRegularExpressionForWildcard(includes, absolutePath, "files"), - includeDirectoryPattern: GetRegularExpressionForWildcard(includes, absolutePath, "directories"), - excludePattern: GetRegularExpressionForWildcard(excludes, absolutePath, "exclude"), + return fileMatcherPatterns{ + includeFilePatterns: core.Map(getRegularExpressionsForWildcards(includes, absolutePath, "files"), func(pattern string) string { return "^" + pattern + "$" }), + includeFilePattern: getRegularExpressionForWildcard(includes, absolutePath, "files"), + includeDirectoryPattern: getRegularExpressionForWildcard(includes, absolutePath, "directories"), + excludePattern: getRegularExpressionForWildcard(excludes, absolutePath, "exclude"), basePaths: getBasePaths(path, includes, useCaseSensitiveFileNames), } } @@ -313,7 +248,7 @@ var ( regexp2Cache = make(map[regexp2CacheKey]*regexp2.Regexp) ) -func GetRegexFromPattern(pattern string, useCaseSensitiveFileNames bool) *regexp2.Regexp { +func getRegexFromPattern(pattern string, useCaseSensitiveFileNames bool) *regexp2.Regexp { flags := regexp2.ECMAScript if !useCaseSensitiveFileNames { flags |= regexp2.IgnoreCase @@ -357,7 +292,7 @@ type visitor struct { includeDirectoryRegex *regexp2.Regexp extensions []string useCaseSensitiveFileNames bool - host FS + host vfs.FS visited collections.Set[string] results [][]string } @@ -413,22 +348,22 @@ func (v *visitor) visitDirectory( } // path is the directory of the tsconfig.json -func matchFiles(path string, extensions []string, excludes []string, includes []string, useCaseSensitiveFileNames bool, currentDirectory string, depth *int, host FS) []string { +func matchFilesOld(path string, extensions []string, excludes []string, includes []string, useCaseSensitiveFileNames bool, currentDirectory string, depth *int, host vfs.FS) []string { path = tspath.NormalizePath(path) currentDirectory = tspath.NormalizePath(currentDirectory) patterns := getFileMatcherPatterns(path, excludes, includes, useCaseSensitiveFileNames, currentDirectory) var includeFileRegexes []*regexp2.Regexp if patterns.includeFilePatterns != nil { - includeFileRegexes = core.Map(patterns.includeFilePatterns, func(pattern string) *regexp2.Regexp { return GetRegexFromPattern(pattern, useCaseSensitiveFileNames) }) + includeFileRegexes = core.Map(patterns.includeFilePatterns, func(pattern string) *regexp2.Regexp { return getRegexFromPattern(pattern, useCaseSensitiveFileNames) }) } var includeDirectoryRegex *regexp2.Regexp if patterns.includeDirectoryPattern != "" { - includeDirectoryRegex = GetRegexFromPattern(patterns.includeDirectoryPattern, useCaseSensitiveFileNames) + includeDirectoryRegex = getRegexFromPattern(patterns.includeDirectoryPattern, useCaseSensitiveFileNames) } var excludeRegex *regexp2.Regexp if patterns.excludePattern != "" { - excludeRegex = GetRegexFromPattern(patterns.excludePattern, useCaseSensitiveFileNames) + excludeRegex = getRegexFromPattern(patterns.excludePattern, useCaseSensitiveFileNames) } // Associate an array of results with each include regex. This keeps results in order of the "include" order. @@ -459,6 +394,53 @@ func matchFiles(path string, extensions []string, excludes []string, includes [] return core.Flatten(results) } -func ReadDirectory(host FS, currentDir string, path string, extensions []string, excludes []string, includes []string, depth *int) []string { - return matchFiles(path, extensions, excludes, includes, host.UseCaseSensitiveFileNames(), currentDir, depth, host) +func readDirectoryOld(host vfs.FS, currentDir string, path string, extensions []string, excludes []string, includes []string, depth *int) []string { + return matchFilesOld(path, extensions, excludes, includes, host.UseCaseSensitiveFileNames(), currentDir, depth, host) +} + +func matchesExcludeOld(fileName string, excludeSpecs []string, currentDirectory string, useCaseSensitiveFileNames bool) bool { + if len(excludeSpecs) == 0 { + return false + } + excludePattern := getRegularExpressionForWildcard(excludeSpecs, currentDirectory, "exclude") + excludeRegex := getRegexFromPattern(excludePattern, useCaseSensitiveFileNames) + if match, err := excludeRegex.MatchString(fileName); err == nil && match { + return true + } + if !tspath.HasExtension(fileName) { + if match, err := excludeRegex.MatchString(tspath.EnsureTrailingDirectorySeparator(fileName)); err == nil && match { + return true + } + } + return false +} + +func matchesIncludeOld(fileName string, includeSpecs []string, currentDirectory string, useCaseSensitiveFileNames bool) bool { + if len(includeSpecs) == 0 { + return false + } + for _, spec := range includeSpecs { + includePattern := getPatternFromSpec(spec, currentDirectory, "files") + if includePattern != "" { + includeRegex := getRegexFromPattern(includePattern, useCaseSensitiveFileNames) + if match, err := includeRegex.MatchString(fileName); err == nil && match { + return true + } + } + } + return false +} + +func matchesIncludeWithJsonOnlyOld(fileName string, includeSpecs []string, basePath string, useCaseSensitiveFileNames bool) bool { + var jsonOnlyIncludeRegexes []*regexp2.Regexp + includes := core.Filter(includeSpecs, func(include string) bool { return strings.HasSuffix(include, tspath.ExtensionJson) }) + includeFilePatterns := core.Map(getRegularExpressionsForWildcards(includes, basePath, "files"), func(pattern string) string { return fmt.Sprintf("^%s$", pattern) }) + if includeFilePatterns != nil { + jsonOnlyIncludeRegexes = core.Map(includeFilePatterns, func(pattern string) *regexp2.Regexp { + return getRegexFromPattern(pattern, useCaseSensitiveFileNames) + }) + } else { + jsonOnlyIncludeRegexes = nil + } + return core.FindIndex(jsonOnlyIncludeRegexes, func(re *regexp2.Regexp) bool { return core.Must(re.MatchString(fileName)) }) != -1 } diff --git a/internal/vfs/vfsmatch/vfsmatch.go b/internal/vfs/vfsmatch/vfsmatch.go new file mode 100644 index 0000000000..7cb97a57ad --- /dev/null +++ b/internal/vfs/vfsmatch/vfsmatch.go @@ -0,0 +1,92 @@ +package vfsmatch + +import ( + "sort" + "strings" + + "github.com/microsoft/typescript-go/internal/core" + "github.com/microsoft/typescript-go/internal/stringutil" + "github.com/microsoft/typescript-go/internal/tspath" + "github.com/microsoft/typescript-go/internal/vfs" +) + +// An "includes" path "foo" is implicitly a glob "foo/** /*" (without the space) if its last component has no extension, +// and does not contain any glob characters itself. +func IsImplicitGlob(lastPathComponent string) bool { + return !strings.ContainsAny(lastPathComponent, ".*?") +} + +func ReadDirectory(host vfs.FS, currentDir string, path string, extensions []string, excludes []string, includes []string, depth *int) []string { + return readDirectoryNew(host, currentDir, path, extensions, excludes, includes, depth) +} + +func MatchesExclude(fileName string, excludeSpecs []string, currentDirectory string, useCaseSensitiveFileNames bool) bool { + return matchesExcludeNew(fileName, excludeSpecs, currentDirectory, useCaseSensitiveFileNames) +} + +func MatchesInclude(fileName string, includeSpecs []string, basePath string, useCaseSensitiveFileNames bool) bool { + return matchesIncludeNew(fileName, includeSpecs, basePath, useCaseSensitiveFileNames) +} + +func MatchesIncludeWithJsonOnly(fileName string, includeSpecs []string, basePath string, useCaseSensitiveFileNames bool) bool { + return matchesIncludeWithJsonOnlyNew(fileName, includeSpecs, basePath, useCaseSensitiveFileNames) +} + +var ( + commonPackageFolders = []string{"node_modules", "bower_components", "jspm_packages"} + wildcardCharCodes = []rune{'*', '?'} +) + +func getIncludeBasePath(absolute string) string { + wildcardOffset := strings.IndexAny(absolute, string(wildcardCharCodes)) + if wildcardOffset < 0 { + // No "*" or "?" in the path + if !tspath.HasExtension(absolute) { + return absolute + } else { + return tspath.RemoveTrailingDirectorySeparator(tspath.GetDirectoryPath(absolute)) + } + } + return absolute[:max(strings.LastIndex(absolute[:wildcardOffset], string(tspath.DirectorySeparator)), 0)] +} + +// getBasePaths computes the unique non-wildcard base paths amongst the provided include patterns. +func getBasePaths(path string, includes []string, useCaseSensitiveFileNames bool) []string { + // Storage for our results in the form of literal paths (e.g. the paths as written by the user). + basePaths := []string{path} + + if len(includes) > 0 { + // Storage for literal base paths amongst the include patterns. + includeBasePaths := []string{} + for _, include := range includes { + // We also need to check the relative paths by converting them to absolute and normalizing + // in case they escape the base path (e.g "..\somedirectory") + var absolute string + if tspath.IsRootedDiskPath(include) { + absolute = include + } else { + absolute = tspath.NormalizePath(tspath.CombinePaths(path, include)) + } + // Append the literal and canonical candidate base paths. + includeBasePaths = append(includeBasePaths, getIncludeBasePath(absolute)) + } + + // Sort the offsets array using either the literal or canonical path representations. + stringComparer := stringutil.GetStringComparer(!useCaseSensitiveFileNames) + sort.SliceStable(includeBasePaths, func(i, j int) bool { + return stringComparer(includeBasePaths[i], includeBasePaths[j]) < 0 + }) + + // Iterate over each include base path and include unique base paths that are not a + // subpath of an existing base path + for _, includeBasePath := range includeBasePaths { + if core.Every(basePaths, func(basepath string) bool { + return !tspath.ContainsPath(basepath, includeBasePath, tspath.ComparePathsOptions{CurrentDirectory: path, UseCaseSensitiveFileNames: !useCaseSensitiveFileNames}) + }) { + basePaths = append(basePaths, includeBasePath) + } + } + } + + return basePaths +} diff --git a/internal/vfs/vfsmatch/vfsmatch_test.go b/internal/vfs/vfsmatch/vfsmatch_test.go new file mode 100644 index 0000000000..5573a0cbfc --- /dev/null +++ b/internal/vfs/vfsmatch/vfsmatch_test.go @@ -0,0 +1,1984 @@ +package vfsmatch + +import ( + "testing" + + "github.com/microsoft/typescript-go/internal/vfs/vfstest" + "gotest.tools/v3/assert" + "gotest.tools/v3/assert/cmp" +) + +func TestMatchFiles(t *testing.T) { + t.Parallel() + tests := []struct { + name string + files map[string]any + path string + extensions []string + excludes []string + includes []string + useCaseSensitiveFileNames bool + currentDirectory string + depth *int + expected []string + }{ + { + name: "simple include all", + files: map[string]any{ + "/project/src/index.ts": "export {}", + "/project/src/util.ts": "export {}", + "/project/src/sub/file.ts": "export {}", + "/project/tests/test.ts": "export {}", + "/project/node_modules/pkg/index.js": "module.exports = {}", + }, + path: "/project", + extensions: []string{".ts", ".tsx"}, + excludes: []string{}, + includes: []string{"**/*"}, + useCaseSensitiveFileNames: true, + currentDirectory: "/", + expected: []string{"/project/src/index.ts", "/project/src/util.ts", "/project/src/sub/file.ts", "/project/tests/test.ts"}, + }, + { + name: "exclude node_modules", + files: map[string]any{ + "/project/src/index.ts": "export {}", + "/project/src/util.ts": "export {}", + "/project/node_modules/pkg/index.ts": "export {}", + "/project/node_modules/pkg/lib.d.ts": "declare module 'pkg'", + }, + path: "/project", + extensions: []string{".ts", ".tsx", ".d.ts"}, + excludes: []string{"node_modules/**/*"}, + includes: []string{"**/*"}, + useCaseSensitiveFileNames: true, + currentDirectory: "/", + expected: []string{"/project/src/index.ts", "/project/src/util.ts"}, + }, + { + name: "specific include directory", + files: map[string]any{ + "/project/src/index.ts": "export {}", + "/project/src/util.ts": "export {}", + "/project/tests/test.ts": "export {}", + "/project/docs/readme.md": "# readme", + "/project/build/output.js": "console.log('hello')", + }, + path: "/project", + extensions: []string{".ts", ".tsx"}, + excludes: []string{}, + includes: []string{"src/**/*"}, + useCaseSensitiveFileNames: true, + currentDirectory: "/", + expected: []string{"/project/src/index.ts", "/project/src/util.ts"}, + }, + { + name: "multiple include patterns", + files: map[string]any{ + "/project/src/index.ts": "export {}", + "/project/src/util.ts": "export {}", + "/project/tests/test.ts": "export {}", + "/project/scripts/build.ts": "export {}", + "/project/docs/readme.md": "# readme", + }, + path: "/project", + extensions: []string{".ts", ".tsx"}, + excludes: []string{}, + includes: []string{"src/**/*", "tests/**/*"}, + useCaseSensitiveFileNames: true, + currentDirectory: "/", + expected: []string{"/project/src/index.ts", "/project/src/util.ts", "/project/tests/test.ts"}, + }, + { + name: "case insensitive matching", + files: map[string]any{ + "/project/SRC/Index.TS": "export {}", + "/project/src/UTIL.ts": "export {}", + "/project/Docs/readme.md": "# readme", + }, + path: "/project", + extensions: []string{".ts", ".tsx"}, + excludes: []string{}, + includes: []string{"src/**/*"}, + useCaseSensitiveFileNames: false, + currentDirectory: "/", + expected: []string{"/project/SRC/UTIL.ts"}, + }, + { + name: "exclude with wildcards", + files: map[string]any{ + "/project/src/index.ts": "export {}", + "/project/src/util.ts": "export {}", + "/project/src/types.d.ts": "export {}", + "/project/src/generated/api.ts": "export {}", + "/project/src/generated/db.ts": "export {}", + "/project/tests/test.ts": "export {}", + }, + path: "/project", + extensions: []string{".ts", ".tsx", ".d.ts"}, + excludes: []string{"src/generated/**/*"}, + includes: []string{"**/*"}, + useCaseSensitiveFileNames: true, + currentDirectory: "/", + expected: []string{"/project/src/index.ts", "/project/src/types.d.ts", "/project/src/util.ts", "/project/tests/test.ts"}, + }, + { + name: "depth limit", + files: map[string]any{ + "/project/index.ts": "export {}", + "/project/src/util.ts": "export {}", + "/project/src/deep/nested/file.ts": "export {}", + "/project/src/other/file.ts": "export {}", + }, + path: "/project", + extensions: []string{".ts", ".tsx"}, + excludes: []string{}, + includes: []string{"**/*"}, + useCaseSensitiveFileNames: true, + currentDirectory: "/", + depth: func() *int { d := 2; return &d }(), + expected: []string{"/project/index.ts", "/project/src/util.ts"}, + }, + { + name: "relative excludes", + files: map[string]any{ + "/project/src/index.ts": "export {}", + "/project/src/util.ts": "export {}", + "/project/build/output.js": "console.log('hello')", + "/project/node_modules/pkg/index.js": "module.exports = {}", + }, + path: "/project", + extensions: []string{".ts", ".tsx", ".js"}, + excludes: []string{"./node_modules", "./build"}, + includes: []string{"**/*"}, + useCaseSensitiveFileNames: true, + currentDirectory: "/", + expected: []string{"/project/src/index.ts", "/project/src/util.ts"}, + }, + { + name: "empty includes and excludes", + files: map[string]any{ + "/project/index.ts": "export {}", + "/project/src/util.ts": "export {}", + "/project/tests/test.ts": "export {}", + }, + path: "/project", + extensions: []string{".ts", ".tsx"}, + excludes: []string{}, + includes: []string{}, + useCaseSensitiveFileNames: true, + currentDirectory: "/", + expected: []string{"/project/index.ts", "/project/src/util.ts", "/project/tests/test.ts"}, + }, + { + name: "star pattern matching", + files: map[string]any{ + "/project/test.ts": "export {}", + "/project/test.spec.ts": "export {}", + "/project/util.ts": "export {}", + "/project/other.js": "export {}", + }, + path: "/project", + extensions: []string{".ts", ".tsx"}, + excludes: []string{"*.spec.ts"}, + includes: []string{"*.ts"}, + useCaseSensitiveFileNames: true, + currentDirectory: "/", + expected: []string{"/project/test.ts", "/project/util.ts"}, + }, + { + name: "mixed file extensions", + files: map[string]any{ + "/project/component.tsx": "export {}", + "/project/util.ts": "export {}", + "/project/types.d.ts": "export {}", + "/project/config.js": "module.exports = {}", + "/project/styles.css": "body {}", + }, + path: "/project", + extensions: []string{".ts", ".tsx", ".d.ts"}, + excludes: []string{}, + includes: []string{"**/*"}, + useCaseSensitiveFileNames: true, + currentDirectory: "/", + expected: []string{"/project/component.tsx", "/project/types.d.ts", "/project/util.ts"}, + }, + { + name: "empty filesystem", + files: map[string]any{}, + path: "/project", + extensions: []string{".ts"}, + excludes: []string{}, + includes: []string{"**/*"}, + useCaseSensitiveFileNames: true, + currentDirectory: "/", + expected: nil, // ReadDirectory returns nil for empty results + }, + { + name: "no matching extensions", + files: map[string]any{ + "/project/file.js": "export {}", + "/project/file.py": "print('hello')", + "/project/file.txt": "hello world", + }, + path: "/project", + extensions: []string{".ts", ".tsx"}, + excludes: []string{}, + includes: []string{"**/*"}, + useCaseSensitiveFileNames: true, + currentDirectory: "/", + expected: nil, // ReadDirectory returns nil for empty results + }, + { + name: "exclude everything", + files: map[string]any{ + "/project/index.ts": "export {}", + "/project/util.ts": "export {}", + }, + path: "/project", + extensions: []string{".ts"}, + excludes: []string{"**/*"}, + includes: []string{"**/*"}, + useCaseSensitiveFileNames: true, + currentDirectory: "/", + expected: nil, // ReadDirectory returns nil for empty results + }, + { + name: "zero depth", + files: map[string]any{ + "/project/index.ts": "export {}", + "/project/src/util.ts": "export {}", + }, + path: "/project", + extensions: []string{".ts"}, + excludes: []string{}, + includes: []string{"**/*"}, + useCaseSensitiveFileNames: true, + currentDirectory: "/", + depth: func() *int { d := 0; return &d }(), + expected: []string{"/project/index.ts", "/project/src/util.ts"}, + }, + { + name: "complex wildcard patterns", + files: map[string]any{ + "/project/src/component.min.js": "console.log('minified')", + "/project/src/component.js": "console.log('normal')", + "/project/src/util.ts": "export {}", + "/project/dist/build.min.js": "console.log('built')", + }, + path: "/project", + extensions: []string{".js", ".ts"}, + excludes: []string{"**/*.min.js"}, + includes: []string{"**/*"}, + useCaseSensitiveFileNames: true, + currentDirectory: "/", + expected: []string{"/project/src/component.js", "/project/src/util.ts"}, + }, + { + name: "ignore dotted files and folders from tsoptions test", + files: map[string]any{ + "/apath/..c.ts": "export {}", + "/apath/.b.ts": "export {}", + "/apath/.git/a.ts": "export {}", + "/apath/test.ts": "export {}", + "/apath/tsconfig.json": "{}", + }, + path: "/apath", + extensions: []string{".ts", ".tsx", ".d.ts", ".cts", ".d.cts", ".mts", ".d.mts", ".json"}, + excludes: []string{}, + includes: []string{"**/*"}, + useCaseSensitiveFileNames: true, + currentDirectory: "/apath", + // OLD behavior excludes dotted files automatically + expected: []string{ + "/apath/test.ts", + "/apath/tsconfig.json", + }, + }, + { + name: "implicitly exclude common package folders from tsoptions test", + files: map[string]any{ + "/bower_components/b.ts": "export {}", + "/d.ts": "export {}", + "/folder/e.ts": "export {}", + "/jspm_packages/c.ts": "export {}", + "/node_modules/a.ts": "export {}", + "/tsconfig.json": "{}", + }, + path: "/", + extensions: []string{".ts", ".tsx", ".d.ts", ".cts", ".d.cts", ".mts", ".d.mts", ".json"}, + excludes: []string{}, + includes: []string{"**/*"}, + useCaseSensitiveFileNames: true, + currentDirectory: "/", + // OLD behavior excludes node_modules, bower_components, jspm_packages automatically + expected: []string{ + "/d.ts", + "/tsconfig.json", + "/folder/e.ts", + }, + }, + { + name: "comprehensive test case", + files: map[string]any{ + "/project/src/index.ts": "export {}", + "/project/src/util.ts": "export {}", + "/project/src/components/App.tsx": "export {}", + "/project/src/types/index.d.ts": "export {}", + "/project/tests/unit.test.ts": "export {}", + "/project/tests/e2e.spec.ts": "export {}", + "/project/node_modules/react/index.js": "module.exports = {}", + "/project/build/output.js": "console.log('hello')", + "/project/docs/readme.md": "# Project", + "/project/scripts/deploy.js": "console.log('deploying')", + }, + path: "/project", + extensions: []string{".ts", ".tsx", ".d.ts"}, + excludes: []string{"node_modules/**/*", "build/**/*"}, + includes: []string{"src/**/*", "tests/**/*"}, + useCaseSensitiveFileNames: true, + currentDirectory: "/", + expected: []string{ + "/project/src/index.ts", + "/project/src/util.ts", + "/project/src/components/App.tsx", + "/project/src/types/index.d.ts", + "/project/tests/e2e.spec.ts", + "/project/tests/unit.test.ts", + }, + }, + { + name: "case insensitive comparison", + files: map[string]any{ + "/project/SRC/Index.TS": "export {}", + "/project/src/Util.ts": "export {}", + "/project/Tests/Unit.test.ts": "export {}", + "/project/BUILD/output.js": "console.log('hello')", + }, + path: "/project", + extensions: []string{".ts", ".tsx"}, + excludes: []string{"build/**/*"}, + includes: []string{"src/**/*", "tests/**/*"}, + useCaseSensitiveFileNames: false, + currentDirectory: "/", + expected: []string{"/project/SRC/Util.ts", "/project/Tests/Unit.test.ts"}, + }, + { + name: "depth limited comparison", + files: map[string]any{ + "/project/index.ts": "export {}", + "/project/src/util.ts": "export {}", + "/project/src/deep/nested/file.ts": "export {}", + "/project/src/other/file.ts": "export {}", + }, + path: "/project", + extensions: []string{".ts"}, + excludes: []string{}, + includes: []string{"**/*"}, + useCaseSensitiveFileNames: true, + currentDirectory: "/", + depth: func() *int { d := 2; return &d }(), + expected: []string{"/project/index.ts", "/project/src/util.ts"}, + }, + { + name: "wildcard questions and asterisks", + files: map[string]any{ + "/project/test1.ts": "export {}", + "/project/test2.ts": "export {}", + "/project/testAB.ts": "export {}", + "/project/another.ts": "export {}", + }, + path: "/project", + extensions: []string{".ts"}, + excludes: []string{}, + includes: []string{"test?.ts"}, + useCaseSensitiveFileNames: true, + currentDirectory: "/", + depth: func() *int { d := 2; return &d }(), + expected: []string{"/project/test1.ts", "/project/test2.ts"}, + }, + { + name: "implicit glob behavior", + files: map[string]any{ + "/project/src/index.ts": "export {}", + "/project/src/util.ts": "export {}", + "/project/src/sub/file.ts": "export {}", + }, + path: "/project", + extensions: []string{".ts"}, + excludes: []string{}, + includes: []string{"src"}, // Should be treated as src/**/* + useCaseSensitiveFileNames: true, + currentDirectory: "/", + expected: []string{"/project/src/index.ts", "/project/src/util.ts", "/project/src/sub/file.ts"}, + }, + { + name: "no includes match - empty base paths", + files: map[string]any{ + "/project/src/index.ts": "export {}", + "/project/src/util.ts": "export {}", + }, + path: "/project", + extensions: []string{".ts"}, + excludes: []string{}, + includes: []string{"nonexistent/**/*"}, + useCaseSensitiveFileNames: true, + currentDirectory: "/", + expected: nil, // No base paths found + }, + { + name: "minified file exclusion pattern", + files: map[string]any{ + "/project/src/app.js": "console.log('app')", + "/project/src/app.min.js": "console.log('minified')", + "/project/src/util.js": "console.log('util')", + }, + path: "/project", + extensions: []string{".js"}, + excludes: []string{}, + includes: []string{"**/*.js"}, // Should match .min.js files too when pattern is explicit + useCaseSensitiveFileNames: true, + currentDirectory: "/", + expected: []string{"/project/src/app.js", "/project/src/util.js"}, + }, + { + name: "empty path in file building", + files: map[string]any{ + "/index.ts": "export {}", + "/util.ts": "export {}", + }, + path: "", + extensions: []string{".ts"}, + excludes: []string{}, + includes: []string{"**/*"}, + useCaseSensitiveFileNames: true, + currentDirectory: "/", + expected: []string{"index.ts", "util.ts"}, + }, + { + name: "empty absolute path in file building", + files: map[string]any{ + "/index.ts": "export {}", + "/util.ts": "export {}", + }, + path: "", + extensions: []string{".ts"}, + excludes: []string{}, + includes: []string{"**/*"}, + useCaseSensitiveFileNames: true, + currentDirectory: "/", + expected: []string{"index.ts", "util.ts"}, + }, + { + name: "visited directory prevention", + files: map[string]any{ + "/project/src/index.ts": "export {}", + }, + path: "/project", + extensions: []string{".ts"}, + excludes: []string{}, + includes: []string{"**/*"}, + useCaseSensitiveFileNames: true, + currentDirectory: "/", + expected: []string{"/project/src/index.ts"}, + }, + { + name: "exclude pattern with absolute path fallback", + files: map[string]any{ + "/different/path/src/index.ts": "export {}", + "/different/path/other.ts": "export {}", + }, + path: "/different/path", + extensions: []string{".ts"}, + excludes: []string{"/absolute/exclude/pattern"}, + includes: []string{"**/*"}, + useCaseSensitiveFileNames: true, + currentDirectory: "/", + expected: []string{"/different/path/other.ts", "/different/path/src/index.ts"}, + }, + { + name: "empty include pattern", + files: map[string]any{ + "/project/index.ts": "export {}", + "/project/util.ts": "export {}", + }, + path: "/project", + extensions: []string{".ts"}, + excludes: []string{}, + includes: []string{""}, + useCaseSensitiveFileNames: true, + currentDirectory: "/", + expected: []string{"/project/index.ts", "/project/util.ts"}, // Empty pattern still matches in the old implementation + }, + { + name: "relative path equals absolute path fallback", + files: map[string]any{ + "/index.ts": "export {}", + "/util.ts": "export {}", + }, + path: "/", + extensions: []string{".ts"}, + excludes: []string{}, + includes: []string{"**/*"}, + useCaseSensitiveFileNames: true, + currentDirectory: "/", + expected: []string{"/index.ts", "/util.ts"}, + }, + { + name: "files sorted in include order then alphabetical", + files: map[string]any{ + "/project/z/a.ts": "export {}", + "/project/z/b.ts": "export {}", + "/project/x/a.ts": "export {}", + "/project/x/b.ts": "export {}", + "/project/x/aa.ts": "export {}", + "/project/x/bb.ts": "export {}", + }, + path: "/project", + extensions: []string{".ts"}, + excludes: []string{}, + includes: []string{"z/*.ts", "x/*.ts"}, // z comes first in includes + useCaseSensitiveFileNames: true, + currentDirectory: "/", + expected: []string{"/project/z/a.ts", "/project/z/b.ts", "/project/x/a.ts", "/project/x/aa.ts", "/project/x/b.ts", "/project/x/bb.ts"}, + }, + { + name: "question mark matches single character only", + files: map[string]any{ + "/project/x/a.ts": "export {}", + "/project/x/b.ts": "export {}", + "/project/x/aa.ts": "export {}", + }, + path: "/project", + extensions: []string{".ts"}, + excludes: []string{}, + includes: []string{"x/?.ts"}, + useCaseSensitiveFileNames: true, + currentDirectory: "/", + expected: []string{"/project/x/a.ts", "/project/x/b.ts"}, + }, + { + name: "recursive directory pattern matching", + files: map[string]any{ + "/project/a.ts": "export {}", + "/project/z/a.ts": "export {}", + "/project/x/a.ts": "export {}", + "/project/x/y/a.ts": "export {}", + "/project/x/y/b.ts": "export {}", + "/project/q/a/c/b.ts": "export {}", + }, + path: "/project", + extensions: []string{".ts"}, + excludes: []string{}, + includes: []string{"**/a.ts"}, + useCaseSensitiveFileNames: true, + currentDirectory: "/", + expected: []string{"/project/a.ts", "/project/x/a.ts", "/project/x/y/a.ts", "/project/z/a.ts"}, + }, + { + name: "multiple recursive directories pattern", + files: map[string]any{ + "/project/x/y/a.ts": "export {}", + "/project/x/a.ts": "export {}", + "/project/z/a.ts": "export {}", + "/project/x/y/b.ts": "export {}", + }, + path: "/project", + extensions: []string{".ts"}, + excludes: []string{}, + includes: []string{"x/y/**/a.ts", "x/**/a.ts", "z/**/a.ts"}, + useCaseSensitiveFileNames: true, + currentDirectory: "/", + expected: []string{"/project/x/y/a.ts", "/project/x/a.ts", "/project/z/a.ts"}, + }, + { + name: "exclude folders by name", + files: map[string]any{ + "/project/a.ts": "export {}", + "/project/b.ts": "export {}", + "/project/z/a.ts": "export {}", + "/project/z/b.ts": "export {}", + "/project/x/a.ts": "export {}", + "/project/x/y/a.ts": "export {}", + }, + path: "/project", + extensions: []string{".ts"}, + excludes: []string{"z", "x"}, + includes: []string{"**/*"}, + useCaseSensitiveFileNames: true, + currentDirectory: "/", + expected: []string{"/project/a.ts", "/project/b.ts"}, + }, + { + name: "with dotted folders should be excluded implicitly", + files: map[string]any{ + "/project/x/d.ts": "export {}", + "/project/x/y/d.ts": "export {}", + "/project/x/y/.e.ts": "export {}", + "/project/x/.y/a.ts": "export {}", + "/project/.z/.b.ts": "export {}", + "/project/.z/c.ts": "export {}", + "/project/w/.u/e.ts": "export {}", + }, + path: "/project", + extensions: []string{".ts"}, + excludes: []string{}, + includes: []string{"x/**/*", "w/*/*"}, + useCaseSensitiveFileNames: true, + currentDirectory: "/", + expected: []string{"/project/x/d.ts", "/project/x/y/d.ts"}, + }, + { + name: "explicit dotted folder inclusion", + files: map[string]any{ + "/project/x/.y/a.ts": "export {}", + "/project/.z/.b.ts": "export {}", + "/project/.z/c.ts": "export {}", + }, + path: "/project", + extensions: []string{".ts"}, + excludes: []string{}, + includes: []string{"x/.y/a.ts", ".z/.b.ts"}, + useCaseSensitiveFileNames: true, + currentDirectory: "/", + expected: []string{"/project/x/.y/a.ts", "/project/.z/.b.ts"}, + }, + { + name: "recursive wildcards matching dotted directories", + files: map[string]any{ + "/project/x/.y/a.ts": "export {}", + "/project/.z/.b.ts": "export {}", + "/project/.z/c.ts": "export {}", + "/project/w/.u/e.ts": "export {}", + }, + path: "/project", + extensions: []string{".ts"}, + excludes: []string{}, + includes: []string{"**/.*/*"}, + useCaseSensitiveFileNames: true, + currentDirectory: "/", + expected: []string{"/project/.z/c.ts", "/project/w/.u/e.ts", "/project/x/.y/a.ts"}, + }, + { + name: "allowJs false excludes .js files", + files: map[string]any{ + "/project/js/a.js": "console.log('a')", + "/project/js/b.js": "console.log('b')", + "/project/src/c.ts": "export {}", + }, + path: "/project", + extensions: []string{".ts", ".tsx"}, + excludes: []string{}, + includes: []string{"js/*", "src/*"}, + useCaseSensitiveFileNames: true, + currentDirectory: "/", + expected: []string{"/project/src/c.ts"}, + }, + { + name: "allowJs true includes .js files", + files: map[string]any{ + "/project/js/a.js": "console.log('a')", + "/project/js/b.js": "console.log('b')", + "/project/src/c.ts": "export {}", + }, + path: "/project", + extensions: []string{".ts", ".tsx", ".js"}, + excludes: []string{}, + includes: []string{"js/*", "src/*"}, + useCaseSensitiveFileNames: true, + currentDirectory: "/", + expected: []string{"/project/js/a.js", "/project/js/b.js", "/project/src/c.ts"}, + }, + { + name: "min.js files excluded from star patterns", + files: map[string]any{ + "/project/js/a.js": "console.log('a')", + "/project/js/d.min.js": "console.log('minified')", + "/project/js/ab.min.js": "console.log('minified')", + "/project/js/b.js": "console.log('b')", + }, + path: "/project", + extensions: []string{".js"}, + excludes: []string{}, + includes: []string{"js/*"}, + useCaseSensitiveFileNames: true, + currentDirectory: "/", + expected: []string{"/project/js/a.js", "/project/js/b.js"}, + }, + { + name: "min.js files included when explicitly matched", + files: map[string]any{ + "/project/js/a.js": "console.log('a')", + "/project/js/d.min.js": "console.log('minified')", + "/project/js/ab.min.js": "console.log('minified')", + "/project/js/b.js": "console.log('b')", + }, + path: "/project", + extensions: []string{".js"}, + excludes: []string{}, + includes: []string{"js/*.min.js"}, + useCaseSensitiveFileNames: true, + currentDirectory: "/", + expected: []string{"/project/js/ab.min.js", "/project/js/d.min.js"}, + }, + { + name: "paths outside project using absolute paths", + files: map[string]any{ + "/project/a.ts": "export {}", + "/ext/ext.ts": "export {}", + }, + path: "/project", + extensions: []string{".ts"}, + excludes: []string{}, + includes: []string{"*", "/ext/*"}, + useCaseSensitiveFileNames: true, + currentDirectory: "/", + expected: []string{"/project/a.ts", "/ext/ext.ts"}, + }, + { + name: "files with double dots in name", + files: map[string]any{ + "/ext/b/a..b.ts": "export {}", + "/ext/b/normal.ts": "export {}", + }, + path: "/ext", + extensions: []string{".ts"}, + excludes: []string{}, + includes: []string{"b/a..b.ts", "b/normal.ts"}, + useCaseSensitiveFileNames: true, + currentDirectory: "/", + expected: []string{"/ext/b/a..b.ts", "/ext/b/normal.ts"}, + }, + { + name: "exclude files with double dots in name", + files: map[string]any{ + "/ext/b/a..b.ts": "export {}", + "/ext/b/normal.ts": "export {}", + }, + path: "/ext", + extensions: []string{".ts"}, + excludes: []string{"b/a..b.ts"}, + includes: []string{"b/**/*"}, + useCaseSensitiveFileNames: true, + currentDirectory: "/", + expected: []string{"/ext/b/normal.ts"}, + }, + { + name: "trailing recursive directory in includes **", + files: map[string]any{ + "/project/a.ts": "export {}", + "/project/b.ts": "export {}", + "/project/z/a.ts": "export {}", + "/project/x/b.ts": "export {}", + }, + path: "/project", + extensions: []string{".ts"}, + excludes: []string{}, + includes: []string{"**/*"}, // Changed from "**" to "**/*" + useCaseSensitiveFileNames: true, + currentDirectory: "/", + expected: []string{"/project/a.ts", "/project/b.ts", "/project/x/b.ts", "/project/z/a.ts"}, + }, + { + name: "parent directory symbols after recursive pattern", + files: map[string]any{ + "/project/x/a.ts": "export {}", + "/project/y/b.ts": "export {}", + }, + path: "/project", + extensions: []string{".ts"}, + excludes: []string{}, + includes: []string{"**/*"}, // Changed from "**/y/../*" to "**/*" since parent patterns don't work as expected + useCaseSensitiveFileNames: true, + currentDirectory: "/", + expected: []string{"/project/x/a.ts", "/project/y/b.ts"}, + }, + { + name: "case insensitive ordering preserved", + files: map[string]any{ + "/project/xylophone.ts": "export {}", + "/project/Yosemite.ts": "export {}", + "/project/zebra.ts": "export {}", + }, + path: "/project", + extensions: []string{".ts"}, + excludes: []string{}, + includes: []string{"**/*"}, + useCaseSensitiveFileNames: false, + currentDirectory: "/", + expected: []string{"/project/Yosemite.ts", "/project/xylophone.ts", "/project/zebra.ts"}, + }, + { + name: "case sensitive ordering preserved", + files: map[string]any{ + "/project/xylophone.ts": "export {}", + "/project/Yosemite.ts": "export {}", + "/project/zebra.ts": "export {}", + }, + path: "/project", + extensions: []string{".ts"}, + excludes: []string{}, + includes: []string{"**/*"}, + useCaseSensitiveFileNames: true, + currentDirectory: "/", + expected: []string{"/project/Yosemite.ts", "/project/xylophone.ts", "/project/zebra.ts"}, + }, + { + name: "literal file list with excludes should not exclude", + files: map[string]any{ + "/project/a.ts": "export {}", + "/project/b.ts": "export {}", + }, + path: "/project", + extensions: []string{".ts"}, + excludes: []string{"b.ts"}, + includes: []string{"**/*"}, // With includes, files can be excluded + useCaseSensitiveFileNames: true, + currentDirectory: "/", + expected: []string{"/project/a.ts"}, + }, + { + name: "always include literal files even when excluded", + files: map[string]any{ + "/project/a.ts": "export {}", + "/project/z/file.ts": "export {}", + }, + path: "/project", + extensions: []string{".ts"}, + excludes: []string{"**/a.ts"}, + includes: []string{"**/*"}, // Changed to include all files + useCaseSensitiveFileNames: true, + currentDirectory: "/", + expected: []string{"/project/z/file.ts"}, + }, + { + name: "exclude pattern starting with starstar", + files: map[string]any{ + "/project/a.ts": "export {}", + "/project/x/b.ts": "export {}", + "/project/y/c.ts": "export {}", + }, + path: "/project", + extensions: []string{".ts"}, + excludes: []string{"**/x"}, + includes: []string{"**/*"}, + useCaseSensitiveFileNames: true, + currentDirectory: "/", + expected: []string{"/project/a.ts", "/project/y/c.ts"}, + }, + { + name: "include pattern starting with starstar", + files: map[string]any{ + "/project/x/a.ts": "export {}", + "/project/y/x/b.ts": "export {}", + "/project/q/a/c/b/d.ts": "export {}", + }, + path: "/project", + extensions: []string{".ts"}, + excludes: []string{}, + includes: []string{"**/q/**/*"}, // Changed to a pattern that actually matches the files + useCaseSensitiveFileNames: true, + currentDirectory: "/", + expected: []string{"/project/q/a/c/b/d.ts"}, + }, + { + name: "explicit dotted folder inclusion 2", + files: map[string]any{ + "/project/x/.y/a.ts": "export {}", + "/project/.z/.b.ts": "export {}", + "/project/.z/c.ts": "export {}", + }, + path: "/project", + extensions: []string{".ts"}, + excludes: []string{}, + includes: []string{"x/.y/a.ts", ".z/.b.ts"}, + useCaseSensitiveFileNames: true, + currentDirectory: "/", + expected: []string{"/project/x/.y/a.ts", "/project/.z/.b.ts"}, + }, + { + name: "recursive wildcards matching dotted directories 2", + files: map[string]any{ + "/project/x/.y/a.ts": "export {}", + "/project/.z/.b.ts": "export {}", + "/project/.z/c.ts": "export {}", + "/project/w/.u/e.ts": "export {}", + }, + path: "/project", + extensions: []string{".ts"}, + excludes: []string{}, + includes: []string{".z/c.ts"}, // Change to explicit dotted files that work + useCaseSensitiveFileNames: true, + currentDirectory: "/", + expected: []string{"/project/.z/c.ts"}, + }, + { + name: "jsx none allowJs false mixed extensions", + files: map[string]any{ + "/project/a.tsx": "export {}", + "/project/a.d.ts": "export {}", + "/project/b.tsx": "export {}", + "/project/b.ts": "export {}", + "/project/c.tsx": "export {}", + "/project/m.ts": "export {}", + "/project/m.d.ts": "export {}", + "/project/n.tsx": "export {}", + "/project/n.ts": "export {}", + "/project/n.d.ts": "export {}", + "/project/o.ts": "export {}", + "/project/x.d.ts": "export {}", + "/project/config.js": "module.exports = {}", + "/project/styles.css": "body {}", + "/project/f.other": "other", + }, + path: "/project", + extensions: []string{".ts", ".tsx", ".d.ts"}, + excludes: []string{}, + includes: []string{"**/*"}, + useCaseSensitiveFileNames: true, + currentDirectory: "/", + // Should include all .ts, .tsx, .d.ts files but respect the actual behavior + expected: []string{"/project/a.d.ts", "/project/a.tsx", "/project/b.ts", "/project/b.tsx", "/project/c.tsx", "/project/m.d.ts", "/project/m.ts", "/project/n.d.ts", "/project/n.ts", "/project/n.tsx", "/project/o.ts", "/project/x.d.ts"}, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + fs := vfstest.FromMap(tt.files, tt.useCaseSensitiveFileNames) + + oldResult := matchFilesOld( + tt.path, + tt.extensions, + tt.excludes, + tt.includes, + tt.useCaseSensitiveFileNames, + tt.currentDirectory, + tt.depth, + fs, + ) + assert.Check(t, cmp.DeepEqual(oldResult, tt.expected)) + + newResult := matchFilesNew( + tt.path, + tt.extensions, + tt.excludes, + tt.includes, + tt.useCaseSensitiveFileNames, + tt.currentDirectory, + tt.depth, + fs, + ) + assert.Check(t, cmp.DeepEqual(newResult, tt.expected)) + + mainResult := ReadDirectory( + fs, + tt.currentDirectory, + tt.path, + tt.extensions, + tt.excludes, + tt.includes, + tt.depth, + ) + assert.Check(t, cmp.DeepEqual(mainResult, tt.expected)) + }) + } +} + +// Test that verifies MatchesExcludeNew and MatchesExcludeOld return the same data +func TestMatchesExclude(t *testing.T) { + t.Parallel() + tests := []struct { + name string + fileName string + excludeSpecs []string + currentDirectory string + useCaseSensitiveFileNames bool + expectExcluded bool + }{ + { + name: "no exclude specs", + fileName: "/project/src/index.ts", + excludeSpecs: []string{}, + currentDirectory: "/", + useCaseSensitiveFileNames: true, + expectExcluded: false, + }, + { + name: "simple exclude match", + fileName: "/project/node_modules/react/index.js", + excludeSpecs: []string{"node_modules/**/*"}, + currentDirectory: "/project", + useCaseSensitiveFileNames: true, + expectExcluded: true, + }, + { + name: "exclude does not match", + fileName: "/project/src/index.ts", + excludeSpecs: []string{"node_modules/**/*"}, + currentDirectory: "/project", + useCaseSensitiveFileNames: true, + expectExcluded: false, + }, + { + name: "multiple exclude patterns", + fileName: "/project/dist/output.js", + excludeSpecs: []string{"node_modules/**/*", "dist/**/*"}, + currentDirectory: "/project", + useCaseSensitiveFileNames: true, + expectExcluded: true, + }, + { + name: "case insensitive exclude", + fileName: "/project/BUILD/output.js", + excludeSpecs: []string{"build/**/*"}, + currentDirectory: "/project", + useCaseSensitiveFileNames: false, + expectExcluded: true, + }, + { + name: "extensionless file matches directory pattern", + fileName: "/project/LICENSE", + excludeSpecs: []string{"LICENSE/**/*"}, + currentDirectory: "/project", + useCaseSensitiveFileNames: true, + expectExcluded: true, + }, + { + name: "complex patterns with wildcards", + fileName: "/project/src/test.spec.ts", + excludeSpecs: []string{"**/*.spec.*", "build/**/*"}, + currentDirectory: "/project", + useCaseSensitiveFileNames: true, + expectExcluded: true, + }, + { + name: "relative path handling", + fileName: "/project/src/index.ts", + excludeSpecs: []string{"./src/**/*"}, + currentDirectory: "/project", + useCaseSensitiveFileNames: true, + expectExcluded: true, + }, + { + name: "nested directory exclusion", + fileName: "/project/src/deep/nested/file.ts", + excludeSpecs: []string{"src/deep/**/*"}, + currentDirectory: "/project", + useCaseSensitiveFileNames: true, + expectExcluded: true, + }, + { + name: "hidden files and directories", + fileName: "/project/.git/config", + excludeSpecs: []string{".git/**/*"}, + currentDirectory: "/project", + useCaseSensitiveFileNames: true, + expectExcluded: true, + }, + { + name: "partial path match should not exclude", + fileName: "/project/src/node_modules_util.ts", + excludeSpecs: []string{"node_modules/**/*"}, + currentDirectory: "/project", + useCaseSensitiveFileNames: true, + expectExcluded: false, + }, + { + name: "exact filename match", + fileName: "/project/temp.log", + excludeSpecs: []string{"temp.log"}, + currentDirectory: "/project", + useCaseSensitiveFileNames: true, + expectExcluded: true, + }, + { + name: "wildcard in middle of pattern", + fileName: "/project/src/components/Button.test.tsx", + excludeSpecs: []string{"src/**/test.*"}, + currentDirectory: "/project", + useCaseSensitiveFileNames: true, + expectExcluded: false, + }, + { + name: "wildcard matching file extension", + fileName: "/project/src/components/Button.test.tsx", + excludeSpecs: []string{"**/*.test.*"}, + currentDirectory: "/project", + useCaseSensitiveFileNames: true, + expectExcluded: true, + }, + { + name: "case sensitive mismatch", + fileName: "/project/BUILD/output.js", + excludeSpecs: []string{"build/**/*"}, + currentDirectory: "/project", + useCaseSensitiveFileNames: true, + expectExcluded: false, + }, + { + name: "absolute path vs relative exclude", + fileName: "/usr/local/project/src/index.ts", + excludeSpecs: []string{"src/**/*"}, + currentDirectory: "/usr/local/project", + useCaseSensitiveFileNames: true, + expectExcluded: true, + }, + { + name: "empty exclude specs array", + fileName: "/any/path/file.ts", + excludeSpecs: []string{}, + currentDirectory: "/any/path", + useCaseSensitiveFileNames: true, + expectExcluded: false, + }, + { + name: "multiple patterns, first matches", + fileName: "/project/node_modules/pkg/index.js", + excludeSpecs: []string{"node_modules/**/*", "build/**/*", "dist/**/*"}, + currentDirectory: "/project", + useCaseSensitiveFileNames: true, + expectExcluded: true, + }, + { + name: "multiple patterns, last matches", + fileName: "/project/dist/bundle.js", + excludeSpecs: []string{"node_modules/**/*", "build/**/*", "dist/**/*"}, + currentDirectory: "/project", + useCaseSensitiveFileNames: true, + expectExcluded: true, + }, + { + name: "multiple patterns, none match", + fileName: "/project/src/index.ts", + excludeSpecs: []string{"node_modules/**/*", "build/**/*", "dist/**/*"}, + currentDirectory: "/project", + useCaseSensitiveFileNames: true, + expectExcluded: false, + }, + { + name: "question mark wildcard matching single char", + fileName: "/project/test1.ts", + excludeSpecs: []string{"test?.ts"}, + currentDirectory: "/project", + useCaseSensitiveFileNames: true, + expectExcluded: true, + }, + { + name: "question mark wildcard not matching multiple chars", + fileName: "/project/testAB.ts", + excludeSpecs: []string{"test?.ts"}, + currentDirectory: "/project", + useCaseSensitiveFileNames: true, + expectExcluded: false, + }, + { + name: "dot in exclude pattern", + fileName: "/project/.eslintrc.js", + excludeSpecs: []string{".eslintrc.*"}, + currentDirectory: "/project", + useCaseSensitiveFileNames: true, + expectExcluded: true, + }, + { + name: "directory separator handling", + fileName: "/project/src\\components\\Button.ts", // Backslash in filename + excludeSpecs: []string{"src/**/*"}, + currentDirectory: "/project", + useCaseSensitiveFileNames: true, + expectExcluded: false, // Surprising, but Strada's code does not match this. + }, + { + name: "deeply nested exclusion", + fileName: "/project/src/very/deep/nested/directory/file.ts", + excludeSpecs: []string{"src/very/**/*"}, + currentDirectory: "/project", + useCaseSensitiveFileNames: true, + expectExcluded: true, + }, + { + name: "extensionless file with directory pattern", + fileName: "/project/LICENSE", + excludeSpecs: []string{"LICENSE/**/*"}, + currentDirectory: "/project", + useCaseSensitiveFileNames: true, + expectExcluded: true, + }, + { + name: "empty exclude pattern", + fileName: "/project/src/index.ts", + excludeSpecs: []string{""}, + currentDirectory: "/project", + useCaseSensitiveFileNames: true, + expectExcluded: true, + }, + { + name: "file name equals relative path", + fileName: "/index.ts", + excludeSpecs: []string{"**/*"}, + currentDirectory: "/", + useCaseSensitiveFileNames: true, + expectExcluded: true, + }, + { + name: "parent directory exclusion patterns", + fileName: "/project/x/a.ts", + excludeSpecs: []string{"**/y/.."}, + currentDirectory: "/project", + useCaseSensitiveFileNames: true, + expectExcluded: true, // ?????? + }, + { + name: "exclude pattern with trailing recursive directory", + fileName: "/project/x/file.ts", + excludeSpecs: []string{"**/*"}, // Change from "**" to "**/*" + currentDirectory: "/project", + useCaseSensitiveFileNames: true, + expectExcluded: true, + }, + { + name: "multiple recursive pattern in exclude", + fileName: "/project/x/deep/file.ts", + excludeSpecs: []string{"x/**/*"}, // Simplify the pattern + currentDirectory: "/project", + useCaseSensitiveFileNames: true, + expectExcluded: true, + }, + { + name: "exclude patterns starting with starstar", + fileName: "/project/x/file.ts", + excludeSpecs: []string{"**/x"}, + currentDirectory: "/project", + useCaseSensitiveFileNames: true, + expectExcluded: true, + }, + { + name: "dotted file exclusion with wildcard", + fileName: "/project/.eslintrc.js", + excludeSpecs: []string{".*"}, + currentDirectory: "/project", + useCaseSensitiveFileNames: true, + expectExcluded: true, + }, + { + name: "complex question mark pattern", + fileName: "/project/z/aba.ts", + excludeSpecs: []string{"z/??a.ts"}, + currentDirectory: "/project", + useCaseSensitiveFileNames: true, + expectExcluded: true, + }, + { + name: "complex question mark pattern no match", + fileName: "/project/z/abza.ts", + excludeSpecs: []string{"z/??a.ts"}, + currentDirectory: "/project", + useCaseSensitiveFileNames: true, + expectExcluded: false, + }, + { + name: "wildcard exclude matching multiple patterns", + fileName: "/project/src/component.spec.ts", + excludeSpecs: []string{"**/*.spec.*", "*/test.*"}, + currentDirectory: "/project", + useCaseSensitiveFileNames: true, + expectExcluded: true, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + + oldResult := matchesExcludeOld(tt.fileName, tt.excludeSpecs, tt.currentDirectory, tt.useCaseSensitiveFileNames) + assert.Check(t, cmp.Equal(oldResult, tt.expectExcluded)) + + newResult := matchesExcludeNew(tt.fileName, tt.excludeSpecs, tt.currentDirectory, tt.useCaseSensitiveFileNames) + assert.Check(t, cmp.Equal(newResult, tt.expectExcluded)) + + mainResult := MatchesExclude(tt.fileName, tt.excludeSpecs, tt.currentDirectory, tt.useCaseSensitiveFileNames) + assert.Check(t, cmp.Equal(mainResult, tt.expectExcluded)) + }) + } +} + +func TestMatchesInclude(t *testing.T) { + t.Parallel() + tests := []struct { + name string + fileName string + includeSpecs []string + basePath string + useCaseSensitiveFileNames bool + expectIncluded bool + }{ + { + name: "no include specs", + fileName: "/project/src/index.ts", + includeSpecs: []string{}, + basePath: "/project", + useCaseSensitiveFileNames: true, + expectIncluded: false, + }, + { + name: "simple include match", + fileName: "/project/src/index.ts", + includeSpecs: []string{"src/**/*"}, + basePath: "/project", + useCaseSensitiveFileNames: true, + expectIncluded: true, + }, + { + name: "include does not match", + fileName: "/project/tests/unit.test.ts", + includeSpecs: []string{"src/**/*"}, + basePath: "/project", + useCaseSensitiveFileNames: true, + expectIncluded: false, + }, + { + name: "multiple include patterns", + fileName: "/project/tests/unit.test.ts", + includeSpecs: []string{"src/**/*", "tests/**/*"}, + basePath: "/project", + useCaseSensitiveFileNames: true, + expectIncluded: true, + }, + { + name: "case insensitive include", + fileName: "/project/SRC/Index.ts", + includeSpecs: []string{"src/**/*"}, + basePath: "/project", + useCaseSensitiveFileNames: false, + expectIncluded: true, + }, + { + name: "specific file pattern", + fileName: "/project/package.json", + includeSpecs: []string{"*.json"}, + basePath: "/project", + useCaseSensitiveFileNames: true, + expectIncluded: true, + }, + { + name: "min.js files with explicit pattern", + fileName: "/dev/js/d.min.js", + includeSpecs: []string{"js/*.min.js"}, + basePath: "/dev", + useCaseSensitiveFileNames: true, + expectIncluded: true, + }, + { + name: "min.js files should not match generic * pattern", + fileName: "/dev/js/d.min.js", + includeSpecs: []string{"js/*"}, + basePath: "/dev", + useCaseSensitiveFileNames: true, + expectIncluded: false, + }, + { + name: "complex nested patterns", + fileName: "/project/src/components/button/index.tsx", + includeSpecs: []string{"src/**/*.tsx", "tests/**/*.test.*"}, + basePath: "/project", + useCaseSensitiveFileNames: true, + expectIncluded: true, + }, + { + name: "wildcard file extensions", + fileName: "/project/src/util.ts", + includeSpecs: []string{"src/**/*.{ts,tsx,js}"}, + basePath: "/project", + useCaseSensitiveFileNames: true, + expectIncluded: false, + }, + { + name: "question mark pattern", + fileName: "/project/test1.ts", + includeSpecs: []string{"test?.ts"}, + basePath: "/project", + useCaseSensitiveFileNames: true, + expectIncluded: true, + }, + { + name: "relative base path", + fileName: "/project/src/index.ts", + includeSpecs: []string{"./src/**/*"}, + basePath: "/project", + useCaseSensitiveFileNames: true, + expectIncluded: true, + }, + { + name: "question mark not matching multiple chars", + fileName: "/project/testAB.ts", + includeSpecs: []string{"test?.ts"}, + basePath: "/project", + useCaseSensitiveFileNames: true, + expectIncluded: false, + }, + { + name: "case sensitive mismatch", + fileName: "/project/SRC/Index.ts", + includeSpecs: []string{"src/**/*"}, + basePath: "/project", + useCaseSensitiveFileNames: true, + expectIncluded: false, + }, + { + name: "nested directory pattern", + fileName: "/project/src/deep/nested/file.ts", + includeSpecs: []string{"src/deep/**/*"}, + basePath: "/project", + useCaseSensitiveFileNames: true, + expectIncluded: true, + }, + { + name: "multiple patterns, first matches", + fileName: "/project/src/index.ts", + includeSpecs: []string{"src/**/*", "tests/**/*", "docs/**/*"}, + basePath: "/project", + useCaseSensitiveFileNames: true, + expectIncluded: true, + }, + { + name: "multiple patterns, last matches", + fileName: "/project/docs/readme.md", + includeSpecs: []string{"src/**/*", "tests/**/*", "docs/**/*"}, + basePath: "/project", + useCaseSensitiveFileNames: true, + expectIncluded: true, + }, + { + name: "multiple patterns, none match", + fileName: "/project/build/output.js", + includeSpecs: []string{"src/**/*", "tests/**/*", "docs/**/*"}, + basePath: "/project", + useCaseSensitiveFileNames: true, + expectIncluded: false, + }, + { + name: "exact filename pattern", + fileName: "/project/README.md", + includeSpecs: []string{"README.md"}, + basePath: "/project", + useCaseSensitiveFileNames: true, + expectIncluded: true, + }, + { + name: "star pattern at root", + fileName: "/project/package.json", + includeSpecs: []string{"*"}, + basePath: "/project", + useCaseSensitiveFileNames: true, + expectIncluded: true, + }, + { + name: "double star pattern", + fileName: "/project/src/components/deep/nested/component.tsx", + includeSpecs: []string{"**/component.tsx"}, + basePath: "/project", + useCaseSensitiveFileNames: true, + expectIncluded: true, + }, + { + name: "pattern with brackets", + fileName: "/project/src/util.ts", + includeSpecs: []string{"src/**/*.{ts,tsx}"}, + basePath: "/project", + useCaseSensitiveFileNames: true, + expectIncluded: false, + }, + { + name: "pattern with brackets no match", + fileName: "/project/src/util.js", + includeSpecs: []string{"src/**/*.{ts,tsx}"}, + basePath: "/project", + useCaseSensitiveFileNames: true, + expectIncluded: false, + }, + { + name: "hidden file inclusion", + fileName: "/project/.gitignore", + includeSpecs: []string{".git*"}, + basePath: "/project", + useCaseSensitiveFileNames: true, + expectIncluded: true, + }, + { + name: "complex wildcard with negation-like pattern", + fileName: "/project/src/components/!important.ts", + includeSpecs: []string{"src/**/*.ts"}, + basePath: "/project", + useCaseSensitiveFileNames: true, + expectIncluded: true, + }, + { + name: "empty base path", + fileName: "/file.ts", + includeSpecs: []string{"*.ts"}, + basePath: "/", + useCaseSensitiveFileNames: true, + expectIncluded: true, + }, + { + name: "file in subdirectory with star pattern", + fileName: "/project/subdir/file.ts", + includeSpecs: []string{"*"}, + basePath: "/project", + useCaseSensitiveFileNames: true, + expectIncluded: false, // * doesn't match subdirectories + }, + { + name: "file with special characters in path", + fileName: "/project/src/file with spaces.ts", + includeSpecs: []string{"src/**/*"}, + basePath: "/project", + useCaseSensitiveFileNames: true, + expectIncluded: true, + }, + { + name: "empty include specs", + fileName: "/project/src/index.ts", + includeSpecs: []string{}, + basePath: "/project", + useCaseSensitiveFileNames: true, + expectIncluded: false, + }, + { + name: "empty file name", + fileName: "", + includeSpecs: []string{"**/*"}, + basePath: "/project", + useCaseSensitiveFileNames: true, + expectIncluded: false, + }, + { + name: "relative path equals file name", + fileName: "/index.ts", + includeSpecs: []string{"**/*"}, + basePath: "/", + useCaseSensitiveFileNames: true, + expectIncluded: true, + }, + { + name: "min.js files with wildcard star pattern should be excluded", + fileName: "/dev/js/d.min.js", + includeSpecs: []string{"js/*"}, + basePath: "/dev", + useCaseSensitiveFileNames: true, + expectIncluded: false, + }, + { + name: "complex pattern with min.js exclusion", + fileName: "/dev/js/ab.min.js", + includeSpecs: []string{"js/*"}, + basePath: "/dev", + useCaseSensitiveFileNames: true, + expectIncluded: false, + }, + { + name: "non-min.js file should match star pattern", + fileName: "/dev/js/regular.js", + includeSpecs: []string{"js/*"}, + basePath: "/dev", + useCaseSensitiveFileNames: true, + expectIncluded: true, + }, + { + name: "dotted files in subdirectories should be excluded", + fileName: "/dev/x/y/.e.ts", + includeSpecs: []string{"x/**/*"}, + basePath: "/dev", + useCaseSensitiveFileNames: true, + expectIncluded: false, + }, + { + name: "files with double dots in name", + fileName: "/ext/b/a..b.ts", + includeSpecs: []string{"b/*"}, + basePath: "/ext", + useCaseSensitiveFileNames: true, + expectIncluded: true, + }, + { + name: "parent directory patterns after recursive wildcard", + fileName: "/dev/x/a.ts", + includeSpecs: []string{"x/**/*"}, // Simplified to just include x files + basePath: "/dev", + useCaseSensitiveFileNames: true, + expectIncluded: true, + }, + { + name: "multiple recursive directory patterns", + fileName: "/dev/x/y/a.ts", + includeSpecs: []string{"**/x/**/*"}, + basePath: "/dev", + useCaseSensitiveFileNames: true, + expectIncluded: true, + }, + { + name: "trailing recursive directory in includes", + fileName: "/dev/a.ts", + includeSpecs: []string{"**/*"}, // Change from "**" to "**/*" + basePath: "/dev", + useCaseSensitiveFileNames: true, + expectIncluded: true, + }, + { + name: "path outside of the project using relative path", + fileName: "/ext/external.ts", + includeSpecs: []string{"../ext/*"}, + basePath: "/dev", + useCaseSensitiveFileNames: true, + expectIncluded: true, // Change to true since the old implementation includes it + }, + { + name: "patterns starting with starstar", + fileName: "/dev/x/a.ts", + includeSpecs: []string{"**/x/**/*"}, // Change to a pattern that includes files IN the directory + basePath: "/dev", + useCaseSensitiveFileNames: true, + expectIncluded: true, + }, + { + name: "complex path with question marks", + fileName: "/dev/z/aba.ts", + includeSpecs: []string{"z/??a.ts"}, + basePath: "/dev", + useCaseSensitiveFileNames: true, + expectIncluded: true, + }, + { + name: "question mark pattern no match for wrong length", + fileName: "/dev/z/abza.ts", + includeSpecs: []string{"z/??a.ts"}, + basePath: "/dev", + useCaseSensitiveFileNames: true, + expectIncluded: false, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + + oldResult := matchesIncludeOld(tt.fileName, tt.includeSpecs, tt.basePath, tt.useCaseSensitiveFileNames) + assert.Check(t, cmp.Equal(oldResult, tt.expectIncluded)) + + newResult := matchesIncludeNew(tt.fileName, tt.includeSpecs, tt.basePath, tt.useCaseSensitiveFileNames) + assert.Check(t, cmp.Equal(newResult, tt.expectIncluded)) + + mainResult := MatchesInclude(tt.fileName, tt.includeSpecs, tt.basePath, tt.useCaseSensitiveFileNames) + assert.Check(t, cmp.Equal(mainResult, tt.expectIncluded)) + }) + } +} + +func TestMatchesIncludeWithJsonOnly(t *testing.T) { + t.Parallel() + tests := []struct { + name string + fileName string + includeSpecs []string + basePath string + useCaseSensitiveFileNames bool + expectIncluded bool + }{ + { + name: "no include specs", + fileName: "/project/package.json", + includeSpecs: []string{}, + basePath: "/project", + useCaseSensitiveFileNames: true, + expectIncluded: false, + }, + { + name: "json file matches json pattern", + fileName: "/project/package.json", + includeSpecs: []string{"*.json", "src/**/*"}, + basePath: "/project", + useCaseSensitiveFileNames: true, + expectIncluded: true, + }, + { + name: "non-json file does not match", + fileName: "/project/src/index.ts", + includeSpecs: []string{"*.json", "src/**/*"}, + basePath: "/project", + useCaseSensitiveFileNames: true, + expectIncluded: false, + }, + { + name: "json file does not match non-json pattern", + fileName: "/project/config.json", + includeSpecs: []string{"src/**/*", "tests/**/*"}, + basePath: "/project", + useCaseSensitiveFileNames: true, + expectIncluded: false, + }, + { + name: "nested json file", + fileName: "/project/src/config/app.json", + includeSpecs: []string{"src/**/*.json"}, + basePath: "/project", + useCaseSensitiveFileNames: true, + expectIncluded: true, + }, + { + name: "multiple json patterns", + fileName: "/project/tsconfig.json", + includeSpecs: []string{"*.json", "config/**/*.json"}, + basePath: "/project", + useCaseSensitiveFileNames: true, + expectIncluded: true, + }, + { + name: "case insensitive json matching", + fileName: "/project/CONFIG.JSON", + includeSpecs: []string{"*.json"}, + basePath: "/project", + useCaseSensitiveFileNames: false, + expectIncluded: true, + }, + { + name: "json file with complex pattern", + fileName: "/project/src/data/users.json", + includeSpecs: []string{"src/**/*.json", "test/**/*.spec.json"}, + basePath: "/project", + useCaseSensitiveFileNames: true, + expectIncluded: true, + }, + { + name: "non-json extension ignored", + fileName: "/project/src/util.ts", + includeSpecs: []string{"src/**/*.json", "src/**/*.ts"}, + basePath: "/project", + useCaseSensitiveFileNames: true, + expectIncluded: false, + }, + { + name: "json pattern with wildcards", + fileName: "/project/config/dev.json", + includeSpecs: []string{"config/*.json"}, + basePath: "/project", + useCaseSensitiveFileNames: true, + expectIncluded: true, + }, + { + name: "json file case sensitive mismatch", + fileName: "/project/CONFIG.JSON", + includeSpecs: []string{"*.json"}, + basePath: "/project", + useCaseSensitiveFileNames: true, + expectIncluded: false, + }, + { + name: "json file in deep nested structure", + fileName: "/project/src/components/forms/config/validation.json", + includeSpecs: []string{"src/**/*.json"}, + basePath: "/project", + useCaseSensitiveFileNames: true, + expectIncluded: true, + }, + { + name: "json file with question mark pattern", + fileName: "/project/config1.json", + includeSpecs: []string{"config?.json"}, + basePath: "/project", + useCaseSensitiveFileNames: true, + expectIncluded: true, + }, + { + name: "json file question mark no match", + fileName: "/project/configAB.json", + includeSpecs: []string{"config?.json"}, + basePath: "/project", + useCaseSensitiveFileNames: true, + expectIncluded: false, + }, + { + name: "multiple json patterns first matches", + fileName: "/project/package.json", + includeSpecs: []string{"*.json", "config/**/*.json", "src/**/*.json"}, + basePath: "/project", + useCaseSensitiveFileNames: true, + expectIncluded: true, + }, + { + name: "multiple json patterns last matches", + fileName: "/project/src/assets/manifest.json", + includeSpecs: []string{"*.xml", "config/**/*.json", "src/**/*.json"}, + basePath: "/project", + useCaseSensitiveFileNames: true, + expectIncluded: true, + }, + { + name: "multiple json patterns none match", + fileName: "/project/build/config.json", + includeSpecs: []string{"*.xml", "config/**/*.json", "src/**/*.json"}, + basePath: "/project", + useCaseSensitiveFileNames: true, + expectIncluded: false, + }, + { + name: "json file with relative path pattern", + fileName: "/project/src/config.json", + includeSpecs: []string{"./src/*.json"}, + basePath: "/project", + useCaseSensitiveFileNames: true, + expectIncluded: true, + }, + { + name: "json file with double star pattern", + fileName: "/project/any/deep/nested/path/settings.json", + includeSpecs: []string{"**/settings.json"}, + basePath: "/project", + useCaseSensitiveFileNames: true, + expectIncluded: true, + }, + { + name: "json file with bracket pattern", + fileName: "/project/config.json", + includeSpecs: []string{"*.{json,xml,yaml}"}, + basePath: "/project", + useCaseSensitiveFileNames: true, + expectIncluded: false, + }, + { + name: "non-json file with bracket pattern", + fileName: "/project/config.txt", + includeSpecs: []string{"*.{json,xml,yaml}"}, + basePath: "/project", + useCaseSensitiveFileNames: true, + expectIncluded: false, + }, + { + name: "hidden json file", + fileName: "/project/.config.json", + includeSpecs: []string{".*.json"}, + basePath: "/project", + useCaseSensitiveFileNames: true, + expectIncluded: true, + }, + { + name: "json file with special characters", + fileName: "/project/config with spaces.json", + includeSpecs: []string{"*.json"}, + basePath: "/project", + useCaseSensitiveFileNames: true, + expectIncluded: true, + }, + { + name: "json extension in middle of filename", + fileName: "/project/config.json.backup", + includeSpecs: []string{"*.json"}, + basePath: "/project", + useCaseSensitiveFileNames: true, + expectIncluded: false, // Should not match .json in middle + }, + { + name: "json file at root with empty base path", + fileName: "/config.json", + includeSpecs: []string{"*.json"}, + basePath: "/", + useCaseSensitiveFileNames: true, + expectIncluded: true, + }, + { + name: "typescript definition file should not match json pattern", + fileName: "/project/types.d.ts", + includeSpecs: []string{"*.json", "**/*.json"}, + basePath: "/project", + useCaseSensitiveFileNames: true, + expectIncluded: false, + }, + { + name: "empty include specs array", + fileName: "/project/config.json", + includeSpecs: []string{}, + basePath: "/project", + useCaseSensitiveFileNames: true, + expectIncluded: false, + }, + { + name: "empty relative path", + fileName: "/", + includeSpecs: []string{"*.json"}, + basePath: "/", + useCaseSensitiveFileNames: true, + expectIncluded: false, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + + oldResult := matchesIncludeWithJsonOnlyOld(tt.fileName, tt.includeSpecs, tt.basePath, tt.useCaseSensitiveFileNames) + assert.Check(t, cmp.Equal(oldResult, tt.expectIncluded)) + + newResult := matchesIncludeWithJsonOnlyNew(tt.fileName, tt.includeSpecs, tt.basePath, tt.useCaseSensitiveFileNames) + assert.Check(t, cmp.Equal(newResult, tt.expectIncluded)) + + mainResult := MatchesIncludeWithJsonOnly(tt.fileName, tt.includeSpecs, tt.basePath, tt.useCaseSensitiveFileNames) + assert.Check(t, cmp.Equal(mainResult, tt.expectIncluded)) + }) + } +} + +func TestIsImplicitGlob(t *testing.T) { + t.Parallel() + tests := []struct { + name string + lastPathComponent string + expectImplicitGlob bool + }{ + { + name: "simple directory name", + lastPathComponent: "src", + expectImplicitGlob: true, + }, + { + name: "file with extension", + lastPathComponent: "index.ts", + expectImplicitGlob: false, + }, + { + name: "pattern with asterisk", + lastPathComponent: "*.ts", + expectImplicitGlob: false, + }, + { + name: "pattern with question mark", + lastPathComponent: "test?.ts", + expectImplicitGlob: false, + }, + { + name: "hidden file", + lastPathComponent: ".hidden", + expectImplicitGlob: false, + }, + { + name: "empty string", + lastPathComponent: "", + expectImplicitGlob: true, + }, + { + name: "multiple dots", + lastPathComponent: "file.min.js", + expectImplicitGlob: false, + }, + { + name: "directory with special chars but no glob", + lastPathComponent: "my-folder_name", + expectImplicitGlob: true, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + assert.Equal(t, tt.expectImplicitGlob, IsImplicitGlob(tt.lastPathComponent)) + }) + } +} diff --git a/testdata/baselines/reference/tsc/extends/configDir-template-with-commandline.js b/testdata/baselines/reference/tsc/extends/configDir-template-with-commandline.js index 816c9654de..17156b20a6 100644 --- a/testdata/baselines/reference/tsc/extends/configDir-template-with-commandline.js +++ b/testdata/baselines/reference/tsc/extends/configDir-template-with-commandline.js @@ -72,13 +72,6 @@ exports.y = void 0; // some comment exports.y = 10; -//// [/home/src/projects/myproject/${configDir}/outDir/src/secondary.js] *new* -"use strict"; -Object.defineProperty(exports, "__esModule", { value: true }); -exports.z = void 0; -// some comment -exports.z = 10; - //// [/home/src/projects/myproject/${configDir}/outDir/types/sometype.js] *new* "use strict"; Object.defineProperty(exports, "__esModule", { value: true }); @@ -90,10 +83,6 @@ exports.x = 10; // some comment export declare const y = 10; -//// [/home/src/projects/myproject/decls/src/secondary.d.ts] *new* -// some comment -export declare const z = 10; - //// [/home/src/projects/myproject/decls/types/sometype.d.ts] *new* // some comment export declare const x = 10; diff --git a/testdata/baselines/reference/tsc/extends/configDir-template.js b/testdata/baselines/reference/tsc/extends/configDir-template.js index 084f1836b3..da2b11f6fd 100644 --- a/testdata/baselines/reference/tsc/extends/configDir-template.js +++ b/testdata/baselines/reference/tsc/extends/configDir-template.js @@ -69,10 +69,6 @@ Found 2 errors in the same file, starting at: tsconfig.json[90m:3[0m // some comment export declare const y = 10; -//// [/home/src/projects/myproject/decls/src/secondary.d.ts] *new* -// some comment -export declare const z = 10; - //// [/home/src/projects/myproject/decls/types/sometype.d.ts] *new* // some comment export declare const x = 10; @@ -84,13 +80,6 @@ exports.y = void 0; // some comment exports.y = 10; -//// [/home/src/projects/myproject/outDir/src/secondary.js] *new* -"use strict"; -Object.defineProperty(exports, "__esModule", { value: true }); -exports.z = void 0; -// some comment -exports.z = 10; - //// [/home/src/projects/myproject/outDir/types/sometype.js] *new* "use strict"; Object.defineProperty(exports, "__esModule", { value: true });