forked from colbymchenry/codegraph
-
Notifications
You must be signed in to change notification settings - Fork 0
Expand file tree
/
Copy pathpath-aliases.ts
More file actions
242 lines (224 loc) · 7.82 KB
/
path-aliases.ts
File metadata and controls
242 lines (224 loc) · 7.82 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
/**
* Project-level import-path alias loading.
*
* Reads `compilerOptions.paths` from `tsconfig.json` / `jsconfig.json`
* at the project root and converts the patterns into a form the
* import-resolver can consult.
*
* This is the single biggest blocker to accurate resolution on modern
* JS/TS codebases: aliases like `@/components/Foo` (Next, Nuxt, Nest,
* Vite scaffolds) point into a `paths` map the resolver previously
* ignored — every import through an alias was treated as unresolvable
* unless it happened to match the small hard-coded fallback list.
*
* Scope deliberately small for v1:
* - reads tsconfig.json, then jsconfig.json
* - honours top-level `compilerOptions.baseUrl` and `compilerOptions.paths`
* - supports `*` wildcard (the only TS-supported wildcard)
* - does NOT follow `extends` chains yet (most projects don't need it)
* - does NOT read Vite/webpack/Rollup configs (separate follow-up)
*
* The file is parsed as JSON-with-comments-tolerant — tsconfigs in the
* wild routinely contain `//` and `/* *\/` comments and trailing
* commas, which JSON.parse rejects. We strip those before parsing.
*/
import * as fs from 'fs';
import * as path from 'path';
import { logDebug } from '../errors';
/** A single alias pattern from `compilerOptions.paths`. */
export interface AliasPattern {
/** The literal prefix before `*` (or the whole pattern if no `*`). */
prefix: string;
/** The literal suffix after `*` (almost always empty). */
suffix: string;
/** Whether the pattern contains a `*` wildcard. */
hasWildcard: boolean;
/**
* Replacement templates. When `hasWildcard` is true, `*` in the
* replacement is filled with the captured wildcard portion of the
* import path. Stored relative to {@link AliasMap.baseUrl}.
* tsconfig allows multiple targets per alias (priority order).
*/
replacements: string[];
}
export interface AliasMap {
/** Absolute path. The directory `compilerOptions.paths` is rooted at. */
baseUrl: string;
/**
* Patterns ordered by specificity: longer prefix first, then literal-
* before-wildcard, so the resolver tries the most-specific match.
*/
patterns: AliasPattern[];
}
/**
* Strip JSONC comments + trailing commas so a tsconfig with the usual
* VS Code-style annotations parses cleanly. Walks the source as a
* tiny state machine that tracks string context — the previous
* regex-only version corrupted any URL inside a string value
* (`"baseUrl": "https://cdn.example.com"` had everything after `//`
* truncated).
*/
function stripJsonc(src: string): string {
let out = '';
let i = 0;
let inString = false;
while (i < src.length) {
const ch = src[i]!;
if (inString) {
out += ch;
if (ch === '\\' && i + 1 < src.length) {
out += src[i + 1]!;
i += 2;
continue;
}
if (ch === '"') inString = false;
i++;
continue;
}
if (ch === '"') {
inString = true;
out += ch;
i++;
continue;
}
if (ch === '/' && src[i + 1] === '/') {
while (i < src.length && src[i] !== '\n') i++;
continue;
}
if (ch === '/' && src[i + 1] === '*') {
i += 2;
while (i < src.length && !(src[i] === '*' && src[i + 1] === '/')) i++;
i += 2;
continue;
}
out += ch;
i++;
}
// Trailing commas before } or ] — outside strings, so safe to
// run on the comment-stripped output.
return out.replace(/,(\s*[}\]])/g, '$1');
}
interface RawTsconfig {
compilerOptions?: {
baseUrl?: string;
paths?: Record<string, string[]>;
};
}
function readTsconfigLike(filePath: string): RawTsconfig | null {
try {
const raw = fs.readFileSync(filePath, 'utf-8');
const parsed = JSON.parse(stripJsonc(raw)) as RawTsconfig;
return parsed && typeof parsed === 'object' ? parsed : null;
} catch (err) {
logDebug('path-aliases: failed to parse', { filePath, err: String(err) });
return null;
}
}
function splitWildcard(pattern: string): {
prefix: string;
suffix: string;
hasWildcard: boolean;
} {
const star = pattern.indexOf('*');
if (star === -1) return { prefix: pattern, suffix: '', hasWildcard: false };
return {
prefix: pattern.slice(0, star),
suffix: pattern.slice(star + 1),
hasWildcard: true,
};
}
/**
* Load aliases for `projectRoot`. Returns `null` when no tsconfig /
* jsconfig is present or when the file has no usable `paths`.
*
* Cheap to call repeatedly — caching is the caller's job (the
* resolver does it via {@link aliasCache}).
*/
export function loadProjectAliases(projectRoot: string): AliasMap | null {
const candidates = ['tsconfig.json', 'jsconfig.json'];
let raw: RawTsconfig | null = null;
let usedFile: string | null = null;
for (const name of candidates) {
const p = path.join(projectRoot, name);
if (fs.existsSync(p)) {
raw = readTsconfigLike(p);
if (raw) {
usedFile = name;
break;
}
}
}
if (!raw) return null;
const co = raw.compilerOptions ?? {};
const baseUrlRel = co.baseUrl ?? '.';
const baseUrl = path.resolve(projectRoot, baseUrlRel);
const paths = co.paths;
if (!paths || typeof paths !== 'object') {
// baseUrl alone isn't an "alias" per se; with no paths we'd just
// be redirecting the whole tree. Skip — the existing resolver
// already handles relative imports.
return null;
}
const patterns: AliasPattern[] = [];
for (const [pattern, targets] of Object.entries(paths)) {
if (!Array.isArray(targets) || targets.length === 0) continue;
const filtered = targets.filter((t): t is string => typeof t === 'string');
if (filtered.length === 0) continue;
const { prefix, suffix, hasWildcard } = splitWildcard(pattern);
patterns.push({ prefix, suffix, hasWildcard, replacements: filtered });
}
if (patterns.length === 0) return null;
// Specificity sort: longer prefix first; literal patterns before
// wildcard patterns of the same prefix length. TypeScript itself
// uses a similar "most specific match wins" rule.
patterns.sort((a, b) => {
if (a.prefix.length !== b.prefix.length) return b.prefix.length - a.prefix.length;
if (a.hasWildcard !== b.hasWildcard) return a.hasWildcard ? 1 : -1;
return 0;
});
logDebug('path-aliases loaded', {
file: usedFile,
baseUrl,
patternCount: patterns.length,
});
return { baseUrl, patterns };
}
/**
* Resolve an import path through an {@link AliasMap}. Returns the list
* of candidate filesystem paths (relative to `projectRoot`), in the
* priority order defined by tsconfig (multiple replacements per alias
* are tried in order). Returns `[]` when no alias matches.
*
* Callers still need to try each candidate with the language's
* extension list — this function only does the alias rewrite.
*/
export function applyAliases(
importPath: string,
aliases: AliasMap,
projectRoot: string
): string[] {
for (const pat of aliases.patterns) {
if (!importPath.startsWith(pat.prefix)) continue;
if (pat.suffix && !importPath.endsWith(pat.suffix)) continue;
let captured = '';
if (pat.hasWildcard) {
captured = importPath.slice(pat.prefix.length, importPath.length - pat.suffix.length);
} else if (importPath !== pat.prefix) {
// Literal pattern must match exactly.
continue;
}
const out: string[] = [];
for (const target of pat.replacements) {
const filled = pat.hasWildcard ? target.replace('*', captured) : target;
// baseUrl is absolute; produce a path relative to projectRoot
const absolute = path.resolve(aliases.baseUrl, filled);
const relative = path.relative(projectRoot, absolute);
// Skip if the rewrite escapes the project root (unsafe + can't
// be looked up via the file index anyway).
if (relative.startsWith('..')) continue;
out.push(relative.replace(/\\/g, '/'));
}
return out;
}
return [];
}