a06f331eb8
CI / benchmark (push) Has been skipped
install-script / posix-syntax (push) Successful in 6m1s
CI / build-onnx (push) Failing after 6m43s
init-smoke / dry-run (push) Failing after 15m57s
security / govulncheck (push) Has been cancelled
security / trivy-fs (push) Has been cancelled
CI / test (1.26, ubuntu-latest) (push) Has been cancelled
Scorecard supply-chain security / Scorecard analysis (push) Has been cancelled
CI / test (1.26, macos-latest) (push) Has been cancelled
CI / build-windows (push) Has been cancelled
CI / lint (push) Has been cancelled
install-script / powershell-syntax (push) Has been cancelled
install-script / install (macos-14) (push) Has been cancelled
install-script / install (ubuntu-latest) (push) Has been cancelled
476 lines
14 KiB
Go
476 lines
14 KiB
Go
// Package tsalias resolves TypeScript / JavaScript path-alias imports
|
|
// declared in `tsconfig.json` / `jsconfig.json` to repo-relative file
|
|
// paths the rest of the indexer can consume.
|
|
//
|
|
// Recognised shape:
|
|
//
|
|
// {
|
|
// "compilerOptions": {
|
|
// "baseUrl": "./src",
|
|
// "paths": {
|
|
// "@/*": ["lib/*"],
|
|
// "@components/*": ["src/components/*"],
|
|
// "$utils": ["src/util/index.ts"]
|
|
// }
|
|
// }
|
|
// }
|
|
//
|
|
// Resolution semantics follow the tsserver / Vite / Webpack consensus:
|
|
//
|
|
// - Entries are matched longest-prefix-first so `@components/Button`
|
|
// matches `@components/*` ahead of a hypothetical `@/*`.
|
|
// - A single `*` wildcard splits the pattern into a prefix and a
|
|
// suffix; the substring matched by `*` is slotted into the target
|
|
// at the corresponding `*` position.
|
|
// - Patterns without `*` are exact-match.
|
|
// - Targets are joined with `baseUrl` (if set) and returned without
|
|
// the trailing `.ts/.tsx/.js/.jsx/.mts/.cts` extension — callers
|
|
// reuse the same probing logic as relative imports.
|
|
// - Multi-target arrays (`"@/*": ["a/*", "b/*"]`) are resolved by disk
|
|
// existence: each candidate is probed under the repo root in priority
|
|
// order and the first that exists on disk wins, falling back to the
|
|
// first entry (the documented "primary" path) when none do.
|
|
//
|
|
// tsconfig JSONC features the TypeScript tooling itself accepts — `//` and
|
|
// `/* */` comments and trailing commas — are stripped before parsing, so a
|
|
// commented config does not silently drop every alias for the repo. The
|
|
// package still does not follow `extends:` chains or monorepo `references[]`
|
|
// traversal; those can be layered on without touching the resolver API.
|
|
package tsalias
|
|
|
|
import (
|
|
"encoding/json"
|
|
"errors"
|
|
"io/fs"
|
|
"os"
|
|
"path"
|
|
"path/filepath"
|
|
"sort"
|
|
"strings"
|
|
)
|
|
|
|
// Alias is one entry in the `paths` map.
|
|
type Alias struct {
|
|
// AliasPrefix is the portion of the source pattern before `*`
|
|
// (or the full pattern when HasWildcard is false).
|
|
AliasPrefix string
|
|
// AliasSuffix is the portion after `*`. Usually empty.
|
|
AliasSuffix string
|
|
// TargetPrefix / TargetSuffix split the primary (first) resolved value
|
|
// the same way. Retained as the single-target fast path and so an
|
|
// externally hand-built Alias keeps working.
|
|
TargetPrefix string
|
|
TargetSuffix string
|
|
// Targets is every replacement value for this pattern, in tsconfig
|
|
// priority order, each split into prefix/suffix. When non-empty and a
|
|
// repo root is known, Resolve probes them on disk and returns the first
|
|
// that exists, falling back to Targets[0] — so a multi-target alias
|
|
// lands on the file that actually exists, not blindly on the first
|
|
// entry. Empty for a hand-built single-target Alias (TargetPrefix wins).
|
|
Targets []AliasTarget
|
|
HasWildcard bool
|
|
}
|
|
|
|
// AliasTarget is one replacement value split around its `*`.
|
|
type AliasTarget struct {
|
|
Prefix string
|
|
Suffix string
|
|
}
|
|
|
|
// Map is the alias set declared by one tsconfig/jsconfig file.
|
|
type Map struct {
|
|
Entries []Alias
|
|
// BaseURL is the relative path the targets resolve against. Empty
|
|
// when the config didn't declare one — callers should treat
|
|
// targets as repo-relative in that case.
|
|
BaseURL string
|
|
// DirPrefix is the repo-relative path of the config file's
|
|
// directory. Used by Collection to pick the nearest ancestor scope.
|
|
DirPrefix string
|
|
// repoRoot is the absolute repository root, set by Load, used to
|
|
// disk-probe multi-target aliases. Empty for hand-built maps, which
|
|
// then resolve by first-match without touching the filesystem.
|
|
repoRoot string
|
|
}
|
|
|
|
// Collection aggregates every alias map found by Load, sorted by
|
|
// DirPrefix length descending so nearest-ancestor lookup is a single
|
|
// linear scan.
|
|
type Collection struct {
|
|
scopes []*Map
|
|
}
|
|
|
|
// Maps returns the underlying scope slice. Test-visibility only.
|
|
func (c *Collection) Maps() []*Map { return c.scopes }
|
|
|
|
// FindForFile returns the alias map for the nearest ancestor scope of
|
|
// relPath, or nil when no scope applies.
|
|
func (c *Collection) FindForFile(relPath string) *Map {
|
|
if c == nil {
|
|
return nil
|
|
}
|
|
relPath = filepath.ToSlash(relPath)
|
|
for _, m := range c.scopes {
|
|
if m.DirPrefix == "" {
|
|
return m
|
|
}
|
|
if relPath == m.DirPrefix || strings.HasPrefix(relPath, m.DirPrefix+"/") {
|
|
return m
|
|
}
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// Resolve maps modulePath against m's aliases and returns the
|
|
// repo-relative target (extension stripped) or "" when no entry
|
|
// matches. The returned path is forward-slashed and rooted at the
|
|
// repository root.
|
|
func Resolve(m *Map, modulePath string) string {
|
|
if m == nil || modulePath == "" {
|
|
return ""
|
|
}
|
|
for i := range m.Entries {
|
|
a := &m.Entries[i]
|
|
star, ok := matchAlias(a, modulePath)
|
|
if !ok {
|
|
continue
|
|
}
|
|
targets := a.Targets
|
|
if len(targets) == 0 {
|
|
targets = []AliasTarget{{Prefix: a.TargetPrefix, Suffix: a.TargetSuffix}}
|
|
}
|
|
first := ""
|
|
for ti, tgt := range targets {
|
|
joined := m.joinTarget(tgt.Prefix + star + tgt.Suffix)
|
|
stripped := stripExt(joined)
|
|
if ti == 0 {
|
|
first = stripped
|
|
}
|
|
// Disk-grounded multi-target: return the first candidate that
|
|
// actually exists. Skipped for hand-built maps (no repoRoot),
|
|
// which fall through to the documented primary path.
|
|
if m.repoRoot != "" && targetExistsOnDisk(m.repoRoot, joined) {
|
|
return stripped
|
|
}
|
|
}
|
|
return first
|
|
}
|
|
return ""
|
|
}
|
|
|
|
// matchAlias reports whether modulePath matches a and, when it does, returns
|
|
// the substring captured by the `*` wildcard ("" for an exact pattern).
|
|
func matchAlias(a *Alias, modulePath string) (string, bool) {
|
|
if a.HasWildcard {
|
|
if len(modulePath) < len(a.AliasPrefix)+len(a.AliasSuffix) {
|
|
return "", false
|
|
}
|
|
if !strings.HasPrefix(modulePath, a.AliasPrefix) {
|
|
return "", false
|
|
}
|
|
if !strings.HasSuffix(modulePath, a.AliasSuffix) {
|
|
return "", false
|
|
}
|
|
return modulePath[len(a.AliasPrefix) : len(modulePath)-len(a.AliasSuffix)], true
|
|
}
|
|
if modulePath != a.AliasPrefix {
|
|
return "", false
|
|
}
|
|
return "", true
|
|
}
|
|
|
|
// joinTarget resolves a filled-in target against the config's BaseURL and
|
|
// DirPrefix, returning a forward-slashed repo-relative path. The result is
|
|
// always path-cleaned: a tsconfig written without a baseUrl (legal since
|
|
// TS 4.1) keeps its targets verbatim (`"zustand": ["./src/index.ts"]`),
|
|
// and an uncleaned `./` prefix would never match a graph file node, so
|
|
// every paths-alias import in such a repo silently failed to resolve.
|
|
func (m *Map) joinTarget(matched string) string {
|
|
joined := matched
|
|
if m.BaseURL != "" {
|
|
joined = filepath.ToSlash(filepath.Join(m.BaseURL, matched))
|
|
}
|
|
if m.DirPrefix != "" {
|
|
joined = filepath.ToSlash(filepath.Join(m.DirPrefix, joined))
|
|
}
|
|
if joined == "" {
|
|
return ""
|
|
}
|
|
return path.Clean(joined)
|
|
}
|
|
|
|
// probeExts are the source extensions a path-alias target may resolve to,
|
|
// matching stripExt's set plus declaration / json / index-file forms.
|
|
var probeExts = []string{".ts", ".tsx", ".d.ts", ".js", ".jsx", ".mts", ".cts", ".mjs", ".cjs", ".json"}
|
|
|
|
// targetExistsOnDisk reports whether the repo-relative joined target resolves
|
|
// to a real file under repoRoot — as an exact path, with any source extension,
|
|
// or as an index file in a directory of that name.
|
|
func targetExistsOnDisk(repoRoot, joined string) bool {
|
|
base := filepath.Join(repoRoot, filepath.FromSlash(joined))
|
|
if fileExists(base) {
|
|
return true
|
|
}
|
|
for _, ext := range probeExts {
|
|
if fileExists(base + ext) {
|
|
return true
|
|
}
|
|
if fileExists(filepath.Join(base, "index"+ext)) {
|
|
return true
|
|
}
|
|
}
|
|
return false
|
|
}
|
|
|
|
func fileExists(p string) bool {
|
|
info, err := os.Stat(p)
|
|
return err == nil && !info.IsDir()
|
|
}
|
|
|
|
func stripExt(p string) string {
|
|
switch ext := filepath.Ext(p); ext {
|
|
case ".ts", ".tsx", ".js", ".jsx", ".mts", ".cts", ".mjs", ".cjs":
|
|
return strings.TrimSuffix(p, ext)
|
|
}
|
|
return p
|
|
}
|
|
|
|
// Load walks repoRoot for tsconfig.json / jsconfig.json files and
|
|
// returns a Collection ready for FindForFile. Returns nil when the
|
|
// walk finds no usable configs. Walk errors on individual files are
|
|
// logged-by-skipping — a malformed tsconfig must not stop indexing.
|
|
//
|
|
// The walk respects a small allowlist of skip-dirs (node_modules, .git,
|
|
// vendor, build, dist) to keep cost bounded on large monorepos.
|
|
func Load(repoRoot string) *Collection {
|
|
if repoRoot == "" {
|
|
return nil
|
|
}
|
|
var scopes []*Map
|
|
skipDirs := map[string]struct{}{
|
|
"node_modules": {},
|
|
".git": {},
|
|
".hg": {},
|
|
".svn": {},
|
|
"vendor": {},
|
|
"build": {},
|
|
"dist": {},
|
|
"target": {},
|
|
".next": {},
|
|
".nuxt": {},
|
|
}
|
|
|
|
err := filepath.WalkDir(repoRoot, func(p string, d fs.DirEntry, err error) error {
|
|
if err != nil {
|
|
if errors.Is(err, fs.ErrPermission) {
|
|
if d != nil && d.IsDir() {
|
|
return filepath.SkipDir
|
|
}
|
|
return nil
|
|
}
|
|
return nil
|
|
}
|
|
if d.IsDir() {
|
|
if _, skip := skipDirs[d.Name()]; skip {
|
|
return filepath.SkipDir
|
|
}
|
|
return nil
|
|
}
|
|
name := d.Name()
|
|
if name != "tsconfig.json" && name != "jsconfig.json" {
|
|
return nil
|
|
}
|
|
rel, relErr := filepath.Rel(repoRoot, p)
|
|
if relErr != nil {
|
|
return nil
|
|
}
|
|
dirRel := filepath.ToSlash(filepath.Dir(rel))
|
|
if dirRel == "." {
|
|
dirRel = ""
|
|
}
|
|
if m := parseConfigFile(p, dirRel, repoRoot); m != nil {
|
|
scopes = append(scopes, m)
|
|
}
|
|
return nil
|
|
})
|
|
if err != nil || len(scopes) == 0 {
|
|
return nil
|
|
}
|
|
sort.SliceStable(scopes, func(i, j int) bool {
|
|
return len(scopes[i].DirPrefix) > len(scopes[j].DirPrefix)
|
|
})
|
|
return &Collection{scopes: scopes}
|
|
}
|
|
|
|
func parseConfigFile(absPath, dirPrefix, repoRoot string) *Map {
|
|
data, err := os.ReadFile(absPath)
|
|
if err != nil {
|
|
return nil
|
|
}
|
|
var raw struct {
|
|
CompilerOptions struct {
|
|
BaseURL string `json:"baseUrl"`
|
|
Paths map[string][]string `json:"paths"`
|
|
} `json:"compilerOptions"`
|
|
}
|
|
// tsconfig files routinely use the JSONC features tsserver itself
|
|
// accepts — // and /* */ comments and trailing commas. A strict JSON
|
|
// parse fails the WHOLE file on any of them, silently dropping every
|
|
// alias for the repo, so strip JSONC first.
|
|
if err := json.Unmarshal(stripJSONC(data), &raw); err != nil {
|
|
return nil
|
|
}
|
|
co := raw.CompilerOptions
|
|
if co.BaseURL == "" && len(co.Paths) == 0 {
|
|
return nil
|
|
}
|
|
m := &Map{
|
|
BaseURL: filepath.ToSlash(strings.TrimSpace(co.BaseURL)),
|
|
DirPrefix: dirPrefix,
|
|
repoRoot: repoRoot,
|
|
}
|
|
for pattern, targets := range co.Paths {
|
|
if len(targets) == 0 {
|
|
continue
|
|
}
|
|
entry, ok := splitAlias(pattern, targets)
|
|
if !ok {
|
|
continue
|
|
}
|
|
m.Entries = append(m.Entries, entry)
|
|
}
|
|
sort.SliceStable(m.Entries, func(i, j int) bool {
|
|
return len(m.Entries[i].AliasPrefix) > len(m.Entries[j].AliasPrefix)
|
|
})
|
|
return m
|
|
}
|
|
|
|
// splitAlias splits one `paths` entry (pattern → ordered targets) into an
|
|
// Alias. Every target is split around its `*`; the first becomes the primary
|
|
// TargetPrefix/TargetSuffix and all of them populate Targets for disk-probing.
|
|
// Targets whose wildcard arity disagrees with the pattern (tsserver rejects
|
|
// these) are skipped; the entry is dropped only when none remain.
|
|
func splitAlias(pattern string, targets []string) (Alias, bool) {
|
|
pStar := strings.Index(pattern, "*")
|
|
a := Alias{HasWildcard: pStar != -1}
|
|
if a.HasWildcard {
|
|
a.AliasPrefix = pattern[:pStar]
|
|
a.AliasSuffix = pattern[pStar+1:]
|
|
} else {
|
|
a.AliasPrefix = pattern
|
|
}
|
|
for _, target := range targets {
|
|
tStar := strings.Index(target, "*")
|
|
if (pStar == -1) != (tStar == -1) {
|
|
// Mismatched wildcard arity between pattern and this target.
|
|
continue
|
|
}
|
|
var t AliasTarget
|
|
if tStar == -1 {
|
|
t.Prefix = target
|
|
} else {
|
|
t.Prefix = target[:tStar]
|
|
t.Suffix = target[tStar+1:]
|
|
}
|
|
a.Targets = append(a.Targets, t)
|
|
}
|
|
if len(a.Targets) == 0 {
|
|
return Alias{}, false
|
|
}
|
|
a.TargetPrefix = a.Targets[0].Prefix
|
|
a.TargetSuffix = a.Targets[0].Suffix
|
|
return a, true
|
|
}
|
|
|
|
// stripJSONC removes // line comments, /* */ block comments, and trailing
|
|
// commas from a JSON-with-comments byte slice while leaving the contents of
|
|
// string literals (including any comment-like or comma sequences inside them)
|
|
// untouched, so a commented tsconfig parses as JSON instead of failing wholesale.
|
|
func stripJSONC(data []byte) []byte {
|
|
out := make([]byte, 0, len(data))
|
|
inString, escaped := false, false
|
|
for i := 0; i < len(data); i++ {
|
|
c := data[i]
|
|
if inString {
|
|
out = append(out, c)
|
|
switch {
|
|
case escaped:
|
|
escaped = false
|
|
case c == '\\':
|
|
escaped = true
|
|
case c == '"':
|
|
inString = false
|
|
}
|
|
continue
|
|
}
|
|
if c == '"' {
|
|
inString = true
|
|
out = append(out, c)
|
|
continue
|
|
}
|
|
if c == '/' && i+1 < len(data) {
|
|
switch data[i+1] {
|
|
case '/':
|
|
i += 2
|
|
for i < len(data) && data[i] != '\n' {
|
|
i++
|
|
}
|
|
if i < len(data) {
|
|
out = append(out, '\n')
|
|
}
|
|
continue
|
|
case '*':
|
|
i += 2
|
|
for i < len(data) {
|
|
if data[i] == '*' && i+1 < len(data) && data[i+1] == '/' {
|
|
i++
|
|
break
|
|
}
|
|
i++
|
|
}
|
|
continue
|
|
}
|
|
}
|
|
out = append(out, c)
|
|
}
|
|
return stripTrailingCommas(out)
|
|
}
|
|
|
|
// stripTrailingCommas drops any comma whose next non-whitespace byte is a } or
|
|
// ] closer, outside of string literals.
|
|
func stripTrailingCommas(data []byte) []byte {
|
|
out := make([]byte, 0, len(data))
|
|
inString, escaped := false, false
|
|
for i := 0; i < len(data); i++ {
|
|
c := data[i]
|
|
if inString {
|
|
out = append(out, c)
|
|
switch {
|
|
case escaped:
|
|
escaped = false
|
|
case c == '\\':
|
|
escaped = true
|
|
case c == '"':
|
|
inString = false
|
|
}
|
|
continue
|
|
}
|
|
if c == '"' {
|
|
inString = true
|
|
out = append(out, c)
|
|
continue
|
|
}
|
|
if c == ',' {
|
|
j := i + 1
|
|
for j < len(data) && (data[j] == ' ' || data[j] == '\t' || data[j] == '\n' || data[j] == '\r') {
|
|
j++
|
|
}
|
|
if j < len(data) && (data[j] == '}' || data[j] == ']') {
|
|
continue
|
|
}
|
|
}
|
|
out = append(out, c)
|
|
}
|
|
return out
|
|
}
|