Files
wehub-resource-sync 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
chore: import upstream snapshot with attribution
2026-07-13 12:33:42 +08:00

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
}