vendor tsgo
This commit is contained in:
425
tools/tsgo/internal/vfs/vfsmatch/MATCHING_ALGORITHM.md
Normal file
425
tools/tsgo/internal/vfs/vfsmatch/MATCHING_ALGORITHM.md
Normal file
@@ -0,0 +1,425 @@
|
||||
# Glob Matching Algorithm Specification
|
||||
|
||||
This document is a formal algorithmic specification of the file-path glob matching
|
||||
logic. An implementation conforming to this specification must produce identical
|
||||
results for all inputs. All subroutine errors are propagated as errors of the
|
||||
calling routine unless stated otherwise.
|
||||
|
||||
---
|
||||
|
||||
## 1. Definitions
|
||||
|
||||
**Path** — A normalized, `/`-separated absolute file path (e.g., `/project/src/index.ts`).
|
||||
|
||||
**Path component** — A single segment between `/` delimiters (e.g., `src`, `index.ts`). The leading `/` produces the root component, which is the empty string `""`.
|
||||
|
||||
**Spec** — A user-provided glob string (e.g., `src/**/*.ts`), before compilation.
|
||||
|
||||
**Base path** — The absolute directory against which relative specs are resolved.
|
||||
|
||||
**Usage mode** — One of three modes that alter matching semantics:
|
||||
- **Files** — Matches complete file paths.
|
||||
- **Directories** — Matches directory prefixes for traversal pruning.
|
||||
- **Exclude** — Matches paths to be excluded.
|
||||
|
||||
**Component kind** — One of:
|
||||
- **Literal** — Contains no `*` or `?` characters.
|
||||
- **Wildcard** — Contains at least one `*` or `?` character.
|
||||
- **DoubleAsterisk** — The exact string `**`.
|
||||
|
||||
**Character** — A single Unicode scalar value (codepoint). See Section 9 for the precise character-boundary requirements that apply during segment matching.
|
||||
|
||||
**Segment kind** — One of:
|
||||
- **SegLiteral** — An exact literal substring.
|
||||
- **SegStar** — Matches zero or more characters excluding `/`.
|
||||
- **SegQuestion** — Matches exactly one character excluding `/`.
|
||||
|
||||
**Pattern** — A compiled spec consisting of a component list, a usage mode, and a case-sensitivity flag.
|
||||
|
||||
---
|
||||
|
||||
## 2. Helper Predicates
|
||||
|
||||
**IS_HIDDEN_PATH**(_component_)
|
||||
|
||||
> 1. If the length of _component_ is 0, return **false**.
|
||||
> 2. If the first character of _component_ is `"."`, return **true**.
|
||||
> 3. Return **false**.
|
||||
|
||||
**IS_PACKAGE_FOLDER**(_component_)
|
||||
|
||||
> 1. If _component_ equals `"node_modules"` (case-insensitive), return **true**.
|
||||
> 2. If _component_ equals `"bower_components"` (case-insensitive), return **true**.
|
||||
> 3. If _component_ equals `"jspm_packages"` (case-insensitive), return **true**.
|
||||
> 4. Return **false**.
|
||||
|
||||
**ENSURE_TRAILING_SLASH**(_s_)
|
||||
|
||||
> 1. If the length of _s_ is 0, return _s_.
|
||||
> 2. If the last character of _s_ is `"/"`, return _s_.
|
||||
> 3. Return _s_ concatenated with `"/"`.
|
||||
|
||||
**STRINGS_EQUAL**(_a_, _b_, _caseSensitive_)
|
||||
|
||||
> 1. If _caseSensitive_ is **true**, return whether _a_ and _b_ are byte-for-byte identical.
|
||||
> 2. Return whether _a_ and _b_ are equal under Unicode case folding.
|
||||
|
||||
**IS_IMPLICIT_GLOB**(_component_)
|
||||
|
||||
> 1. If _component_ contains any of the characters `"."`, `"*"`, or `"?"`, return **false**.
|
||||
> 2. Return **true**.
|
||||
|
||||
---
|
||||
|
||||
## 3. Spec Normalization
|
||||
|
||||
**NORMALIZE_SPEC**(_spec_, _basePath_)
|
||||
|
||||
> 1. Let _components_ be the result of resolving _spec_ against _basePath_ into an ordered list of normalized path components. The first element is the absolute root prefix (e.g., `"/home"`). The resolution uses `/` as the path separator, resolves `.` and `..` segments, and collapses consecutive separators.
|
||||
> 2. If the last character of _components_\[0\] is `"/"`, remove it.
|
||||
> 3. Return _components_.
|
||||
|
||||
---
|
||||
|
||||
## 4. Segment Parsing
|
||||
|
||||
**PARSE_SEGMENTS**(_string_)
|
||||
|
||||
> 1. Let _segments_ be an empty list.
|
||||
> 2. Let _start_ be 0.
|
||||
> 3. For each index _i_ from 0 to the length of _string_ − 1:
|
||||
> 1. If _string_\[_i_\] is `"*"` or `"?"`, then:
|
||||
> 1. If _i_ > _start_, append a **SegLiteral** segment with value _string_\[_start_.._i_\] to _segments_.
|
||||
> 2. If _string_\[_i_\] is `"*"`, append a **SegStar** segment to _segments_.
|
||||
> 3. Otherwise, append a **SegQuestion** segment to _segments_.
|
||||
> 4. Set _start_ to _i_ + 1.
|
||||
> 4. If _start_ < length of _string_, append a **SegLiteral** segment with value _string_\[_start_..\] to _segments_.
|
||||
> 5. Return _segments_.
|
||||
|
||||
---
|
||||
|
||||
## 5. Pattern Compilation
|
||||
|
||||
**COMPILE_PATTERN**(_spec_, _basePath_, _usage_, _caseSensitive_)
|
||||
|
||||
> 1. Let _components_ be the result of NORMALIZE_SPEC(_spec_, _basePath_).
|
||||
> 2. If the last element of _components_ is `"**"` and _usage_ is not **Exclude**, return **failure**. (The pattern compiles to nothing.)
|
||||
> 3. If IS_IMPLICIT_GLOB(last element of _components_) is **true** (note: this check is applied to the _normalized_ component, not the raw spec string), then:
|
||||
> 1. Append `"**"` to _components_.
|
||||
> 2. Append `"*"` to _components_.
|
||||
> 4. Let _compiledComponents_ be an empty list.
|
||||
> 5. For each _part_ in _components_:
|
||||
> 1. If _part_ is `"**"`, append a **DoubleAsterisk** component to _compiledComponents_.
|
||||
> 2. Otherwise, if _part_ contains no `"*"` or `"?"` characters, append a **Literal** component with value _part_ to _compiledComponents_.
|
||||
> 3. Otherwise, append a **Wildcard** component with segments PARSE_SEGMENTS(_part_) to _compiledComponents_.
|
||||
> 6. Return a pattern with component list _compiledComponents_, usage mode _usage_, and case-sensitivity flag _caseSensitive_.
|
||||
|
||||
---
|
||||
|
||||
## 6. Path Component Extraction
|
||||
|
||||
**NEXT_PATH_COMPONENT**(_path_, _offset_)
|
||||
|
||||
> 1. If _offset_ ≥ length of _path_, return (**none**, _offset_, **false**).
|
||||
> 2. If _offset_ is 0 and _path_\[0\] is `"/"`, return (`""`, 1, **true**).
|
||||
> 3. While _offset_ < length of _path_ and _path_\[_offset_\] is `"/"`, increment _offset_.
|
||||
> 4. If _offset_ ≥ length of _path_, return (**none**, _offset_, **false**).
|
||||
> 5. Let _start_ be _offset_.
|
||||
> 6. While _offset_ < length of _path_ and _path_\[_offset_\] is not `"/"`, increment _offset_.
|
||||
> 7. Return (_path_\[_start_.._offset_\], _offset_, **true**).
|
||||
|
||||
---
|
||||
|
||||
## 7. Full-Path Matching
|
||||
|
||||
**MATCH_PATH**(_pattern_, _path_)
|
||||
|
||||
> 1. Return the result of MATCH_PATH_INNER(_pattern_, _path_, 0, 0, **false**).
|
||||
|
||||
**MATCH_PATH_PREFIX**(_pattern_, _path_)
|
||||
|
||||
> 1. Return the result of MATCH_PATH_INNER(_pattern_, _path_, 0, 0, **true**).
|
||||
|
||||
**MATCH_PATH_INNER**(_pattern_, _path_, _pathOffset_, _compIdx_, _prefixOnly_)
|
||||
|
||||
> 1. Let _components_ be the component list of _pattern_.
|
||||
> 2. Let _usage_ be the usage mode of _pattern_.
|
||||
> 3. Let _caseSensitive_ be the case-sensitivity flag of _pattern_.
|
||||
> 4. Loop:
|
||||
> 1. Let (_part_, _nextOffset_, _ok_) be the result of NEXT_PATH_COMPONENT(_path_, _pathOffset_).
|
||||
> 2. If _ok_ is **false**, then:
|
||||
> 1. If _prefixOnly_ is **true**, return **true**.
|
||||
> 2. Return the result of PATTERN_SATISFIED(_components_, _compIdx_).
|
||||
> 3. If _compIdx_ ≥ length of _components_, then:
|
||||
> 1. If _usage_ is **Exclude** and _prefixOnly_ is **false**, return **true**.
|
||||
> 2. Return **false**.
|
||||
> 4. Let _comp_ be _components_\[_compIdx_\].
|
||||
> 5. If the kind of _comp_ is **DoubleAsterisk**, then:
|
||||
> 1. Let _skipResult_ be the result of MATCH_PATH_INNER(_pattern_, _path_, _pathOffset_, _compIdx_ + 1, _prefixOnly_).
|
||||
> 2. If _skipResult_ is **true**, return **true**.
|
||||
> 3. If _usage_ is not **Exclude**, then:
|
||||
> 1. If IS_HIDDEN_PATH(_part_) is **true**, return **false**.
|
||||
> 2. If IS_PACKAGE_FOLDER(_part_) is **true**, return **false**.
|
||||
> 4. Set _pathOffset_ to _nextOffset_.
|
||||
> 5. Continue the loop.
|
||||
> 6. If the kind of _comp_ is **Literal**, then:
|
||||
> 1. If STRINGS_EQUAL(_comp_.value, _part_, _caseSensitive_) is **false**, return **false**.
|
||||
> 7. If the kind of _comp_ is **Wildcard**, then:
|
||||
> 1. If _usage_ is not **Exclude** and IS_PACKAGE_FOLDER(_part_) is **true**, return **false**.
|
||||
> 2. If the result of MATCH_WILDCARD(_pattern_, _comp_.segments, _part_) is **false**, return **false**.
|
||||
> 8. Set _pathOffset_ to _nextOffset_.
|
||||
> 9. Increment _compIdx_.
|
||||
|
||||
**PATTERN_SATISFIED**(_components_, _compIdx_)
|
||||
|
||||
> 1. For each index _i_ from _compIdx_ to length of _components_ − 1:
|
||||
> 1. If the kind of _components_\[_i_\] is not **DoubleAsterisk**, return **false**.
|
||||
> 2. Return **true**.
|
||||
|
||||
---
|
||||
|
||||
## 8. Wildcard Component Matching
|
||||
|
||||
**MATCH_WILDCARD**(_pattern_, _segments_, _string_)
|
||||
|
||||
> 1. Let _usage_ be the usage mode of _pattern_.
|
||||
> 2. Let _caseSensitive_ be the case-sensitivity flag of _pattern_.
|
||||
> 3. If _usage_ is not **Exclude**, then:
|
||||
> 1. If the length of _segments_ > 0, then:
|
||||
> 1. Let _firstKind_ be the kind of _segments_\[0\].
|
||||
> 2. If (_firstKind_ is **SegStar** or _firstKind_ is **SegQuestion**) and IS_HIDDEN_PATH(_string_) is **true**, return **false**.
|
||||
> 4. Let _matched_ be the result of MATCH_SEGMENTS(_segments_, _string_, _caseSensitive_).
|
||||
> 5. If _matched_ is **false**, return **false**.
|
||||
> 6. Let _accepted_ be the result of SHOULD_ACCEPT_MIN_JS(_pattern_, _segments_, _string_).
|
||||
> 7. Return _accepted_.
|
||||
|
||||
---
|
||||
|
||||
## 9. Segment Matching
|
||||
|
||||
In this section, all string positions refer to **character** (codepoint) boundaries.
|
||||
Implementations must advance by full codepoints, not by encoding units (e.g., not
|
||||
by individual bytes in UTF-8, nor by individual code units in UTF-16). "Increment
|
||||
_sIdx_" means advance _sIdx_ past the next character (one codepoint). Likewise,
|
||||
"length of _s_" is the number of characters, and _s_\[_sIdx_\] is the character
|
||||
at position _sIdx_.
|
||||
|
||||
The original TypeScript implementation uses ECMAScript regexes without the `u` flag,
|
||||
which operate on UTF-16 code units; a conforming implementation may match on
|
||||
codepoints instead, as the difference is only observable for supplementary-plane
|
||||
characters (U+10000 and above) in filenames.
|
||||
|
||||
**MATCH_SEGMENTS**(_segments_, _s_, _caseSensitive_)
|
||||
|
||||
> 1. Let _segIdx_ be 0.
|
||||
> 2. Let _sIdx_ be 0.
|
||||
> 3. Let _starSegIdx_ be −1.
|
||||
> 4. Let _starSIdx_ be 0.
|
||||
> 5. While _sIdx_ < length of _s_:
|
||||
> 1. If _segIdx_ < length of _segments_, then:
|
||||
> 1. Let _seg_ be _segments_\[_segIdx_\].
|
||||
> 2. If the kind of _seg_ is **SegLiteral**, then:
|
||||
> 1. Let _lit_ be the value of _seg_.
|
||||
> 2. If _sIdx_ + length of _lit_ ≤ length of _s_ and STRINGS_EQUAL(_lit_, _s_\[_sIdx_.._sIdx_+len(_lit_)\], _caseSensitive_) is **true**, then:
|
||||
> 1. Set _sIdx_ to _sIdx_ + length of _lit_.
|
||||
> 2. Increment _segIdx_.
|
||||
> 3. Continue the loop.
|
||||
> 3. If the kind of _seg_ is **SegQuestion**, then:
|
||||
> 1. If _s_\[_sIdx_\] is not `"/"`, then:
|
||||
> 1. Increment _sIdx_.
|
||||
> 2. Increment _segIdx_.
|
||||
> 3. Continue the loop.
|
||||
> 4. If the kind of _seg_ is **SegStar**, then:
|
||||
> 1. Set _starSegIdx_ to _segIdx_.
|
||||
> 2. Set _starSIdx_ to _sIdx_.
|
||||
> 3. Increment _segIdx_.
|
||||
> 4. Continue the loop.
|
||||
> 2. If _starSegIdx_ ≥ 0 and _starSIdx_ < length of _s_ and _s_\[_starSIdx_\] is not `"/"`, then:
|
||||
> 1. Increment _starSIdx_.
|
||||
> 2. Set _sIdx_ to _starSIdx_.
|
||||
> 3. Set _segIdx_ to _starSegIdx_ + 1.
|
||||
> 4. Continue the loop.
|
||||
> 3. Return **false**.
|
||||
> 6. While _segIdx_ < length of _segments_ and the kind of _segments_\[_segIdx_\] is **SegStar**:
|
||||
> 1. Increment _segIdx_.
|
||||
> 7. Return _segIdx_ ≥ length of _segments_.
|
||||
|
||||
---
|
||||
|
||||
## 10. `.min.js` Default Exclusion
|
||||
|
||||
**SHOULD_ACCEPT_MIN_JS**(_pattern_, _segments_, _filename_)
|
||||
|
||||
> 1. Let _usage_ be the usage mode of _pattern_.
|
||||
> 2. If _usage_ is not **Files**, return **true**.
|
||||
> 3. If the result of HAS_MIN_JS_SUFFIX(_filename_, _pattern_.caseSensitive) is **false**, return **true**.
|
||||
> 4. If the result of PATTERN_MENTIONS_MIN_SUFFIX(_segments_, _pattern_.caseSensitive) is **true**, return **true**.
|
||||
> 5. Return **false**.
|
||||
|
||||
**HAS_MIN_JS_SUFFIX**(_filename_, _caseSensitive_)
|
||||
|
||||
> 1. Let _suffix_ be `".min.js"`.
|
||||
> 2. If length of _filename_ < length of _suffix_, return **false**.
|
||||
> 3. Let _tail_ be the last 7 characters of _filename_.
|
||||
> 4. If _caseSensitive_ is **true**, return whether _tail_ equals `".min.js"`.
|
||||
> 5. Return whether _tail_ equals `".min.js"` under Unicode case folding.
|
||||
|
||||
**PATTERN_MENTIONS_MIN_SUFFIX**(_segments_, _caseSensitive_)
|
||||
|
||||
> 1. For each _seg_ in _segments_:
|
||||
> 1. If the kind of _seg_ is not **SegLiteral**, continue.
|
||||
> 2. Let _lit_ be the value of _seg_.
|
||||
> 3. If _caseSensitive_ is **false**, let _lit_ be the lowercase form of _lit_.
|
||||
> 4. If _lit_ contains the substring `".min.js"` or `".min."`, return **true**.
|
||||
> 2. Return **false**.
|
||||
|
||||
---
|
||||
|
||||
## 11. Composite Matchers
|
||||
|
||||
**MATCH_FILE**(_path_, _includePatterns_, _excludePatterns_, _hadIncludes_)
|
||||
|
||||
> 1. For each _pattern_ in _excludePatterns_:
|
||||
> 1. If the result of MATCH_PATH(_pattern_, _path_) is **true**, return (0, **false**).
|
||||
> 2. If length of _includePatterns_ is 0, then:
|
||||
> 1. If _hadIncludes_ is **true**, return (0, **false**).
|
||||
> 2. Return (0, **true**).
|
||||
> 3. For each index _i_ from 0 to length of _includePatterns_ − 1:
|
||||
> 1. If the result of MATCH_PATH(_includePatterns_\[_i_\], _path_) is **true**, return (_i_, **true**).
|
||||
> 4. Return (0, **false**).
|
||||
|
||||
**MATCH_DIRECTORY**(_path_, _includePatterns_, _excludePatterns_, _hadIncludes_)
|
||||
|
||||
> 1. For each _pattern_ in _excludePatterns_:
|
||||
> 1. If the result of MATCH_PATH(_pattern_, _path_) is **true**, return **false**.
|
||||
> 2. If length of _includePatterns_ is 0, then:
|
||||
> 1. If _hadIncludes_ is **true**, return **false**.
|
||||
> 2. Return **true**.
|
||||
> 3. For each _pattern_ in _includePatterns_:
|
||||
> 1. If the result of MATCH_PATH_PREFIX(_pattern_, _path_) is **true**, return **true**.
|
||||
> 4. Return **false**.
|
||||
|
||||
**MATCH_SPEC**(_patterns_, _path_)
|
||||
|
||||
> 1. For each _pattern_ in _patterns_:
|
||||
> 1. If the result of MATCH_PATH(_pattern_, _path_) is **true**, return **true**.
|
||||
> 2. Return **false**.
|
||||
|
||||
**MATCH_SPEC_INDEX**(_patterns_, _path_)
|
||||
|
||||
> 1. For each index _i_ from 0 to length of _patterns_ − 1:
|
||||
> 1. If the result of MATCH_PATH(_patterns_\[_i_\], _path_) is **true**, return _i_.
|
||||
> 2. Return −1.
|
||||
|
||||
---
|
||||
|
||||
## 12. Pattern Set Compilation
|
||||
|
||||
**COMPILE_PATTERNS**(_specs_, _basePath_, _usage_, _caseSensitive_)
|
||||
|
||||
> 1. Let _patterns_ be an empty list.
|
||||
> 2. For each _spec_ in _specs_:
|
||||
> 1. Let _result_ be the result of COMPILE_PATTERN(_spec_, _basePath_, _usage_, _caseSensitive_).
|
||||
> 2. If _result_ is not **failure**, append _result_ to _patterns_.
|
||||
> 3. Return _patterns_.
|
||||
|
||||
**COMPILE_FILE_MATCHER**(_includeSpecs_, _excludeSpecs_, _basePath_, _caseSensitive_)
|
||||
|
||||
> 1. Let _includePatterns_ be the result of COMPILE_PATTERNS(_includeSpecs_, _basePath_, **Files**, _caseSensitive_).
|
||||
> 2. Let _excludePatterns_ be the result of COMPILE_PATTERNS(_excludeSpecs_, _basePath_, **Exclude**, _caseSensitive_).
|
||||
> 3. Let _hadIncludes_ be whether length of _includeSpecs_ > 0.
|
||||
> 4. Return (_includePatterns_, _excludePatterns_, _hadIncludes_).
|
||||
|
||||
**COMPILE_DIRECTORY_MATCHER**(_includeSpecs_, _excludeSpecs_, _basePath_, _caseSensitive_)
|
||||
|
||||
> 1. Let _includePatterns_ be the result of COMPILE_PATTERNS(_includeSpecs_, _basePath_, **Directories**, _caseSensitive_).
|
||||
> 2. Let _excludePatterns_ be the result of COMPILE_PATTERNS(_excludeSpecs_, _basePath_, **Exclude**, _caseSensitive_).
|
||||
> 3. Let _hadIncludes_ be whether length of _includeSpecs_ > 0.
|
||||
> 4. Return (_includePatterns_, _excludePatterns_, _hadIncludes_).
|
||||
|
||||
---
|
||||
|
||||
## 13. Base Path Computation
|
||||
|
||||
**GET_BASE_PATHS**(_rootPath_, _includeSpecs_, _caseSensitive_)
|
||||
|
||||
> 1. Let _basePaths_ be a list containing _rootPath_.
|
||||
> 2. If _includeSpecs_ is empty, return _basePaths_.
|
||||
> 3. Let _includeBasePaths_ be an empty list.
|
||||
> 4. For each _spec_ in _includeSpecs_:
|
||||
> 1. Let _absolute_ be the result of resolving _spec_ to an absolute normalized path against _rootPath_.
|
||||
> 2. Let _basePath_ be GET_INCLUDE_BASE_PATH(_absolute_).
|
||||
> 3. Append _basePath_ to _includeBasePaths_.
|
||||
> 5. Sort _includeBasePaths_ using a string comparator that is case-insensitive if _caseSensitive_ is **false**.
|
||||
> 6. For each _candidate_ in _includeBasePaths_:
|
||||
> 1. If no element of _basePaths_ is a path-prefix of _candidate_ (respecting _caseSensitive_), append _candidate_ to _basePaths_.
|
||||
> 7. Return _basePaths_.
|
||||
|
||||
**GET_INCLUDE_BASE_PATH**(_absoluteSpec_)
|
||||
|
||||
> 1. Let _wildcardOffset_ be the index of the first `"*"` or `"?"` character in _absoluteSpec_.
|
||||
> 2. If _wildcardOffset_ < 0, then:
|
||||
> 1. If _absoluteSpec_ has a file extension (contains `"."`), return the parent directory of _absoluteSpec_.
|
||||
> 2. Return _absoluteSpec_.
|
||||
> 3. Return the substring of _absoluteSpec_ up to and including the last `"/"` before _wildcardOffset_.
|
||||
|
||||
---
|
||||
|
||||
## 14. Directory Traversal
|
||||
|
||||
**READ_DIRECTORY**(_host_, _currentDir_, _path_, _extensions_, _excludeSpecs_, _includeSpecs_, _caseSensitive_, _depth_)
|
||||
|
||||
The _host_ must provide the following operations:
|
||||
- **Realpath**(_path_) — Resolves symlinks and returns the canonical absolute path.
|
||||
- **GetAccessibleEntries**(_path_) — Returns the sorted lists of files and subdirectories in the directory at _path_.
|
||||
|
||||
> 1. Let _path_ be the normalized form of _path_.
|
||||
> 2. Let _currentDir_ be the normalized form of _currentDir_.
|
||||
> 3. Let _absolutePath_ be the concatenation of _currentDir_, `"/"`, and _path_ (normalized).
|
||||
> 4. Let (_fileIncludes_, _fileExcludes_, _fileHadIncludes_) be the result of COMPILE_FILE_MATCHER(_includeSpecs_, _excludeSpecs_, _absolutePath_, _caseSensitive_).
|
||||
> 5. Let (_dirIncludes_, _dirExcludes_, _dirHadIncludes_) be the result of COMPILE_DIRECTORY_MATCHER(_includeSpecs_, _excludeSpecs_, _absolutePath_, _caseSensitive_).
|
||||
> 6. Let _resultBuckets_ be a list of empty lists, with length equal to max(length of _fileIncludes_, 1).
|
||||
> 7. Let _visited_ be an empty set of strings.
|
||||
> 8. Let _basePaths_ be the result of GET_BASE_PATHS(_path_, _includeSpecs_, _caseSensitive_).
|
||||
> 9. For each _basePath_ in _basePaths_:
|
||||
> 1. Let _baseAbsolute_ be the concatenation of _currentDir_, `"/"`, and _basePath_ (normalized).
|
||||
> 2. Perform VISIT(_host_, _basePath_, _baseAbsolute_, _depth_, _extensions_, _fileIncludes_, _fileExcludes_, _fileHadIncludes_, _dirIncludes_, _dirExcludes_, _dirHadIncludes_, _caseSensitive_, _visited_, _resultBuckets_).
|
||||
> 10. Return the concatenation of all lists in _resultBuckets_, in order.
|
||||
|
||||
**VISIT**(_host_, _path_, _absolutePath_, _depth_, _extensions_, _fileIncludes_, _fileExcludes_, _fileHadIncludes_, _dirIncludes_, _dirExcludes_, _dirHadIncludes_, _caseSensitive_, _visited_, _resultBuckets_)
|
||||
|
||||
> 1. Let _realPath_ be the result of _host_.Realpath(_absolutePath_).
|
||||
> 2. Let _canonicalPath_ be the canonical form of _realPath_ under the file system's case-sensitivity rules.
|
||||
> 3. If _visited_ contains _canonicalPath_, return.
|
||||
> 4. Add _canonicalPath_ to _visited_.
|
||||
> 5. Let _entries_ be the result of _host_.GetAccessibleEntries(_absolutePath_).
|
||||
> 6. Let _absPrefix_ be ENSURE_TRAILING_SLASH(_absolutePath_).
|
||||
> 7. Let _pathPrefix_ be ENSURE_TRAILING_SLASH(_path_).
|
||||
> 8. For each _file_ in _entries_.files:
|
||||
> 1. If _extensions_ is non-empty and the file extension of _file_ is not in _extensions_, continue.
|
||||
> 2. Let _absFile_ be _absPrefix_ concatenated with _file_.
|
||||
> 3. Let (_index_, _matched_) be the result of MATCH_FILE(_absFile_, _fileIncludes_, _fileExcludes_, _fileHadIncludes_).
|
||||
> 4. If _matched_ is **true**, append _pathPrefix_ concatenated with _file_ to _resultBuckets_\[_index_\].
|
||||
> 9. If _depth_ is finite (i.e., not the sentinel value representing unlimited depth), then:
|
||||
> 1. Decrement _depth_.
|
||||
> 2. If _depth_ is 0, return.
|
||||
> 10. For each _dir_ in _entries_.directories:
|
||||
> 1. Let _absDir_ be _absPrefix_ concatenated with _dir_.
|
||||
> 2. If the result of MATCH_DIRECTORY(_absDir_, _dirIncludes_, _dirExcludes_, _dirHadIncludes_) is **false**, continue.
|
||||
> 3. Perform VISIT(_host_, _pathPrefix_ concatenated with _dir_, _absDir_, _depth_, _extensions_, _fileIncludes_, _fileExcludes_, _fileHadIncludes_, _dirIncludes_, _dirExcludes_, _dirHadIncludes_, _caseSensitive_, _visited_, _resultBuckets_).
|
||||
|
||||
---
|
||||
|
||||
## 15. Invariants
|
||||
|
||||
The following properties hold for all conforming implementations:
|
||||
|
||||
1. COMPILE_PATTERN returns **failure** for any include or directory spec whose last component is `"**"`.
|
||||
2. When the pattern is exhausted but path components remain, exclude patterns return **true** and all other patterns return **false**.
|
||||
3. MATCH_SEGMENTS is guaranteed **O(n·m)** where _n_ is the string length and _m_ is the segment count.
|
||||
4. The `.min.js` default exclusion applies only under **Files** usage mode and only to wildcard components.
|
||||
5. Symlink cycles are detected through real-path canonicalization in VISIT and cause the directory to be skipped.
|
||||
6. Excludes are always evaluated before includes in MATCH_FILE and MATCH_DIRECTORY.
|
||||
7. For include patterns, wildcard components reject package folders; literal components do not.
|
||||
8. For include patterns, `**` does not descend into hidden paths or package folders.
|
||||
9. For include patterns, a wildcard component whose first segment is **SegStar** or **SegQuestion** does not match hidden path components.
|
||||
233
tools/tsgo/internal/vfs/vfsmatch/bench_test.go
Normal file
233
tools/tsgo/internal/vfs/vfsmatch/bench_test.go
Normal file
@@ -0,0 +1,233 @@
|
||||
package vfsmatch
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/microsoft/typescript-go/internal/vfs"
|
||||
"github.com/microsoft/typescript-go/internal/vfs/cachedvfs"
|
||||
"github.com/microsoft/typescript-go/internal/vfs/vfstest"
|
||||
)
|
||||
|
||||
// Benchmark test cases using the same hosts as the unit tests
|
||||
|
||||
func BenchmarkReadDirectory(b *testing.B) {
|
||||
benchCases := []struct {
|
||||
name string
|
||||
host func() vfs.FS
|
||||
path string
|
||||
extensions []string
|
||||
excludes []string
|
||||
includes []string
|
||||
}{
|
||||
{
|
||||
name: "LiteralIncludes",
|
||||
host: caseInsensitiveHost,
|
||||
path: "/dev",
|
||||
extensions: []string{".ts", ".tsx", ".d.ts"},
|
||||
includes: []string{"a.ts", "b.ts"},
|
||||
},
|
||||
{
|
||||
name: "WildcardIncludes",
|
||||
host: caseInsensitiveHost,
|
||||
path: "/dev",
|
||||
extensions: []string{".ts", ".tsx", ".d.ts"},
|
||||
includes: []string{"z/*.ts", "x/*.ts"},
|
||||
},
|
||||
{
|
||||
name: "RecursiveWildcard",
|
||||
host: caseInsensitiveHost,
|
||||
path: "/dev",
|
||||
extensions: []string{".ts", ".tsx", ".d.ts"},
|
||||
includes: []string{"**/a.ts"},
|
||||
},
|
||||
{
|
||||
name: "RecursiveWithExcludes",
|
||||
host: caseInsensitiveHost,
|
||||
path: "/dev",
|
||||
extensions: []string{".ts", ".tsx", ".d.ts"},
|
||||
excludes: []string{"**/b.ts"},
|
||||
includes: []string{"**/*.ts"},
|
||||
},
|
||||
{
|
||||
name: "ComplexPattern",
|
||||
host: caseInsensitiveHost,
|
||||
path: "/dev",
|
||||
extensions: []string{".ts", ".tsx", ".d.ts"},
|
||||
excludes: []string{"*.ts", "z/??z.ts", "*/b.ts"},
|
||||
includes: []string{"a.ts", "b.ts", "z/a.ts", "z/abz.ts", "z/aba.ts", "x/b.ts"},
|
||||
},
|
||||
{
|
||||
name: "DottedFolders",
|
||||
host: dottedFoldersHost,
|
||||
path: "/dev",
|
||||
extensions: []string{".ts", ".tsx", ".d.ts"},
|
||||
includes: []string{"**/.*/*"},
|
||||
},
|
||||
{
|
||||
name: "CommonPackageFolders",
|
||||
host: commonFoldersHost,
|
||||
path: "/dev",
|
||||
extensions: []string{".ts", ".tsx", ".d.ts"},
|
||||
includes: []string{"**/a.ts"},
|
||||
},
|
||||
{
|
||||
name: "NoIncludes",
|
||||
host: caseInsensitiveHost,
|
||||
path: "/dev",
|
||||
extensions: []string{".ts", ".tsx", ".d.ts"},
|
||||
},
|
||||
{
|
||||
name: "MultipleRecursive",
|
||||
host: caseInsensitiveHost,
|
||||
path: "/dev",
|
||||
extensions: []string{".ts", ".tsx", ".d.ts"},
|
||||
includes: []string{"**/x/**/*"},
|
||||
},
|
||||
{
|
||||
name: "LargeFileSystem",
|
||||
host: largeFileSystemHost,
|
||||
path: "/project",
|
||||
extensions: []string{".ts", ".tsx", ".d.ts"},
|
||||
includes: []string{"src/**/*.ts"},
|
||||
excludes: []string{"**/node_modules/**", "**/*.test.ts"},
|
||||
},
|
||||
{
|
||||
name: "LargeAllFiles",
|
||||
host: largeFileSystemHost,
|
||||
path: "/project",
|
||||
extensions: []string{".ts", ".tsx", ".js"},
|
||||
excludes: []string{"**/node_modules/**"},
|
||||
includes: []string{"**/*"},
|
||||
},
|
||||
}
|
||||
|
||||
for _, bc := range benchCases {
|
||||
b.Run(bc.name, func(b *testing.B) {
|
||||
host := cachedvfs.From(bc.host())
|
||||
b.ReportAllocs()
|
||||
for b.Loop() {
|
||||
matchFiles(bc.path, bc.extensions, bc.excludes, bc.includes, host.UseCaseSensitiveFileNames(), "/", UnlimitedDepth, host)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// largeFileSystemHost creates a more realistic file system with many files
|
||||
func largeFileSystemHost() vfs.FS {
|
||||
files := make(map[string]string)
|
||||
|
||||
// Create a realistic project structure
|
||||
dirs := []string{
|
||||
"/project/src",
|
||||
"/project/src/components",
|
||||
"/project/src/utils",
|
||||
"/project/src/services",
|
||||
"/project/src/models",
|
||||
"/project/src/hooks",
|
||||
"/project/test",
|
||||
"/project/node_modules/react",
|
||||
"/project/node_modules/typescript",
|
||||
"/project/node_modules/@types/node",
|
||||
}
|
||||
|
||||
// Add files to each directory
|
||||
for _, dir := range dirs {
|
||||
for j := range 20 {
|
||||
files[dir+"/file"+string(rune('a'+j))+".ts"] = ""
|
||||
files[dir+"/file"+string(rune('a'+j))+".test.ts"] = ""
|
||||
}
|
||||
}
|
||||
|
||||
// Add some dotted directories
|
||||
files["/project/src/.hidden/secret.ts"] = ""
|
||||
files["/project/.config/settings.ts"] = ""
|
||||
|
||||
return vfstest.FromMap(files, false)
|
||||
}
|
||||
|
||||
// BenchmarkPatternCompilation benchmarks the pattern compilation step
|
||||
func BenchmarkPatternCompilation(b *testing.B) {
|
||||
patterns := []struct {
|
||||
name string
|
||||
spec string
|
||||
}{
|
||||
{"Literal", "src/file.ts"},
|
||||
{"SingleWildcard", "src/*.ts"},
|
||||
{"QuestionMark", "src/?.ts"},
|
||||
{"DoubleAsterisk", "**/file.ts"},
|
||||
{"Complex", "src/**/components/*.tsx"},
|
||||
{"DottedPattern", "**/.*/*"},
|
||||
}
|
||||
|
||||
for _, p := range patterns {
|
||||
b.Run(p.name, func(b *testing.B) {
|
||||
for b.Loop() {
|
||||
_, _ = compileGlobPattern(p.spec, "/project", UsageFiles, true)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// BenchmarkPatternMatching benchmarks pattern matching against paths
|
||||
func BenchmarkPatternMatching(b *testing.B) {
|
||||
testCases := []struct {
|
||||
name string
|
||||
spec string
|
||||
paths []string
|
||||
}{
|
||||
{
|
||||
name: "LiteralMatch",
|
||||
spec: "src/file.ts",
|
||||
paths: []string{
|
||||
"/project/src/file.ts",
|
||||
"/project/src/other.ts",
|
||||
"/project/lib/file.ts",
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "WildcardMatch",
|
||||
spec: "src/*.ts",
|
||||
paths: []string{
|
||||
"/project/src/file.ts",
|
||||
"/project/src/component.ts",
|
||||
"/project/src/deep/file.ts",
|
||||
"/project/lib/file.ts",
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "RecursiveMatch",
|
||||
spec: "**/file.ts",
|
||||
paths: []string{
|
||||
"/project/file.ts",
|
||||
"/project/src/file.ts",
|
||||
"/project/src/deep/nested/file.ts",
|
||||
"/project/src/other.ts",
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "ComplexMatch",
|
||||
spec: "src/**/components/*.tsx",
|
||||
paths: []string{
|
||||
"/project/src/components/Button.tsx",
|
||||
"/project/src/features/auth/components/Login.tsx",
|
||||
"/project/src/components/Button.ts",
|
||||
"/project/lib/components/Button.tsx",
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
for _, tc := range testCases {
|
||||
pattern, ok := compileGlobPattern(tc.spec, "/project", UsageFiles, true)
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
|
||||
b.Run(tc.name, func(b *testing.B) {
|
||||
for b.Loop() {
|
||||
for _, path := range tc.paths {
|
||||
pattern.matches(path)
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
26
tools/tsgo/internal/vfs/vfsmatch/stringer_generated.go
Normal file
26
tools/tsgo/internal/vfs/vfsmatch/stringer_generated.go
Normal file
@@ -0,0 +1,26 @@
|
||||
// Code generated by "stringer -type=Usage -trimprefix=Usage -output=stringer_generated.go"; DO NOT EDIT.
|
||||
|
||||
package vfsmatch
|
||||
|
||||
import "strconv"
|
||||
|
||||
func _() {
|
||||
// An "invalid array index" compiler error signifies that the constant values have changed.
|
||||
// Re-run the stringer command to generate them again.
|
||||
var x [1]struct{}
|
||||
_ = x[UsageFiles-0]
|
||||
_ = x[UsageDirectories-1]
|
||||
_ = x[UsageExclude-2]
|
||||
}
|
||||
|
||||
const _Usage_name = "FilesDirectoriesExclude"
|
||||
|
||||
var _Usage_index = [...]uint8{0, 5, 16, 23}
|
||||
|
||||
func (i Usage) String() string {
|
||||
idx := int(i) - 0
|
||||
if i < 0 || idx >= len(_Usage_index)-1 {
|
||||
return "Usage(" + strconv.FormatInt(int64(i), 10) + ")"
|
||||
}
|
||||
return _Usage_name[_Usage_index[idx]:_Usage_index[idx+1]]
|
||||
}
|
||||
717
tools/tsgo/internal/vfs/vfsmatch/vfsmatch.go
Normal file
717
tools/tsgo/internal/vfs/vfsmatch/vfsmatch.go
Normal file
@@ -0,0 +1,717 @@
|
||||
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}
|
||||
}
|
||||
1938
tools/tsgo/internal/vfs/vfsmatch/vfsmatch_test.go
Normal file
1938
tools/tsgo/internal/vfs/vfsmatch/vfsmatch_test.go
Normal file
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user