Files
kjol/tools/tsgo/internal/vfs/vfsmatch/vfsmatch.go
2026-07-09 16:50:43 -04:00

718 lines
22 KiB
Go

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}
}