package vfsmatch import ( "math" "slices" "strings" "unicode/utf8" "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" ) //go:generate go tool golang.org/x/tools/cmd/stringer -type=Usage -trimprefix=Usage -output=stringer_generated.go //go:generate npx dprint fmt stringer_generated.go // This file implements the glob matching algorithm specified in MATCHING_ALGORITHM.md. type Usage int8 const ( UsageFiles Usage = iota UsageDirectories UsageExclude ) // UnlimitedDepth can be passed as the depth argument to indicate there is no depth limit. const UnlimitedDepth = math.MaxInt func ReadDirectory(host vfs.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) } // IsImplicitGlob checks if a path component is implicitly a glob. // 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, ".*?") } var 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 { comparePathsOptions := tspath.ComparePathsOptions{CurrentDirectory: path, UseCaseSensitiveFileNames: useCaseSensitiveFileNames} stringComparer := comparePathsOptions.GetComparer() // 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. slices.SortStableFunc(includeBasePaths, stringComparer) // 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, comparePathsOptions) }) { basePaths = append(basePaths, includeBasePath) } } } return basePaths } // globPattern is a compiled glob pattern for matching file paths without regex. type globPattern struct { components []component // path segments to match (e.g., ["src", "**", "*.ts"]) isExclude bool // exclude patterns have different matching rules caseSensitive bool excludeMinJs bool // for "files" patterns, exclude .min.js by default } // component is a single path segment in a glob pattern. // Examples: "src" (literal), "*" (wildcard), "*.ts" (wildcard), "**" (recursive) type component struct { kind componentKind literal string // for kindLiteral: the exact string to match segments []segment // for kindWildcard: parsed wildcard pattern // Include patterns with wildcards skip common package folders (node_modules, etc.) skipPackageFolders bool } type componentKind int const ( kindLiteral componentKind = iota // exact match (e.g., "src") kindWildcard // contains * or ? (e.g., "*.ts") kindDoubleAsterisk // ** matches zero or more directories ) // segment is a piece of a wildcard component. // Example: "*.ts" becomes [segStar, segLiteral(".ts")] type segment struct { kind segmentKind literal string // only for segLiteral } type segmentKind int const ( segLiteral segmentKind = iota // exact text segStar // * matches any chars except / segQuestion // ? matches single char except / ) // compileGlobPattern compiles a glob spec (e.g., "src/**/*.ts") into a pattern. // Returns (pattern, false) if the pattern would match nothing. func compileGlobPattern(spec string, basePath string, usage Usage, caseSensitive bool) (globPattern, bool) { parts := tspath.GetNormalizedPathComponents(spec, basePath) // "src/**" without a filename matches nothing (for include patterns) if usage != UsageExclude && core.LastOrNil(parts) == "**" { return globPattern{}, false } // Normalize root: "/home/" -> "/home" parts[0] = tspath.RemoveTrailingDirectorySeparator(parts[0]) // Directories implicitly match all files: "src" -> "src/**/*" if IsImplicitGlob(core.LastOrNil(parts)) { parts = append(parts, "**", "*") } p := globPattern{ isExclude: usage == UsageExclude, caseSensitive: caseSensitive, excludeMinJs: usage == UsageFiles, // Avoid slice growth during compilation. components: make([]component, 0, len(parts)), } for _, part := range parts { p.components = append(p.components, parseComponent(part, usage != UsageExclude)) } return p, true } // parseComponent converts a path segment string into a component. func parseComponent(s string, isInclude bool) component { if s == "**" { return component{kind: kindDoubleAsterisk} } if !strings.ContainsAny(s, "*?") { return component{kind: kindLiteral, literal: s} } return component{ kind: kindWildcard, segments: parseSegments(s), skipPackageFolders: isInclude, } } // parseSegments breaks "*.ts" into [segStar, segLiteral(".ts")] func parseSegments(s string) []segment { // Preallocate based on wildcard count: each wildcard contributes 1 segment, // and each wildcard can split literals into at most one extra literal segment. wildcards := 0 for i := range len(s) { if s[i] == '*' || s[i] == '?' { wildcards++ } } result := make([]segment, 0, 2*wildcards+1) start := 0 for i := range len(s) { switch s[i] { case '*', '?': if i > start { result = append(result, segment{kind: segLiteral, literal: s[start:i]}) } if s[i] == '*' { result = append(result, segment{kind: segStar}) } else { result = append(result, segment{kind: segQuestion}) } start = i + 1 } } if start < len(s) { result = append(result, segment{kind: segLiteral, literal: s[start:]}) } return result } // matches returns true if path matches this pattern. func (p *globPattern) matches(path string) bool { return p.matchPathParts(path, "", 0, 0, false) } // matchesParts returns true if prefix+suffix matches this pattern. // This avoids allocating a combined string for common call sites where prefix ends with '/'. func (p *globPattern) matchesParts(prefix, suffix string) bool { return p.matchPathParts(prefix, suffix, 0, 0, false) } // matchesPrefixParts returns true if files under prefix+suffix could match. func (p *globPattern) matchesPrefixParts(prefix, suffix string) bool { return p.matchPathParts(prefix, suffix, 0, 0, true) } // matchPathParts is like matchPath, but operates on a virtual path formed by prefix+suffix. // Offsets are in the combined string. func (p *globPattern) matchPathParts(prefix, suffix string, pathOffset, compIdx int, prefixOnly bool) bool { for { pathPart, nextOffset, ok := nextPathPartParts(prefix, suffix, pathOffset) if !ok { if prefixOnly { return true } return p.patternSatisfied(compIdx) } if compIdx >= len(p.components) { return p.isExclude && !prefixOnly } comp := p.components[compIdx] switch comp.kind { case kindDoubleAsterisk: if p.matchPathParts(prefix, suffix, pathOffset, compIdx+1, prefixOnly) { return true } if !p.isExclude && (isHiddenPath(pathPart) || isPackageFolder(pathPart)) { return false } pathOffset = nextOffset continue case kindLiteral: if comp.skipPackageFolders && isPackageFolder(pathPart) { panic("unreachable: literal components never have skipPackageFolders") } if !p.stringsEqual(comp.literal, pathPart) { return false } case kindWildcard: if comp.skipPackageFolders && isPackageFolder(pathPart) { return false } if !p.matchWildcard(comp.segments, pathPart) { return false } } pathOffset = nextOffset compIdx++ } } // patternSatisfied checks if remaining pattern components can match empty input. func (p *globPattern) patternSatisfied(compIdx int) bool { // A pattern is satisfied when remaining components can match empty input. // For both include and exclude patterns, only trailing "**" components may match nothing. for _, c := range p.components[compIdx:] { if c.kind != kindDoubleAsterisk { return false } } return true } // nextPathPart extracts the next path component from path starting at offset. func nextPathPartSingle(s string, offset int) (part string, nextOffset int, ok bool) { if offset >= len(s) { return "", offset, false } if offset == 0 && len(s) > 0 && s[0] == '/' { return "", 1, true } for offset < len(s) && s[offset] == '/' { offset++ } if offset >= len(s) { return "", offset, false } rest := s[offset:] if idx := strings.IndexByte(rest, '/'); idx >= 0 { return rest[:idx], offset + idx, true } return rest, len(s), true } func nextPathPartParts(prefix, suffix string, offset int) (part string, nextOffset int, ok bool) { // Fast paths: keep the hot single-string scan tight. if len(suffix) == 0 { return nextPathPartSingle(prefix, offset) } if len(prefix) == 0 { return nextPathPartSingle(suffix, offset) } // For matchFilesNoRegex call sites, prefix is a directory path ending in '/', // and suffix is a single entry name (no '/'). That makes this significantly // simpler than a general-purpose "virtual concatenation" scanner. totalLen := len(prefix) + len(suffix) if offset >= totalLen { return "", offset, false } // Handle leading slash (root of absolute path) if offset == 0 && prefix[0] == '/' { return "", 1, true } // Scan within prefix. if offset < len(prefix) { for offset < len(prefix) && prefix[offset] == '/' { offset++ } if offset < len(prefix) { rest := prefix[offset:] idx := strings.IndexByte(rest, '/') // idx is guaranteed >= 0 for the call sites we care about because prefix ends in '/'. return rest[:idx], offset + idx, true } // Fall through into suffix region. } // Scan suffix: it's a single component. sOff := offset - len(prefix) if sOff >= len(suffix) { return "", offset, false } return suffix[sOff:], totalLen, true } // matchWildcard matches a path component against wildcard segments. func (p *globPattern) matchWildcard(segs []segment, s string) bool { // Include patterns: wildcards at start cannot match hidden files if !p.isExclude && len(segs) > 0 && isHiddenPath(s) && (segs[0].kind == segStar || segs[0].kind == segQuestion) { return false } // Fast path: single * followed by literal suffix (e.g., "*.ts") if len(segs) == 2 && segs[0].kind == segStar && segs[1].kind == segLiteral { suffix := segs[1].literal if len(s) < len(suffix) || !p.stringsEqual(suffix, s[len(s)-len(suffix):]) { return false } return p.shouldIncludeMinJs(s, segs) } return p.matchSegments(segs, s) && p.shouldIncludeMinJs(s, segs) } // matchSegments matches segments against string s using an iterative algorithm. // This avoids exponential backtracking by tracking only the last star position. // The algorithm is O(n*m) where n is the string length and m is pattern length. func (p *globPattern) matchSegments(segs []segment, s string) bool { segIdx, sIdx := 0, 0 starSegIdx, starSIdx := -1, 0 for sIdx < len(s) { if segIdx < len(segs) { seg := segs[segIdx] switch seg.kind { case segLiteral: end := sIdx + len(seg.literal) if end <= len(s) && p.stringsEqual(seg.literal, s[sIdx:end]) { sIdx = end segIdx++ continue } case segQuestion: if s[sIdx] != '/' { _, size := utf8.DecodeRuneInString(s[sIdx:]) sIdx += size segIdx++ continue } case segStar: // Record star position for backtracking, then try matching zero chars. starSegIdx = segIdx starSIdx = sIdx segIdx++ continue } } // Current segment didn't match. Backtrack to last star if possible. if starSegIdx >= 0 && starSIdx < len(s) && s[starSIdx] != '/' { // Star consumes one more character (rune), retry from segment after star. _, size := utf8.DecodeRuneInString(s[starSIdx:]) starSIdx += size sIdx = starSIdx segIdx = starSegIdx + 1 continue } return false } // Consume any trailing stars. for segIdx < len(segs) && segs[segIdx].kind == segStar { segIdx++ } return segIdx >= len(segs) } func (p *globPattern) shouldIncludeMinJs(filename string, segs []segment) bool { if !p.excludeMinJs { return true } // Preserve legacy behavior: // - When matching is case-sensitive, only the exact ".min.js" suffix is excluded by default. // - When matching is case-insensitive, any casing variant is excluded by default. if !p.hasMinJsSuffix(filename) { return true } // Allow when the user's pattern explicitly references the .min. suffix. if p.patternMentionsMinSuffix(segs) { return true } return false } func (p *globPattern) hasMinJsSuffix(filename string) bool { if p.caseSensitive { return strings.HasSuffix(filename, ".min.js") } const minJs = ".min.js" if len(filename) < len(minJs) { return false } // Avoid allocating via strings.ToLower; compare suffix case-insensitively. return strings.EqualFold(filename[len(filename)-len(minJs):], minJs) } func (p *globPattern) patternMentionsMinSuffix(segs []segment) bool { for _, seg := range segs { if seg.kind != segLiteral { continue } lit := seg.literal if !p.caseSensitive { lit = strings.ToLower(lit) } if strings.Contains(lit, ".min.js") || strings.Contains(lit, ".min.") { return true } } return false } // stringsEqual compares strings with appropriate case sensitivity. func (p *globPattern) stringsEqual(a, b string) bool { if p.caseSensitive { return a == b } return strings.EqualFold(a, b) } // isHiddenPath checks if a path component is hidden (starts with dot). func isHiddenPath(name string) bool { return len(name) > 0 && name[0] == '.' } // isPackageFolder checks if name is a common package folder (node_modules, etc.) func isPackageFolder(name string) bool { switch len(name) { case len("node_modules"): return strings.EqualFold(name, "node_modules") case len("jspm_packages"): return strings.EqualFold(name, "jspm_packages") case len("bower_components"): return strings.EqualFold(name, "bower_components") } return false } func ensureTrailingSlash(s string) string { if len(s) > 0 && s[len(s)-1] != '/' { return s + "/" } return s } // globMatcher combines include and exclude patterns for file matching. type globMatcher struct { includes []globPattern excludes []globPattern hadIncludes bool // true if include specs were provided (even if none compiled) } func newGlobMatcher(includeSpecs, excludeSpecs []string, basePath string, caseSensitive bool, usage Usage) *globMatcher { m := &globMatcher{ hadIncludes: len(includeSpecs) > 0, includes: make([]globPattern, 0, len(includeSpecs)), excludes: make([]globPattern, 0, len(excludeSpecs)), } for _, spec := range includeSpecs { if p, ok := compileGlobPattern(spec, basePath, usage, caseSensitive); ok { m.includes = append(m.includes, p) } } for _, spec := range excludeSpecs { if p, ok := compileGlobPattern(spec, basePath, UsageExclude, caseSensitive); ok { m.excludes = append(m.excludes, p) } } return m } // matchesFileParts checks if prefix+suffix matches against the glob patterns. // Returns the index of the matching include pattern and true if matched, or (0, false) if not. func (m *globMatcher) matchesFileParts(prefix, suffix string) (int, bool) { for i := range m.excludes { if m.excludes[i].matchesParts(prefix, suffix) { return 0, false } } if len(m.includes) == 0 { if m.hadIncludes { return 0, false } return 0, true } for i := range m.includes { if m.includes[i].matchesParts(prefix, suffix) { return i, true } } return 0, false } // matchesDirectoryParts checks if files under the directory prefix+suffix could match any pattern. func (m *globMatcher) matchesDirectoryParts(prefix, suffix string) bool { for i := range m.excludes { if m.excludes[i].matchesParts(prefix, suffix) { return false } } if len(m.includes) == 0 { return !m.hadIncludes } for i := range m.includes { if m.includes[i].matchesPrefixParts(prefix, suffix) { return true } } return false } // globVisitor traverses directories matching files against glob patterns. type globVisitor struct { host vfs.FS fileMatcher *globMatcher directoryMatcher *globMatcher extensions []string useCaseSensitiveFileNames bool visited collections.Set[string] results [][]string } // visit walks a directory tree, collecting files that match the glob patterns. // resolvedRealPath, when non-empty, is the already-resolved real path for this // directory (computed incrementally from the parent). When empty, Realpath is // called to resolve symlinks. func (v *globVisitor) visit(path, absolutePath string, depth int, resolvedRealPath string) { // Detect symlink cycles var realPath string if resolvedRealPath != "" { realPath = resolvedRealPath } else { realPath = v.host.Realpath(absolutePath) } canonicalPath := tspath.GetCanonicalFileName(realPath, v.useCaseSensitiveFileNames) if v.visited.Has(canonicalPath) { return } v.visited.Add(canonicalPath) entries := v.host.GetAccessibleEntries(absolutePath) pathPrefix := ensureTrailingSlash(path) absPrefix := ensureTrailingSlash(absolutePath) for _, file := range entries.Files { if len(v.extensions) > 0 && !tspath.FileExtensionIsOneOf(file, v.extensions) { continue } if idx, ok := v.fileMatcher.matchesFileParts(absPrefix, file); ok { v.results[idx] = append(v.results[idx], pathPrefix+file) } } if depth != UnlimitedDepth { depth-- if depth == 0 { return } } for _, dir := range entries.Directories { if !v.directoryMatcher.matchesDirectoryParts(absPrefix, dir) { continue } absDir := absPrefix + dir var childRealPath string if entries.Symlinks != nil { if _, isSymlink := entries.Symlinks[dir]; !isSymlink { // Non-symlink directory: compute realpath incrementally. childRealPath = tspath.CombinePaths(realPath, dir) } // else: symlink directory; leave childRealPath empty to force Realpath call. } // If Symlinks is nil, the FS doesn't track symlinks; // leave childRealPath empty to call Realpath (preserving old behavior). v.visit(pathPrefix+dir, absDir, depth, childRealPath) } } func matchFiles(path string, extensions, excludes, 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) fileMatcher := newGlobMatcher(includes, excludes, absolutePath, useCaseSensitiveFileNames, UsageFiles) directoryMatcher := newGlobMatcher(includes, excludes, absolutePath, useCaseSensitiveFileNames, UsageDirectories) v := globVisitor{ host: host, fileMatcher: fileMatcher, directoryMatcher: directoryMatcher, extensions: extensions, useCaseSensitiveFileNames: useCaseSensitiveFileNames, results: make([][]string, max(len(fileMatcher.includes), 1)), } for _, basePath := range getBasePaths(path, includes, useCaseSensitiveFileNames) { v.visit(basePath, tspath.CombinePaths(currentDirectory, basePath), depth, "") } // Fast path: a single include bucket (or no includes) doesn't need flattening. if len(v.results) == 1 { return v.results[0] } return core.Flatten(v.results) } // SpecMatcher wraps multiple glob patterns for matching paths. type SpecMatcher struct { patterns []globPattern } // MatchString returns true if any pattern matches the path. func (m *SpecMatcher) MatchString(path string) bool { for i := range m.patterns { if m.patterns[i].matches(path) { return true } } return false } // MatchIndex returns the index of the first matching pattern, or -1. func (m *SpecMatcher) MatchIndex(path string) int { for i := range m.patterns { if m.patterns[i].matches(path) { return i } } return -1 } // NewSpecMatcher creates a matcher for one or more glob specs. // It returns a matcher that can test if paths match any of the patterns. func NewSpecMatcher(specs []string, basePath string, usage Usage, useCaseSensitiveFileNames bool) *SpecMatcher { if len(specs) == 0 { return nil } patterns := make([]globPattern, 0, len(specs)) for _, spec := range specs { if p, ok := compileGlobPattern(spec, basePath, usage, useCaseSensitiveFileNames); ok { patterns = append(patterns, p) } } if len(patterns) == 0 { return nil } return &SpecMatcher{patterns: patterns} }