codesynapt 0.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +17 -0
- package/LICENSE +686 -0
- package/LICENSES.md +141 -0
- package/README.md +331 -0
- package/electron/main.cjs +2849 -0
- package/electron/plugin-loader.cjs +184 -0
- package/electron/preload.cjs +108 -0
- package/package.json +216 -0
- package/packages/core/bin/codesynapt-mcp.cjs +611 -0
- package/packages/core/bin/codesynapt.cjs +1933 -0
- package/packages/core/legacy.js +300 -0
- package/packages/core/lib/control-server.cjs +1539 -0
- package/packages/core/lib/embedding.cjs +89 -0
- package/packages/core/lib/logger.cjs +63 -0
- package/packages/core/lib/search-cache.cjs +140 -0
- package/packages/core/lib/search-worker.cjs +255 -0
- package/packages/core/lib/search.cjs +211 -0
- package/packages/core/lib/symbol-graph.cjs +402 -0
- package/packages/core/lib/symbol-parser-js.cjs +542 -0
- package/packages/core/lib/symbol-parser-misc.cjs +394 -0
- package/packages/core/lib/symbol-parser-py.cjs +215 -0
- package/packages/core/lib/symbol-parser-treesitter.cjs +658 -0
- package/packages/core/lib/symbol-parser-tsc.cjs +332 -0
- package/packages/core/monorepo.js +310 -0
- package/packages/core/parser.js +2234 -0
- package/packages/core/scanner.js +623 -0
- package/plugin-api/LICENSE +21 -0
- package/plugin-api/README.md +114 -0
- package/plugin-api/docs/01-getting-started.md +197 -0
- package/plugin-api/docs/02-concepts.md +269 -0
- package/plugin-api/docs/api-reference.md +463 -0
- package/plugin-api/docs/troubleshooting.md +332 -0
- package/plugin-api/docs/types/exporter.md +377 -0
- package/plugin-api/docs/types/theme.md +312 -0
- package/plugin-api/examples/hello-world-plugin/README.md +70 -0
- package/plugin-api/examples/hello-world-plugin/main.js +36 -0
- package/plugin-api/examples/hello-world-plugin/manifest.json +12 -0
- package/plugin-api/examples/mermaid-exporter/README.md +125 -0
- package/plugin-api/examples/mermaid-exporter/main.js +58 -0
- package/plugin-api/examples/mermaid-exporter/manifest.json +12 -0
- package/plugin-api/examples/rust-parser/README.md +71 -0
- package/plugin-api/examples/rust-parser/main.js +123 -0
- package/plugin-api/examples/rust-parser/manifest.json +12 -0
- package/plugin-api/examples/sunset-theme/README.md +95 -0
- package/plugin-api/examples/sunset-theme/manifest.json +12 -0
- package/plugin-api/examples/sunset-theme/theme.css +31 -0
- package/plugin-api/package.json +20 -0
- package/plugin-api/types.d.ts +395 -0
- package/public/app.js +6837 -0
- package/public/backend.js +285 -0
- package/public/index.html +647 -0
- package/public/plugin-host.js +321 -0
- package/public/style.css +4359 -0
- package/public/vendor/three.module.js +53044 -0
- package/scripts/competitor-watch.mjs +144 -0
- package/scripts/copy-vendor.js +21 -0
- package/scripts/download-bundled-node.cjs +53 -0
- package/scripts/fuses-after-pack.cjs +34 -0
- package/scripts/license-check.js +119 -0
- package/scripts/perf-test.js +200 -0
- package/server.js +132 -0
|
@@ -0,0 +1,2234 @@
|
|
|
1
|
+
import fs from 'fs'
|
|
2
|
+
import path from 'path'
|
|
3
|
+
import { parse as babelParse } from '@babel/parser'
|
|
4
|
+
import traverseModule from '@babel/traverse'
|
|
5
|
+
const traverse = traverseModule.default || traverseModule
|
|
6
|
+
|
|
7
|
+
// ─── Public API ───────────────────────────────────────────────
|
|
8
|
+
// Returns { imports, routes?, apiCalls? }
|
|
9
|
+
// imports — file-to-file static dependency edges (see resolveImport)
|
|
10
|
+
// routes — server-side route declarations { method, path } in this file
|
|
11
|
+
// apiCalls — client-side HTTP calls { method, url } from this file
|
|
12
|
+
// Routes + apiCalls are used to draw "full-stack" edges between client code
|
|
13
|
+
// that calls an URL and the server file that handles that URL.
|
|
14
|
+
export function parseFile(absPath, content, ext) {
|
|
15
|
+
if (!content) return { imports: [] }
|
|
16
|
+
try {
|
|
17
|
+
let r
|
|
18
|
+
switch (ext) {
|
|
19
|
+
case 'js': case 'jsx': case 'mjs': case 'cjs':
|
|
20
|
+
case 'ts': case 'tsx':
|
|
21
|
+
r = parseJS(content); break
|
|
22
|
+
case 'vue': case 'svelte': case 'astro':
|
|
23
|
+
r = parseComponentFile(content); break
|
|
24
|
+
case 'py': case 'pyw': case 'pyi':
|
|
25
|
+
r = parsePython(content); break
|
|
26
|
+
case 'ipynb': {
|
|
27
|
+
// Jupyter notebook = JSON wrapper. Extract code cells and parse
|
|
28
|
+
// them as Python (typical) / generic (R/Julia/etc). Bypass the
|
|
29
|
+
// shared trailing logic because we MUST source routes/apiCalls/
|
|
30
|
+
// URLs from the extracted code only — the raw JSON contains
|
|
31
|
+
// hundreds of registry URLs from `metadata` that drown out
|
|
32
|
+
// real API hosts.
|
|
33
|
+
const ipy = parseIpynb(content)
|
|
34
|
+
return {
|
|
35
|
+
imports: ipy.imports,
|
|
36
|
+
routes: extractPyRoutes(ipy.codeContent),
|
|
37
|
+
apiCalls: extractPyApiCalls(ipy.codeContent),
|
|
38
|
+
externalUrls: extractExternalUrls(ipy.codeContent),
|
|
39
|
+
dynamicPatterns: detectDynamicPatterns(ipy.codeContent, 'py'),
|
|
40
|
+
envUsage: extractEnvUsage(ipy.codeContent, 'py'),
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
case 'lsp': case 'dcl': case 'lisp': case 'el':
|
|
44
|
+
r = parseLisp(content); break
|
|
45
|
+
case 'css': case 'scss': case 'sass': case 'less': case 'styl':
|
|
46
|
+
r = parseCSS(content); break
|
|
47
|
+
case 'html': case 'htm':
|
|
48
|
+
r = parseHTML(content); break
|
|
49
|
+
case 'md': case 'mdx':
|
|
50
|
+
r = parseMarkdown(content); break
|
|
51
|
+
case 'rs':
|
|
52
|
+
r = parseRust(content); break
|
|
53
|
+
case 'go':
|
|
54
|
+
r = parseGo(content); break
|
|
55
|
+
case 'java': case 'kt':
|
|
56
|
+
r = parseJavaKotlin(content); break
|
|
57
|
+
case 'cs':
|
|
58
|
+
r = parseCSharp(content); break
|
|
59
|
+
case 'swift':
|
|
60
|
+
r = parseSwift(content); break
|
|
61
|
+
case 'dart':
|
|
62
|
+
r = parseDart(content); break
|
|
63
|
+
case 'c': case 'cc': case 'cpp': case 'h': case 'hpp':
|
|
64
|
+
r = parseC(content); break
|
|
65
|
+
case 'rb':
|
|
66
|
+
r = parseRuby(content); break
|
|
67
|
+
case 'php':
|
|
68
|
+
r = parsePHP(content); break
|
|
69
|
+
case 'sh': case 'bash': case 'zsh':
|
|
70
|
+
r = parseShell(content); break
|
|
71
|
+
case 'ps1':
|
|
72
|
+
r = parsePS1(content); break
|
|
73
|
+
case 'clj': case 'scm':
|
|
74
|
+
r = parseClojure(content); break
|
|
75
|
+
case 'rst':
|
|
76
|
+
r = parseRst(content); break
|
|
77
|
+
case 'prisma':
|
|
78
|
+
// Prisma schema file: no imports we'd track, but we DO want
|
|
79
|
+
// db model extraction below. Return empty imports.
|
|
80
|
+
r = { imports: [] }; break
|
|
81
|
+
case 'json': case 'yaml': case 'yml': case 'toml': case 'xml': case 'sql':
|
|
82
|
+
// Skip URL grep on these — package-lock.json and YAML lockfiles
|
|
83
|
+
// contain hundreds of registry URLs that drown out real API hosts.
|
|
84
|
+
return { imports: [] }
|
|
85
|
+
default: {
|
|
86
|
+
const g = parseGeneric(content)
|
|
87
|
+
g.externalUrls = extractExternalUrls(content)
|
|
88
|
+
return g
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
// Layer on routes / apiCalls where applicable. Cheap regex passes —
|
|
92
|
+
// false positives are filtered downstream during route↔call matching.
|
|
93
|
+
if (ext === 'js' || ext === 'jsx' || ext === 'mjs' || ext === 'cjs'
|
|
94
|
+
|| ext === 'ts' || ext === 'tsx') {
|
|
95
|
+
// JS/TS web frameworks: Express-style + NestJS-style decorators
|
|
96
|
+
r.routes = [...extractJSRoutes(content), ...extractAnnotationRoutes(content)]
|
|
97
|
+
r.apiCalls = extractJSApiCalls(content)
|
|
98
|
+
} else if (ext === 'py' || ext === 'pyw') {
|
|
99
|
+
r.routes = extractPyRoutes(content)
|
|
100
|
+
r.apiCalls = extractPyApiCalls(content)
|
|
101
|
+
} else if (ext === 'go') {
|
|
102
|
+
r.routes = extractGoRoutes(content)
|
|
103
|
+
} else if (ext === 'java' || ext === 'kt') {
|
|
104
|
+
r.routes = extractAnnotationRoutes(content)
|
|
105
|
+
} else if (ext === 'vue' || ext === 'svelte' || ext === 'astro') {
|
|
106
|
+
r.routes = extractJSRoutes(content)
|
|
107
|
+
r.apiCalls = extractJSApiCalls(content)
|
|
108
|
+
}
|
|
109
|
+
r.externalUrls = extractExternalUrls(content)
|
|
110
|
+
r.dynamicPatterns = detectDynamicPatterns(content, ext)
|
|
111
|
+
r.envUsage = extractEnvUsage(content, ext)
|
|
112
|
+
r.dbModels = extractDbModels(content, ext)
|
|
113
|
+
// Cross-language / FFI edges — append to the regular imports list
|
|
114
|
+
// so the existing edge-building pipeline picks them up.
|
|
115
|
+
let ffi = []
|
|
116
|
+
if (['js','jsx','mjs','cjs','ts','tsx'].includes(ext)) ffi = extractJsFfi(content)
|
|
117
|
+
else if (ext === 'py' || ext === 'pyw') ffi = extractPyFfi(content)
|
|
118
|
+
else if (ext === 'java' || ext === 'kt') ffi = extractJavaFfi(content)
|
|
119
|
+
else if (ext === 'rs') ffi = extractRustFfi(content)
|
|
120
|
+
if (ffi.length) {
|
|
121
|
+
r.imports = (r.imports || []).concat(ffi.filter((e) => e.kind === 'ffi'))
|
|
122
|
+
}
|
|
123
|
+
r.confidence = confidenceFor(r.dynamicPatterns, content, ext)
|
|
124
|
+
return r
|
|
125
|
+
} catch {
|
|
126
|
+
const g = parseGeneric(content)
|
|
127
|
+
g.externalUrls = extractExternalUrls(content)
|
|
128
|
+
g.dynamicPatterns = detectDynamicPatterns(content, ext)
|
|
129
|
+
g.envUsage = extractEnvUsage(content, ext)
|
|
130
|
+
g.dbModels = extractDbModels(content, ext)
|
|
131
|
+
g.confidence = confidenceFor(g.dynamicPatterns, content, ext)
|
|
132
|
+
return g
|
|
133
|
+
}
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
// Graph completeness signal per file. Three buckets so the AI can
|
|
137
|
+
// decide how much to trust the import graph for this file:
|
|
138
|
+
// - high : pure static imports, no dynamic patterns
|
|
139
|
+
// - medium : dynamic patterns present but bounded
|
|
140
|
+
// (require(expr) / import(expr) / template literal)
|
|
141
|
+
// - low : reflection / eval / Function-constructor / DI markers —
|
|
142
|
+
// the graph likely misses real edges from this file
|
|
143
|
+
//
|
|
144
|
+
// DI markers (NestJS/Angular decorators, tsyringe inject) are also
|
|
145
|
+
// strong "graph incomplete" signals because DI resolves dependencies
|
|
146
|
+
// at runtime via metadata.
|
|
147
|
+
function confidenceFor(dynamicPatterns, content, ext) {
|
|
148
|
+
const patterns = dynamicPatterns || []
|
|
149
|
+
// 1. Hard "low" signals from dynamic patterns
|
|
150
|
+
const HARD = new Set(['eval', 'new Function', 'exec'])
|
|
151
|
+
if (patterns.some((p) => HARD.has(p))) return 'low'
|
|
152
|
+
// 2. DI framework hints (low even with zero dynamic patterns) — JS/TS only
|
|
153
|
+
const jsLike = ['js','jsx','mjs','cjs','ts','tsx','vue','svelte','astro'].includes(ext)
|
|
154
|
+
if (jsLike && content) {
|
|
155
|
+
// NestJS / Angular decorators: @Injectable / @Component (with parens)
|
|
156
|
+
// also React's bare @Component but those are typed-decorators of classes.
|
|
157
|
+
if (/@\s*(?:Injectable|Module|Controller)\s*\(/.test(content)) return 'low'
|
|
158
|
+
if (/@\s*Component\s*\(/.test(content) &&
|
|
159
|
+
/(?:^|\s)import[^;]*(?:@angular|@nestjs)\b/.test(content)) return 'low'
|
|
160
|
+
// tsyringe / typed-inject: `inject<T>('TOKEN')` or `container.resolve(...)`
|
|
161
|
+
if (/\b(?:container|resolver)\.(?:resolve|inject)\s*\(/.test(content)) return 'low'
|
|
162
|
+
if (/\binject\s*<[^>]+>\s*\(/.test(content)) return 'low'
|
|
163
|
+
}
|
|
164
|
+
// 3. Other dynamic patterns → medium
|
|
165
|
+
if (patterns.length > 0) return 'medium'
|
|
166
|
+
// 4. Pure static → high
|
|
167
|
+
return 'high'
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
// Detect patterns where modules/files are loaded dynamically — these
|
|
171
|
+
// resist static analysis, so a file with `hasDynamicResolution: true`
|
|
172
|
+
// should NEVER be treated as orphan with high confidence and any
|
|
173
|
+
// imports it makes are likely incomplete in our graph.
|
|
174
|
+
function detectDynamicPatterns(content, ext) {
|
|
175
|
+
const found = []
|
|
176
|
+
const jsLike = ['js','jsx','mjs','cjs','ts','tsx','vue','svelte','astro'].includes(ext)
|
|
177
|
+
const pyLike = ['py','pyw'].includes(ext)
|
|
178
|
+
if (jsLike) {
|
|
179
|
+
// require(<expression>) where the arg isn't a plain string literal
|
|
180
|
+
if (/\brequire\s*\(\s*(?!['"`][^'"`]*['"`]\s*\))/g.test(content)) found.push('require(expr)')
|
|
181
|
+
// import(<expression>) - dynamic import
|
|
182
|
+
if (/\bimport\s*\(\s*(?!['"`][^'"`]*['"`]\s*\))/g.test(content)) found.push('import(expr)')
|
|
183
|
+
// Template literal in require/import that interpolates variables
|
|
184
|
+
if (/\b(?:require|import)\s*\(\s*`[^`]*\$\{/g.test(content)) found.push('require/import template literal')
|
|
185
|
+
// eval / new Function — can load/run arbitrary (cross-file) code
|
|
186
|
+
if (/\beval\s*\(/g.test(content)) found.push('eval')
|
|
187
|
+
if (/\bnew\s+Function\s*\(/g.test(content)) found.push('new Function')
|
|
188
|
+
// NOTE: Reflect.* and globalThis[x] are dispatch/access on ALREADY-referenced
|
|
189
|
+
// values — they don't hide a cross-file import edge, so flagging them is just
|
|
190
|
+
// noise (fires on huge fractions of real code). Intentionally NOT marked.
|
|
191
|
+
}
|
|
192
|
+
if (pyLike) {
|
|
193
|
+
if (/\bimportlib\b/.test(content)) found.push('importlib')
|
|
194
|
+
if (/\b__import__\s*\(/.test(content)) found.push('__import__')
|
|
195
|
+
if (/\beval\s*\(/.test(content)) found.push('eval')
|
|
196
|
+
if (/\bexec\s*\(/.test(content)) found.push('exec')
|
|
197
|
+
// NOTE: getattr() is attribute access on an already-imported object — it does
|
|
198
|
+
// NOT hide a cross-file dependency, and fires on ~22% of real Python files.
|
|
199
|
+
// Marking it would drown the real signals (importlib/__import__/eval). Skipped.
|
|
200
|
+
}
|
|
201
|
+
// Cross-language dependency-injection + reflection blind spots: the dependency
|
|
202
|
+
// is real at runtime but its target isn't a static import/use, so the graph
|
|
203
|
+
// can't see it. High-signal patterns only (not every polymorphic call).
|
|
204
|
+
if (ext === 'java' || ext === 'kt') {
|
|
205
|
+
if (/@(?:Autowired|Inject|Resource|Bean|Component|Service|Repository|Provides)\b/.test(content)) found.push('DI annotation')
|
|
206
|
+
if (/\bClass\.forName\s*\(|\.getDeclaredMethod\s*\(|\.getMethod\s*\(|\.newInstance\s*\(/.test(content)) found.push('reflection')
|
|
207
|
+
}
|
|
208
|
+
if (ext === 'cs') {
|
|
209
|
+
if (/\bservices\.(?:AddScoped|AddTransient|AddSingleton)\b|\[Inject\]/.test(content)) found.push('DI registration')
|
|
210
|
+
if (/\bType\.GetType\s*\(|\bActivator\.CreateInstance\s*\(|\bAssembly\.Load\b|\.GetMethod\s*\(/.test(content)) found.push('reflection')
|
|
211
|
+
}
|
|
212
|
+
if (ext === 'go') {
|
|
213
|
+
if (/\breflect\.(?:ValueOf|TypeOf)\b|\.MethodByName\s*\(|\bplugin\.Open\s*\(/.test(content)) found.push('reflection/plugin')
|
|
214
|
+
}
|
|
215
|
+
if (ext === 'php') {
|
|
216
|
+
if (/\bcall_user_func|\bReflectionClass\b|\bclass_exists\s*\(|\$\$|\b(?:app|resolve)\s*\(/.test(content)) found.push('dynamic/DI call')
|
|
217
|
+
}
|
|
218
|
+
if (ext === 'rb') {
|
|
219
|
+
// const_get can load a class (Rails autoload = cross-file); method_missing /
|
|
220
|
+
// define_method synthesize methods. `send`/`public_send` is plain dispatch on
|
|
221
|
+
// an existing object (idiomatic, not file-hiding) → excluded to avoid noise.
|
|
222
|
+
if (/\bconst_get\b|\bmethod_missing\b|\bdefine_method\b/.test(content)) found.push('metaprogramming')
|
|
223
|
+
}
|
|
224
|
+
return found // empty array = no dynamic patterns
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
// Best-effort static string extraction for a Babel node. Catches:
|
|
228
|
+
// - 'foo' StringLiteral
|
|
229
|
+
// - `./foo` TemplateLiteral with no expressions
|
|
230
|
+
// - VAR where const VAR = 'foo' one-deep scope lookup (const binding only)
|
|
231
|
+
// Returns the resolved string or null.
|
|
232
|
+
function staticStringValue(node, scope) {
|
|
233
|
+
if (!node) return null
|
|
234
|
+
if (node.type === 'StringLiteral') return node.value
|
|
235
|
+
if (node.type === 'TemplateLiteral' && node.expressions.length === 0) {
|
|
236
|
+
return node.quasis.map((q) => q.value.cooked).join('')
|
|
237
|
+
}
|
|
238
|
+
if (node.type === 'Identifier' && scope) {
|
|
239
|
+
const binding = scope.getBinding?.(node.name)
|
|
240
|
+
if (!binding || binding.kind !== 'const') return null
|
|
241
|
+
const init = binding.path?.node?.init
|
|
242
|
+
if (init?.type === 'StringLiteral') return init.value
|
|
243
|
+
if (init?.type === 'TemplateLiteral' && init.expressions.length === 0) {
|
|
244
|
+
return init.quasis.map((q) => q.value.cooked).join('')
|
|
245
|
+
}
|
|
246
|
+
}
|
|
247
|
+
return null
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
// ─── JS / TS via Babel ────────────────────────────────────────
|
|
251
|
+
function parseJS(content) {
|
|
252
|
+
const imports = []
|
|
253
|
+
let ast
|
|
254
|
+
try {
|
|
255
|
+
ast = babelParse(content, {
|
|
256
|
+
sourceType: 'unambiguous',
|
|
257
|
+
allowImportExportEverywhere: true,
|
|
258
|
+
allowReturnOutsideFunction: true,
|
|
259
|
+
allowAwaitOutsideFunction: true,
|
|
260
|
+
errorRecovery: true,
|
|
261
|
+
plugins: [
|
|
262
|
+
'jsx', 'typescript', 'decorators-legacy',
|
|
263
|
+
'classProperties', 'classPrivateProperties', 'classPrivateMethods',
|
|
264
|
+
'dynamicImport', 'optionalChaining', 'nullishCoalescingOperator',
|
|
265
|
+
'topLevelAwait', 'importMeta', 'numericSeparator',
|
|
266
|
+
],
|
|
267
|
+
})
|
|
268
|
+
} catch {
|
|
269
|
+
return parseJSRegex(content)
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
try {
|
|
273
|
+
traverse(ast, {
|
|
274
|
+
ImportDeclaration(p) {
|
|
275
|
+
imports.push({ spec: p.node.source.value, kind: 'import' })
|
|
276
|
+
},
|
|
277
|
+
ExportNamedDeclaration(p) {
|
|
278
|
+
if (p.node.source) imports.push({ spec: p.node.source.value, kind: 'reexport' })
|
|
279
|
+
},
|
|
280
|
+
ExportAllDeclaration(p) {
|
|
281
|
+
if (p.node.source) imports.push({ spec: p.node.source.value, kind: 'reexport' })
|
|
282
|
+
},
|
|
283
|
+
CallExpression(p) {
|
|
284
|
+
const c = p.node.callee
|
|
285
|
+
const args = p.node.arguments
|
|
286
|
+
// require('...') or require(`./foo`) or require(CONST)
|
|
287
|
+
if (c.type === 'Identifier' && c.name === 'require') {
|
|
288
|
+
const spec = staticStringValue(args[0], p.scope)
|
|
289
|
+
if (spec) imports.push({ spec, kind: 'import' })
|
|
290
|
+
}
|
|
291
|
+
// import('...') — JS dynamic import
|
|
292
|
+
if (c.type === 'Import') {
|
|
293
|
+
const spec = staticStringValue(args[0], p.scope)
|
|
294
|
+
if (spec) imports.push({ spec, kind: 'dynamic' })
|
|
295
|
+
}
|
|
296
|
+
// jest.mock('./x') / vi.mock('./x') / proxyquire('./x', ...)
|
|
297
|
+
if (c.type === 'MemberExpression'
|
|
298
|
+
&& c.property?.type === 'Identifier'
|
|
299
|
+
&& c.property.name === 'mock'
|
|
300
|
+
&& (c.object?.name === 'jest' || c.object?.name === 'vi')) {
|
|
301
|
+
const spec = staticStringValue(args[0], p.scope)
|
|
302
|
+
if (spec) imports.push({ spec, kind: 'mock' })
|
|
303
|
+
}
|
|
304
|
+
},
|
|
305
|
+
})
|
|
306
|
+
} catch {
|
|
307
|
+
return parseJSRegex(content)
|
|
308
|
+
}
|
|
309
|
+
return { imports }
|
|
310
|
+
}
|
|
311
|
+
|
|
312
|
+
function parseJSRegex(content) {
|
|
313
|
+
const imports = []
|
|
314
|
+
const patterns = [
|
|
315
|
+
[/import\s+(?:[^'"`;]*\s+from\s+)?['"]([^'"]+)['"]/g, 'import'],
|
|
316
|
+
[/import\s*\(\s*['"]([^'"]+)['"]\s*\)/g, 'dynamic'],
|
|
317
|
+
[/require\s*\(\s*['"]([^'"]+)['"]\s*\)/g, 'import'],
|
|
318
|
+
[/export\s+(?:\*|\{[^}]*\})\s+from\s+['"]([^'"]+)['"]/g, 'reexport'],
|
|
319
|
+
]
|
|
320
|
+
for (const [re, kind] of patterns) {
|
|
321
|
+
let m
|
|
322
|
+
while ((m = re.exec(content))) imports.push({ spec: m[1], kind })
|
|
323
|
+
}
|
|
324
|
+
return { imports }
|
|
325
|
+
}
|
|
326
|
+
|
|
327
|
+
// ─── Vue / Svelte / Astro (extract <script> + parse) ──────────
|
|
328
|
+
function parseComponentFile(content) {
|
|
329
|
+
// Pull out <script>...</script> blocks (approximate)
|
|
330
|
+
const scripts = []
|
|
331
|
+
const scriptRe = /<script[^>]*>([\s\S]*?)<\/script>/g
|
|
332
|
+
let m
|
|
333
|
+
while ((m = scriptRe.exec(content))) scripts.push(m[1])
|
|
334
|
+
const combined = scripts.join('\n')
|
|
335
|
+
return parseJS(combined)
|
|
336
|
+
}
|
|
337
|
+
|
|
338
|
+
// ─── Python ───────────────────────────────────────────────────
|
|
339
|
+
function parsePython(content) {
|
|
340
|
+
const imports = []
|
|
341
|
+
// Split on \r?\n so CRLF files don't leave a trailing \r — JS regex `.`
|
|
342
|
+
// doesn't match \r and `$` won't anchor before it, which would break the
|
|
343
|
+
// `from . import sub` end-anchored capture below.
|
|
344
|
+
const rawLines = content.split(/\r?\n/)
|
|
345
|
+
let inTriple = false
|
|
346
|
+
for (let li = 0; li < rawLines.length; li++) {
|
|
347
|
+
let line = rawLines[li]
|
|
348
|
+
// Skip triple-quoted docstrings / string literals: they frequently
|
|
349
|
+
// contain `from x import y` code examples that are NOT real imports
|
|
350
|
+
// (false-positive edges). Toggle on odd counts of `"""` or `'''`.
|
|
351
|
+
const triples = (line.match(/"""|'''/g) || []).length
|
|
352
|
+
if (inTriple) {
|
|
353
|
+
if (triples % 2 === 1) inTriple = false
|
|
354
|
+
continue
|
|
355
|
+
}
|
|
356
|
+
if (triples % 2 === 1) inTriple = true
|
|
357
|
+
if (line.trim().startsWith('#')) continue
|
|
358
|
+
// Join parenthesized multi-line imports — `from x import (\n a,\n b,\n)`
|
|
359
|
+
// is extremely common in formatted code (black/isort). Accumulate the
|
|
360
|
+
// following lines (import names, not docstrings) until the `)` closes.
|
|
361
|
+
if (/^\s*from\s+[.\w]+\s+import\s*\(/.test(line) && !line.includes(')')) {
|
|
362
|
+
while (li + 1 < rawLines.length && !line.includes(')')) {
|
|
363
|
+
line += ' ' + rawLines[++li].replace(/#.*$/, '').trim()
|
|
364
|
+
}
|
|
365
|
+
line = line.replace(/[()]/g, ' ')
|
|
366
|
+
}
|
|
367
|
+
let m
|
|
368
|
+
// from X import Y (X can include leading dots for relative)
|
|
369
|
+
if ((m = line.match(/^\s*from\s+(\.+|[.\w]+)\s+import\s+(.+)$/))) {
|
|
370
|
+
const from = m[1]
|
|
371
|
+
// `from . import sub` / `from .. import sub`: the imported names are
|
|
372
|
+
// usually SUBMODULES of the relative package, not attributes of its
|
|
373
|
+
// __init__. Emit `.sub` so resolution finds the submodule file; also
|
|
374
|
+
// keep the bare package as a fallback (names defined in __init__).
|
|
375
|
+
if (/^\.+$/.test(from)) {
|
|
376
|
+
for (let n of m[2].replace(/#.*$/, '').split(',')) {
|
|
377
|
+
n = n.trim().replace(/[()]/g, '').split(/\s+as\s+/)[0].trim()
|
|
378
|
+
if (n && n !== '*') imports.push({ spec: from + n, kind: 'import' })
|
|
379
|
+
}
|
|
380
|
+
imports.push({ spec: from, kind: 'import' })
|
|
381
|
+
} else {
|
|
382
|
+
// `from pkg import sub`: the imported names may be SUBMODULES (files)
|
|
383
|
+
// of pkg, not just attributes of its __init__. Emit `pkg.name` so
|
|
384
|
+
// submodule files resolve; non-module names (classes/funcs) simply
|
|
385
|
+
// resolve to nothing. Keep the bare package too (it is also executed,
|
|
386
|
+
// and names may be defined in its __init__).
|
|
387
|
+
for (let n of m[2].replace(/#.*$/, '').split(',')) {
|
|
388
|
+
n = n.trim().replace(/[()]/g, '').split(/\s+as\s+/)[0].trim()
|
|
389
|
+
if (n && n !== '*') imports.push({ spec: from + '.' + n, kind: 'import' })
|
|
390
|
+
}
|
|
391
|
+
imports.push({ spec: from, kind: 'import' })
|
|
392
|
+
}
|
|
393
|
+
}
|
|
394
|
+
else if ((m = line.match(/^\s*from\s+(\.+|[.\w]+)\s+import/))) {
|
|
395
|
+
imports.push({ spec: m[1], kind: 'import' })
|
|
396
|
+
}
|
|
397
|
+
// import X, Y as Z
|
|
398
|
+
else if ((m = line.match(/^\s*import\s+([\w.][\w.\s,]*)/))) {
|
|
399
|
+
for (const name of m[1].split(',')) {
|
|
400
|
+
const clean = name.trim().split(/\s+as\s+/)[0].trim()
|
|
401
|
+
if (clean) imports.push({ spec: clean, kind: 'import' })
|
|
402
|
+
}
|
|
403
|
+
}
|
|
404
|
+
// importlib.import_module('foo') / __import__('foo') — string-arg
|
|
405
|
+
// dynamic imports that are still statically resolvable.
|
|
406
|
+
const dyn = line.match(/\b(?:importlib\.import_module|__import__)\(\s*['"]([^'"]+)['"]/)
|
|
407
|
+
if (dyn) imports.push({ spec: dyn[1], kind: 'dynamic' })
|
|
408
|
+
}
|
|
409
|
+
return { imports }
|
|
410
|
+
}
|
|
411
|
+
|
|
412
|
+
// ─── Jupyter notebook (.ipynb) ────────────────────────────────
|
|
413
|
+
// Parse a notebook's code cells. Returns { imports, codeContent }
|
|
414
|
+
// where codeContent is the concatenated source of every code cell —
|
|
415
|
+
// the caller uses it for URL / route / apiCall extraction so the raw
|
|
416
|
+
// JSON metadata doesn't pollute those signals.
|
|
417
|
+
function parseIpynb(content) {
|
|
418
|
+
let nb
|
|
419
|
+
try { nb = JSON.parse(content) } catch { return { imports: [], codeContent: '' } }
|
|
420
|
+
if (!nb || !Array.isArray(nb.cells)) return { imports: [], codeContent: '' }
|
|
421
|
+
const lang = (nb.metadata?.kernelspec?.language || 'python').toLowerCase()
|
|
422
|
+
const parts = []
|
|
423
|
+
for (const cell of nb.cells) {
|
|
424
|
+
if (cell.cell_type !== 'code') continue
|
|
425
|
+
const src = cell.source
|
|
426
|
+
if (typeof src === 'string') parts.push(src)
|
|
427
|
+
else if (Array.isArray(src)) parts.push(src.join(''))
|
|
428
|
+
}
|
|
429
|
+
const codeContent = parts.join('\n')
|
|
430
|
+
// Strip IPython magics (% / !) so parsePython doesn't get confused.
|
|
431
|
+
// `%matplotlib inline`, `!pip install foo` etc. aren't imports we want.
|
|
432
|
+
const cleaned = codeContent.split('\n')
|
|
433
|
+
.filter((l) => !/^\s*[%!]/.test(l))
|
|
434
|
+
.join('\n')
|
|
435
|
+
// Most notebooks are Python; R/Julia fall back to generic (URL only).
|
|
436
|
+
const r = lang.startsWith('python') ? parsePython(cleaned) : parseGeneric(cleaned)
|
|
437
|
+
return { imports: r.imports || [], codeContent: cleaned }
|
|
438
|
+
}
|
|
439
|
+
|
|
440
|
+
// ─── Lisp / AutoLISP ──────────────────────────────────────────
|
|
441
|
+
function parseLisp(content) {
|
|
442
|
+
const imports = []
|
|
443
|
+
const patterns = [
|
|
444
|
+
/\(\s*load\s+"([^"]+)"/g,
|
|
445
|
+
/\(\s*vl-arx-import\s+"([^"]+)"/g,
|
|
446
|
+
/\(\s*autoload\s+"([^"]+)"/g,
|
|
447
|
+
/\(\s*require\s+(?:'|")([^"')]+)/g,
|
|
448
|
+
]
|
|
449
|
+
for (const re of patterns) {
|
|
450
|
+
let m
|
|
451
|
+
while ((m = re.exec(content))) imports.push({ spec: m[1], kind: 'import' })
|
|
452
|
+
}
|
|
453
|
+
return { imports }
|
|
454
|
+
}
|
|
455
|
+
|
|
456
|
+
// ─── CSS family ───────────────────────────────────────────────
|
|
457
|
+
function parseCSS(content) {
|
|
458
|
+
const imports = []
|
|
459
|
+
const patterns = [
|
|
460
|
+
/@import\s+(?:url\()?['"]([^'")]+)['"]/g,
|
|
461
|
+
/@use\s+['"]([^'"]+)['"]/g, // SCSS
|
|
462
|
+
/@forward\s+['"]([^'"]+)['"]/g,
|
|
463
|
+
/url\(\s*['"]([^'")]+)['"]\s*\)/g,
|
|
464
|
+
]
|
|
465
|
+
for (const re of patterns) {
|
|
466
|
+
let m
|
|
467
|
+
while ((m = re.exec(content))) imports.push({ spec: m[1], kind: 'import' })
|
|
468
|
+
}
|
|
469
|
+
return { imports }
|
|
470
|
+
}
|
|
471
|
+
|
|
472
|
+
// ─── HTML ─────────────────────────────────────────────────────
|
|
473
|
+
function parseHTML(content) {
|
|
474
|
+
const imports = []
|
|
475
|
+
const patterns = [
|
|
476
|
+
/<script\s+[^>]*src\s*=\s*['"]([^'"]+)['"]/gi,
|
|
477
|
+
/<link\s+[^>]*href\s*=\s*['"]([^'"]+)['"]/gi,
|
|
478
|
+
/<img\s+[^>]*src\s*=\s*['"]([^'"]+)['"]/gi,
|
|
479
|
+
/<iframe\s+[^>]*src\s*=\s*['"]([^'"]+)['"]/gi,
|
|
480
|
+
/<video\s+[^>]*src\s*=\s*['"]([^'"]+)['"]/gi,
|
|
481
|
+
/<source\s+[^>]*src\s*=\s*['"]([^'"]+)['"]/gi,
|
|
482
|
+
]
|
|
483
|
+
for (const re of patterns) {
|
|
484
|
+
let m
|
|
485
|
+
while ((m = re.exec(content))) {
|
|
486
|
+
const spec = m[1]
|
|
487
|
+
if (spec.startsWith('http://') || spec.startsWith('https://') ||
|
|
488
|
+
spec.startsWith('//') || spec.startsWith('data:')) continue
|
|
489
|
+
imports.push({ spec, kind: 'asset' })
|
|
490
|
+
}
|
|
491
|
+
}
|
|
492
|
+
return { imports }
|
|
493
|
+
}
|
|
494
|
+
|
|
495
|
+
// ─── Markdown ─────────────────────────────────────────────────
|
|
496
|
+
function parseMarkdown(content) {
|
|
497
|
+
const imports = []
|
|
498
|
+
//  and [text](path) — exclude URLs
|
|
499
|
+
const re = /\[(?:[^\]]*)\]\(([^)]+)\)/g
|
|
500
|
+
let m
|
|
501
|
+
while ((m = re.exec(content))) {
|
|
502
|
+
const spec = m[1].trim().split(/\s+/)[0]
|
|
503
|
+
if (!spec || /^https?:\/\//.test(spec) || spec.startsWith('#')
|
|
504
|
+
|| spec.startsWith('mailto:')) continue
|
|
505
|
+
imports.push({ spec, kind: 'ref' })
|
|
506
|
+
}
|
|
507
|
+
return { imports }
|
|
508
|
+
}
|
|
509
|
+
|
|
510
|
+
// ─── Rust / Go / Java / C / Ruby / PHP / Shell ────────────────
|
|
511
|
+
function parseRust(content) {
|
|
512
|
+
// File modules: `mod X;` / `pub mod X;` / `pub(crate) mod X;` (the `;`
|
|
513
|
+
// matters — inline `mod X { ... }` has no backing file, so we skip it).
|
|
514
|
+
// Imports: `use ...;` / `pub use ...;`, including grouped/nested forms
|
|
515
|
+
// `use a::b::{c, d::e, self};` and renames `use a::b as c;`. Each group
|
|
516
|
+
// member is expanded into its own spec so it resolves to its own file.
|
|
517
|
+
const imports = []
|
|
518
|
+
const VIS = '(?:pub\\s*(?:\\([^)]*\\)\\s*)?)?'
|
|
519
|
+
const modRe = new RegExp('^\\s*' + VIS + 'mod\\s+(\\w+)\\s*;', 'gm')
|
|
520
|
+
let m
|
|
521
|
+
while ((m = modRe.exec(content))) imports.push({ spec: m[1], kind: 'import' })
|
|
522
|
+
// `use` statements can span multiple lines up to the terminating `;`.
|
|
523
|
+
const useRe = new RegExp('^\\s*' + VIS + 'use\\s+([^;]+);', 'gm')
|
|
524
|
+
while ((m = useRe.exec(content))) {
|
|
525
|
+
for (const spec of expandRustUse(m[1])) imports.push({ spec, kind: 'import' })
|
|
526
|
+
}
|
|
527
|
+
return { imports }
|
|
528
|
+
}
|
|
529
|
+
|
|
530
|
+
// Expand a Rust `use` tree (without the leading `use`/trailing `;`) into a
|
|
531
|
+
// flat list of module-path specifiers. Handles nested `{}` groups, `self`
|
|
532
|
+
// (refers to the group prefix), `as` renames, and `*` globs.
|
|
533
|
+
function expandRustUse(body) {
|
|
534
|
+
const s = body.replace(/\s+as\s+\w+/g, '').replace(/\s+/g, '')
|
|
535
|
+
const out = []
|
|
536
|
+
const matchBrace = (str, open) => {
|
|
537
|
+
let depth = 0
|
|
538
|
+
for (let i = open; i < str.length; i++) {
|
|
539
|
+
if (str[i] === '{') depth++
|
|
540
|
+
else if (str[i] === '}') { depth--; if (depth === 0) return i }
|
|
541
|
+
}
|
|
542
|
+
return str.length
|
|
543
|
+
}
|
|
544
|
+
const splitTop = (str) => {
|
|
545
|
+
const parts = []
|
|
546
|
+
let depth = 0, start = 0
|
|
547
|
+
for (let i = 0; i < str.length; i++) {
|
|
548
|
+
if (str[i] === '{') depth++
|
|
549
|
+
else if (str[i] === '}') depth--
|
|
550
|
+
else if (str[i] === ',' && depth === 0) { parts.push(str.slice(start, i)); start = i + 1 }
|
|
551
|
+
}
|
|
552
|
+
parts.push(str.slice(start))
|
|
553
|
+
return parts
|
|
554
|
+
}
|
|
555
|
+
const walk = (seg, prefix) => {
|
|
556
|
+
const brace = seg.indexOf('{')
|
|
557
|
+
if (brace === -1) {
|
|
558
|
+
let leaf = seg.replace(/::\*$/, '').replace(/^\*$/, '')
|
|
559
|
+
if (leaf === 'self' || leaf === '') { if (prefix) out.push(prefix); return }
|
|
560
|
+
out.push(prefix ? prefix + '::' + leaf : leaf)
|
|
561
|
+
return
|
|
562
|
+
}
|
|
563
|
+
const head = seg.slice(0, brace).replace(/::$/, '')
|
|
564
|
+
const newPrefix = head ? (prefix ? prefix + '::' + head : head) : prefix
|
|
565
|
+
const inner = seg.slice(brace + 1, matchBrace(seg, brace))
|
|
566
|
+
for (const part of splitTop(inner)) walk(part, newPrefix)
|
|
567
|
+
}
|
|
568
|
+
walk(s, '')
|
|
569
|
+
return out.filter(Boolean)
|
|
570
|
+
}
|
|
571
|
+
|
|
572
|
+
function parseGo(content) {
|
|
573
|
+
const imports = []
|
|
574
|
+
// import "pkg" or import ( "a" "b" )
|
|
575
|
+
const single = /import\s+"([^"]+)"/g
|
|
576
|
+
const block = /import\s*\(\s*([\s\S]*?)\)/g
|
|
577
|
+
let m
|
|
578
|
+
while ((m = single.exec(content))) imports.push({ spec: m[1], kind: 'import' })
|
|
579
|
+
while ((m = block.exec(content))) {
|
|
580
|
+
const inner = m[1]
|
|
581
|
+
const innerRe = /"([^"]+)"/g
|
|
582
|
+
let mm
|
|
583
|
+
while ((mm = innerRe.exec(inner))) imports.push({ spec: mm[1], kind: 'import' })
|
|
584
|
+
}
|
|
585
|
+
return { imports }
|
|
586
|
+
}
|
|
587
|
+
|
|
588
|
+
function parseJavaKotlin(content) {
|
|
589
|
+
const imports = []
|
|
590
|
+
const re = /^\s*import\s+(?:static\s+)?([\w.]+)/gm
|
|
591
|
+
let m
|
|
592
|
+
while ((m = re.exec(content))) imports.push({ spec: m[1], kind: 'import' })
|
|
593
|
+
return { imports }
|
|
594
|
+
}
|
|
595
|
+
|
|
596
|
+
function parseC(content) {
|
|
597
|
+
const imports = []
|
|
598
|
+
const re = /#\s*include\s+[<"]([^>"]+)[>"]/g
|
|
599
|
+
let m
|
|
600
|
+
while ((m = re.exec(content))) imports.push({ spec: m[1], kind: 'import' })
|
|
601
|
+
return { imports }
|
|
602
|
+
}
|
|
603
|
+
|
|
604
|
+
function parseRuby(content) {
|
|
605
|
+
const imports = []
|
|
606
|
+
const re = /^\s*(?:require|require_relative|load)\s+['"]([^'"]+)['"]/gm
|
|
607
|
+
let m
|
|
608
|
+
while ((m = re.exec(content))) imports.push({ spec: m[1], kind: 'import' })
|
|
609
|
+
return { imports }
|
|
610
|
+
}
|
|
611
|
+
|
|
612
|
+
function parsePHP(content) {
|
|
613
|
+
const imports = []
|
|
614
|
+
// require/include (optionally _once), allowing `__DIR__ . ` concatenation
|
|
615
|
+
// before the string literal (the dominant real-world form).
|
|
616
|
+
const reqRe = /\b(?:require|include)(?:_once)?\b[^;'"]*['"]([^'"]+)['"]/g
|
|
617
|
+
let m
|
|
618
|
+
while ((m = reqRe.exec(content))) imports.push({ spec: m[1], kind: 'require' })
|
|
619
|
+
// `use` namespace imports (PSR-4). Forms handled:
|
|
620
|
+
// use A\B\C; use A\B\C as D; use function A\b; use const A\B;
|
|
621
|
+
// use A\B\{C, D as E, function f}; (PHP 7+ group use)
|
|
622
|
+
// The negative-shape first char (`[A-Za-z_\\]`) skips closure `use ($x)`.
|
|
623
|
+
const useRe = /^\s*use\s+(?:function\s+|const\s+)?([A-Za-z_\\][^;]*);/gm
|
|
624
|
+
while ((m = useRe.exec(content))) {
|
|
625
|
+
for (const spec of expandPhpUse(m[1])) imports.push({ spec, kind: 'use' })
|
|
626
|
+
}
|
|
627
|
+
return { imports }
|
|
628
|
+
}
|
|
629
|
+
|
|
630
|
+
// Expand a PHP `use` body into fully-qualified class names. Handles group
|
|
631
|
+
// use `Prefix\{A, B as C}`, `as` aliases, and `function`/`const` members.
|
|
632
|
+
function expandPhpUse(body) {
|
|
633
|
+
const out = []
|
|
634
|
+
const brace = body.indexOf('{')
|
|
635
|
+
if (brace === -1) {
|
|
636
|
+
const fqcn = body.split(/\s+as\s+/i)[0].trim()
|
|
637
|
+
if (fqcn) out.push(fqcn)
|
|
638
|
+
return out
|
|
639
|
+
}
|
|
640
|
+
const prefix = body.slice(0, brace).replace(/\\\s*$/, '').trim()
|
|
641
|
+
const inner = body.slice(brace + 1, body.lastIndexOf('}'))
|
|
642
|
+
for (let part of inner.split(',')) {
|
|
643
|
+
part = part.replace(/\b(?:function|const)\s+/i, '').split(/\s+as\s+/i)[0].trim()
|
|
644
|
+
if (part) out.push(prefix + '\\' + part.replace(/^\\/, ''))
|
|
645
|
+
}
|
|
646
|
+
return out
|
|
647
|
+
}
|
|
648
|
+
|
|
649
|
+
function parseShell(content) {
|
|
650
|
+
const imports = []
|
|
651
|
+
const re = /^\s*(?:source|\.)\s+(['"]?)([^\s'";]+)\1/gm
|
|
652
|
+
let m
|
|
653
|
+
while ((m = re.exec(content))) imports.push({ spec: m[2], kind: 'import' })
|
|
654
|
+
return { imports }
|
|
655
|
+
}
|
|
656
|
+
|
|
657
|
+
// C# — `using Namespace;`, `using static System.Math;`, `using Alias = Path;`
|
|
658
|
+
function parseCSharp(content) {
|
|
659
|
+
const imports = []
|
|
660
|
+
// Three forms; capture the namespace path in group 1
|
|
661
|
+
const re = /^\s*using\s+(?:static\s+)?(?:[A-Za-z_]\w*\s*=\s*)?([A-Za-z_][\w.]*)\s*;/gm
|
|
662
|
+
let m
|
|
663
|
+
while ((m = re.exec(content))) imports.push({ spec: m[1], kind: 'using' })
|
|
664
|
+
return { imports }
|
|
665
|
+
}
|
|
666
|
+
|
|
667
|
+
// Swift — `import Module`, `@_exported import Module`,
|
|
668
|
+
// `import struct ModuleA.Foo`, `@testable import ModuleB`
|
|
669
|
+
function parseSwift(content) {
|
|
670
|
+
const imports = []
|
|
671
|
+
const re = /^\s*(?:@\w+\s+)?import\s+(?:(?:class|struct|enum|protocol|typealias|func|var|let)\s+)?([A-Za-z_][\w.]*)/gm
|
|
672
|
+
let m
|
|
673
|
+
while ((m = re.exec(content))) imports.push({ spec: m[1], kind: 'import' })
|
|
674
|
+
return { imports }
|
|
675
|
+
}
|
|
676
|
+
|
|
677
|
+
// Dart — `import 'package:foo/bar.dart';`, `import 'src/foo.dart';`,
|
|
678
|
+
// `export 'src/x.dart';`, `part 'foo.g.dart';`, `part of 'main.dart';`
|
|
679
|
+
// All four are recorded; resolveImport handles `dart:` / `package:` /
|
|
680
|
+
// relative resolution. The `part of` directive lands here too — its
|
|
681
|
+
// spec points back at the parent library file, which is a legitimate
|
|
682
|
+
// dependency edge in our graph.
|
|
683
|
+
function parseDart(content) {
|
|
684
|
+
const imports = []
|
|
685
|
+
const re = /^\s*(import|export|part(?:\s+of)?)\s+['"]([^'"]+)['"]/gm
|
|
686
|
+
let m
|
|
687
|
+
while ((m = re.exec(content))) {
|
|
688
|
+
const kw = m[1].split(/\s+/)[0] // 'part of' → 'part'
|
|
689
|
+
imports.push({ spec: m[2], kind: kw })
|
|
690
|
+
}
|
|
691
|
+
return { imports }
|
|
692
|
+
}
|
|
693
|
+
|
|
694
|
+
// PowerShell — `. ./path.ps1` (dot-source), `Import-Module Name`,
|
|
695
|
+
// `Import-Module ./local/Mod.psm1`, `using module ./path`
|
|
696
|
+
function parsePS1(content) {
|
|
697
|
+
const imports = []
|
|
698
|
+
// dot-source: . <path>
|
|
699
|
+
const dotRe = /^\s*\.\s+(?:&\s*)?(['"]?)([^\s'";|]+)\1/gm
|
|
700
|
+
let m
|
|
701
|
+
while ((m = dotRe.exec(content))) {
|
|
702
|
+
if (m[2] && m[2] !== '..') imports.push({ spec: m[2], kind: 'source' })
|
|
703
|
+
}
|
|
704
|
+
// Import-Module Name [-Name] (handles -Name flag)
|
|
705
|
+
const impRe = /(?:Import-Module|using\s+module)\s+(?:-Name\s+)?(['"]?)([^\s'";|,]+)\1/gi
|
|
706
|
+
while ((m = impRe.exec(content))) {
|
|
707
|
+
imports.push({ spec: m[2], kind: 'import-module' })
|
|
708
|
+
}
|
|
709
|
+
return { imports }
|
|
710
|
+
}
|
|
711
|
+
|
|
712
|
+
// Clojure / Scheme — `(require '[ns :as alias])`, `(use 'ns)`,
|
|
713
|
+
// `(:require [ns ...] [ns2 ...])`, `(:use ns)`
|
|
714
|
+
function parseClojure(content) {
|
|
715
|
+
const imports = []
|
|
716
|
+
// Step 1: locate each require/use/import form's body.
|
|
717
|
+
const formRe = /\((?::?(?:require|use|import))\s+([\s\S]*?)\)/g
|
|
718
|
+
let block
|
|
719
|
+
while ((block = formRe.exec(content))) {
|
|
720
|
+
const body = block[1]
|
|
721
|
+
// Step 2a: vector form — `[ns :as alias]` or `[ns :refer [...]]`
|
|
722
|
+
const vecRe = /\[\s*([a-zA-Z][\w.\-]*)/g
|
|
723
|
+
let v
|
|
724
|
+
while ((v = vecRe.exec(body))) imports.push({ spec: v[1], kind: 'require' })
|
|
725
|
+
// Step 2b: quoted symbol form — `'ns`
|
|
726
|
+
const symRe = /'([a-zA-Z][\w.\-]+)/g
|
|
727
|
+
let s
|
|
728
|
+
while ((s = symRe.exec(body))) imports.push({ spec: s[1], kind: 'require' })
|
|
729
|
+
}
|
|
730
|
+
return { imports }
|
|
731
|
+
}
|
|
732
|
+
|
|
733
|
+
// reStructuredText — `.. include:: path`, `.. literalinclude:: path`,
|
|
734
|
+
// `:doc:\`path\``
|
|
735
|
+
function parseRst(content) {
|
|
736
|
+
const imports = []
|
|
737
|
+
const incRe = /^\.\.\s*(?:include|literalinclude|figure|image)\s*::\s*(\S+)/gm
|
|
738
|
+
let m
|
|
739
|
+
while ((m = incRe.exec(content))) imports.push({ spec: m[1], kind: 'include' })
|
|
740
|
+
// :doc:`path` cross-reference
|
|
741
|
+
const docRe = /:doc:`([^`<]+)`/g
|
|
742
|
+
while ((m = docRe.exec(content))) {
|
|
743
|
+
imports.push({ spec: m[1].trim(), kind: 'doc' })
|
|
744
|
+
}
|
|
745
|
+
return { imports }
|
|
746
|
+
}
|
|
747
|
+
|
|
748
|
+
function parseGeneric(content) {
|
|
749
|
+
// Last-resort: catch obviously-relative path-looking string literals
|
|
750
|
+
// (kept restrictive to avoid noise)
|
|
751
|
+
return { imports: [] }
|
|
752
|
+
}
|
|
753
|
+
|
|
754
|
+
// ─── Full-stack: route + API call extraction ────────────────
|
|
755
|
+
//
|
|
756
|
+
// We do NOT try to perfectly understand every framework. We capture the
|
|
757
|
+
// dominant patterns (Express/Fastify/Koa/Hono on Node, Flask/FastAPI on
|
|
758
|
+
// Python) with regex. Anything trickier (route mounting via app.use,
|
|
759
|
+
// generated routes, file-based routing) is out of scope — false positives
|
|
760
|
+
// are filtered later because we only emit an edge if both sides exist.
|
|
761
|
+
|
|
762
|
+
const HTTP_METHODS = ['get','post','put','patch','delete','head','options','all']
|
|
763
|
+
|
|
764
|
+
function extractJSRoutes(content) {
|
|
765
|
+
const routes = []
|
|
766
|
+
// First pass: collect Express-style mount prefixes within this file.
|
|
767
|
+
// Example:
|
|
768
|
+
// const usersRouter = express.Router()
|
|
769
|
+
// usersRouter.get('/list', ...) ← path is '/list'
|
|
770
|
+
// app.use('/api/users', usersRouter) ← gives prefix '/api/users'
|
|
771
|
+
// → emit one route { method:'GET', path:'/api/users/list' }
|
|
772
|
+
//
|
|
773
|
+
// Same-file only — cross-file router mount resolution would need
|
|
774
|
+
// a global pass (deferred).
|
|
775
|
+
const mountRe = /\b(?:app|server|router|api)\.use\s*\(\s*['"`](\/[^'"`]*)['"`]\s*,\s*(\w+)\s*\)/g
|
|
776
|
+
const mounts = new Map() // varName -> prefix
|
|
777
|
+
let mm
|
|
778
|
+
while ((mm = mountRe.exec(content))) {
|
|
779
|
+
const prefix = mm[1].replace(/\/$/, '') // strip trailing slash
|
|
780
|
+
mounts.set(mm[2], prefix)
|
|
781
|
+
}
|
|
782
|
+
|
|
783
|
+
// Method handlers — capture the receiver name too so we can resolve
|
|
784
|
+
// its mount prefix from `mounts`. The trailing capture grabs whatever
|
|
785
|
+
// follows the path string up to the closing `)` so we can pluck a
|
|
786
|
+
// named handler identifier (`getUserHandler` in
|
|
787
|
+
// `app.get('/u', getUserHandler)`).
|
|
788
|
+
const methodRe = new RegExp(
|
|
789
|
+
`\\b(\\w+)\\.(${HTTP_METHODS.join('|')})\\s*\\(\\s*['"\`]([^'"\`]+)['"\`]\\s*,([^)]*)\\)`,
|
|
790
|
+
'g'
|
|
791
|
+
)
|
|
792
|
+
// Only treat the call as a route if the receiver looks like a
|
|
793
|
+
// router/app variable. We accept:
|
|
794
|
+
// - well-known names: app, router, server, api, fastify, hono, express, route
|
|
795
|
+
// - any variable that is the target of a `<name>.use('/prefix', X)`
|
|
796
|
+
// mount (those *receive* sub-routes after mounting)
|
|
797
|
+
// - any variable assigned from `express.Router()` / `Router()` / `Hono()` / `new Hono()`
|
|
798
|
+
const knownReceivers = new Set(['app', 'router', 'server', 'api', 'fastify', 'hono', 'express', 'route'])
|
|
799
|
+
const factoryRe = /\b(?:const|let|var)\s+(\w+)\s*=\s*(?:new\s+)?(?:express\.Router|Router|Hono)\s*\(/g
|
|
800
|
+
let fm
|
|
801
|
+
while ((fm = factoryRe.exec(content))) knownReceivers.add(fm[1])
|
|
802
|
+
// mounted variables (X in app.use('/p', X)) are also valid receivers
|
|
803
|
+
for (const v of mounts.keys()) knownReceivers.add(v)
|
|
804
|
+
|
|
805
|
+
let m
|
|
806
|
+
while ((m = methodRe.exec(content))) {
|
|
807
|
+
const receiver = m[1]
|
|
808
|
+
if (!knownReceivers.has(receiver)) continue
|
|
809
|
+
const method = m[2].toUpperCase()
|
|
810
|
+
const localPath = m[3]
|
|
811
|
+
// If this receiver is a mounted router, prepend its prefix
|
|
812
|
+
const prefix = mounts.get(receiver) || ''
|
|
813
|
+
const full = prefix && localPath.startsWith('/')
|
|
814
|
+
? prefix + (localPath === '/' ? '' : localPath)
|
|
815
|
+
: localPath
|
|
816
|
+
// Pull a named handler identifier from the remaining args, if any.
|
|
817
|
+
// Catches `app.get('/u', getUser)` and `app.get('/u', auth, getUser)`
|
|
818
|
+
// but skips inline arrows / function expressions which can't be
|
|
819
|
+
// matched to a symbol anyway.
|
|
820
|
+
const handlerStr = (m[4] || '').trim()
|
|
821
|
+
let handler = null
|
|
822
|
+
const handlerMatch = handlerStr.match(/([A-Za-z_$][\w$]*)\s*$/)
|
|
823
|
+
if (handlerMatch && !/(=>|function|\{)/.test(handlerStr)) {
|
|
824
|
+
handler = handlerMatch[1]
|
|
825
|
+
}
|
|
826
|
+
routes.push({ method, path: full, handler })
|
|
827
|
+
}
|
|
828
|
+
// Fastify object form: fastify.route({ method: 'GET', url: '/users' })
|
|
829
|
+
const fastifyObjRe = /\.route\s*\(\s*\{[^}]*?method\s*:\s*['"`](\w+)['"`][^}]*?url\s*:\s*['"`]([^'"`]+)['"`]/g
|
|
830
|
+
while ((m = fastifyObjRe.exec(content))) {
|
|
831
|
+
routes.push({ method: m[1].toUpperCase(), path: m[2] })
|
|
832
|
+
}
|
|
833
|
+
return routes
|
|
834
|
+
}
|
|
835
|
+
|
|
836
|
+
// Generic "find every http(s)/ws(s) URL string in source" pass. Catches
|
|
837
|
+
// URLs that the specific apiCall extractors miss: variable-built URLs,
|
|
838
|
+
// SDK constants, WebSocket connects, HTML <img src=...>, comments-as-doc,
|
|
839
|
+
// etc. We grep liberally — false positives (e.g. README URLs in
|
|
840
|
+
// comments) are acceptable here because the goal is "what external hosts
|
|
841
|
+
// could this project ever talk to". The renderer/CLI groups by domain so
|
|
842
|
+
// duplicates collapse naturally.
|
|
843
|
+
const EXT_URL_RE = /https?:\/\/[A-Za-z0-9._~:/?#@!$&'()*+,;=%-]+|wss?:\/\/[A-Za-z0-9._~:/?#@!$&'()*+,;=%-]+/g
|
|
844
|
+
const URL_TRIM_RE = /[.,;:'"`)\]}>\\]+$/
|
|
845
|
+
// ─── DB schema extractors ─────────────────────────────────────
|
|
846
|
+
// Three ORM/schema dialects, all heuristic regex (no full parsing):
|
|
847
|
+
// - Prisma (.prisma) `model X { name Type }`
|
|
848
|
+
// - Drizzle (TS/JS) `pgTable('x', { ... })`
|
|
849
|
+
// - SQLAlchemy (Python) `class X(Base): __tablename__ = '...'`
|
|
850
|
+
// Returns: [{ kind, name, tableName?, fields: [{name,type}] }]
|
|
851
|
+
|
|
852
|
+
function parsePrismaSchema(content) {
|
|
853
|
+
const models = []
|
|
854
|
+
// model Foo { ... } and enum Foo { ... }
|
|
855
|
+
const re = /\b(model|enum)\s+(\w+)\s*\{([^}]*)\}/g
|
|
856
|
+
let m
|
|
857
|
+
while ((m = re.exec(content))) {
|
|
858
|
+
const kind = m[1] // 'model' | 'enum'
|
|
859
|
+
const name = m[2]
|
|
860
|
+
const fields = []
|
|
861
|
+
for (const raw of m[3].split('\n')) {
|
|
862
|
+
const line = raw.trim()
|
|
863
|
+
if (!line || line.startsWith('//') || line.startsWith('@@')) continue
|
|
864
|
+
// For enums, each non-empty line is a value
|
|
865
|
+
if (kind === 'enum') { fields.push({ name: line.split(/\s+/)[0], type: 'enum-value' }); continue }
|
|
866
|
+
const fm = line.match(/^(\w+)\s+(\w+)(\[\])?(\?)?/)
|
|
867
|
+
if (fm) fields.push({ name: fm[1], type: fm[2] + (fm[3] || '') + (fm[4] || '') })
|
|
868
|
+
}
|
|
869
|
+
models.push({ kind: 'prisma-' + kind, name, fields })
|
|
870
|
+
}
|
|
871
|
+
return models
|
|
872
|
+
}
|
|
873
|
+
|
|
874
|
+
function extractDrizzleTables(content) {
|
|
875
|
+
if (!/(?:pg|mysql|sqlite)Table\s*\(/.test(content)) return []
|
|
876
|
+
const tables = []
|
|
877
|
+
// pgTable('users', { id: serial('id').primaryKey(), ... })
|
|
878
|
+
// We grab the table name then walk braces manually to handle nested {}.
|
|
879
|
+
const re = /(?:export\s+(?:const|let|var)\s+(\w+)\s*=\s*)?(?:pg|mysql|sqlite)Table\s*\(\s*['"`](\w+)['"`]\s*,\s*\{/g
|
|
880
|
+
let m
|
|
881
|
+
while ((m = re.exec(content))) {
|
|
882
|
+
const varName = m[1] || m[2]
|
|
883
|
+
const tableName = m[2]
|
|
884
|
+
// Find matching }
|
|
885
|
+
const start = re.lastIndex
|
|
886
|
+
let depth = 1, i = start
|
|
887
|
+
while (i < content.length && depth > 0) {
|
|
888
|
+
const c = content[i]
|
|
889
|
+
if (c === '{') depth++
|
|
890
|
+
else if (c === '}') depth--
|
|
891
|
+
i++
|
|
892
|
+
}
|
|
893
|
+
const body = content.slice(start, i - 1)
|
|
894
|
+
const fields = []
|
|
895
|
+
const fre = /(\w+)\s*:\s*(\w+)\(/g
|
|
896
|
+
let fm
|
|
897
|
+
while ((fm = fre.exec(body))) fields.push({ name: fm[1], type: fm[2] })
|
|
898
|
+
tables.push({ kind: 'drizzle', name: varName, tableName, fields })
|
|
899
|
+
}
|
|
900
|
+
return tables
|
|
901
|
+
}
|
|
902
|
+
|
|
903
|
+
function extractSQLAlchemyModels(content) {
|
|
904
|
+
// class X(Base) or class X(db.Model) or class X(sa.orm.DeclarativeBase)
|
|
905
|
+
if (!/class\s+\w+\s*\(\s*(?:Base|db\.Model|DeclarativeBase|orm\.DeclarativeBase)/.test(content)) return []
|
|
906
|
+
const models = []
|
|
907
|
+
// Split on each `class ` start so the per-class body (including blank
|
|
908
|
+
// lines) is captured cleanly, rather than trying to write a regex
|
|
909
|
+
// that handles empty lines + dedent boundaries.
|
|
910
|
+
const chunks = content.split(/(?=^class\s)/m)
|
|
911
|
+
for (const chunk of chunks) {
|
|
912
|
+
const head = chunk.match(/^class\s+(\w+)\s*\(\s*(?:Base|db\.Model|sa\.orm\.DeclarativeBase|DeclarativeBase|orm\.DeclarativeBase)[^)]*\)\s*:/)
|
|
913
|
+
if (!head) continue
|
|
914
|
+
const name = head[1]
|
|
915
|
+
const body = chunk.slice(head[0].length)
|
|
916
|
+
const fields = []
|
|
917
|
+
const fre = /^\s+(\w+)\s*(?::\s*[\w[\],\s]+)?\s*=\s*(?:Column|mapped_column|Mapped|relationship)\s*\(\s*(\w+)?/gm
|
|
918
|
+
let fm
|
|
919
|
+
while ((fm = fre.exec(body))) fields.push({ name: fm[1], type: fm[2] || 'unknown' })
|
|
920
|
+
let tableName = null
|
|
921
|
+
const tn = body.match(/__tablename__\s*=\s*['"](\w+)['"]/)
|
|
922
|
+
if (tn) tableName = tn[1]
|
|
923
|
+
// Skip empty marker classes (like a project's own `class Base(DeclarativeBase)`)
|
|
924
|
+
if (fields.length === 0 && !tableName) continue
|
|
925
|
+
models.push({ kind: 'sqlalchemy', name, tableName, fields })
|
|
926
|
+
}
|
|
927
|
+
return models
|
|
928
|
+
}
|
|
929
|
+
|
|
930
|
+
// Cross-language / FFI imports — point at a non-source artefact
|
|
931
|
+
// that another file in the repo provides (WASM / .node addon /
|
|
932
|
+
// shared library). Returns extra `imports` entries with
|
|
933
|
+
// kind: 'ffi' so the regular resolver can link them when the
|
|
934
|
+
// target file is indexed.
|
|
935
|
+
function extractJsFfi(content) {
|
|
936
|
+
const out = []
|
|
937
|
+
// WebAssembly: fetch('x.wasm') / readFileSync('x.wasm')
|
|
938
|
+
// WebAssembly.compile(await fetch('x.wasm').then(r=>r.arrayBuffer()))
|
|
939
|
+
const wasmRe = /['"`]([^'"`]+\.wasm)['"`]/g
|
|
940
|
+
let m
|
|
941
|
+
while ((m = wasmRe.exec(content))) out.push({ spec: m[1], kind: 'ffi' })
|
|
942
|
+
// Native node addons: require('./build/Release/foo.node')
|
|
943
|
+
const nodeRe = /['"`]([^'"`]+\.node)['"`]/g
|
|
944
|
+
while ((m = nodeRe.exec(content))) out.push({ spec: m[1], kind: 'ffi' })
|
|
945
|
+
// node-bindings: require('bindings')('foo')
|
|
946
|
+
const bindingsRe = /require\s*\(\s*['"`]bindings['"`]\s*\)\s*\(\s*['"`]([^'"`]+)['"`]/g
|
|
947
|
+
while ((m = bindingsRe.exec(content))) out.push({ spec: m[1] + '.node', kind: 'ffi' })
|
|
948
|
+
return out
|
|
949
|
+
}
|
|
950
|
+
|
|
951
|
+
function extractPyFfi(content) {
|
|
952
|
+
const out = []
|
|
953
|
+
// ctypes: ctypes.CDLL('./libfoo.so') / CDLL('foo.dylib')
|
|
954
|
+
const dllRe = /(?:ctypes\.)?CDLL\s*\(\s*['"]([^'"]+)['"]\s*\)/g
|
|
955
|
+
let m
|
|
956
|
+
while ((m = dllRe.exec(content))) out.push({ spec: m[1], kind: 'ffi' })
|
|
957
|
+
// cffi: ffi.dlopen('foo.so')
|
|
958
|
+
const cffiRe = /\.dlopen\s*\(\s*['"]([^'"]+)['"]\s*\)/g
|
|
959
|
+
while ((m = cffiRe.exec(content))) out.push({ spec: m[1], kind: 'ffi' })
|
|
960
|
+
// CPython convention: `import _foo` / `from _foo import ...`
|
|
961
|
+
// (single leading underscore => compiled extension)
|
|
962
|
+
const extRe = /^(?:from\s+_(\w+)\s+import|import\s+_(\w+))/gm
|
|
963
|
+
while ((m = extRe.exec(content))) {
|
|
964
|
+
const mod = m[1] || m[2]
|
|
965
|
+
if (mod) out.push({ spec: '_' + mod, kind: 'ffi' })
|
|
966
|
+
}
|
|
967
|
+
return out
|
|
968
|
+
}
|
|
969
|
+
|
|
970
|
+
// Java JNI: System.loadLibrary("foo") → foo.so / foo.dll target
|
|
971
|
+
function extractJavaFfi(content) {
|
|
972
|
+
const out = []
|
|
973
|
+
const re = /System\.loadLibrary\s*\(\s*"([^"]+)"\s*\)/g
|
|
974
|
+
let m
|
|
975
|
+
while ((m = re.exec(content))) out.push({ spec: m[1], kind: 'ffi' })
|
|
976
|
+
return out
|
|
977
|
+
}
|
|
978
|
+
|
|
979
|
+
// Rust: extern "C" { fn foo(...); } blocks signal FFI boundary.
|
|
980
|
+
// We just count the block existence as a coarse marker.
|
|
981
|
+
function extractRustFfi(content) {
|
|
982
|
+
const out = []
|
|
983
|
+
const re = /extern\s+"C"\s*\{/g
|
|
984
|
+
let count = 0
|
|
985
|
+
while (re.exec(content)) count++
|
|
986
|
+
if (count > 0) out.push({ spec: 'extern-c', kind: 'ffi-marker', count })
|
|
987
|
+
return out
|
|
988
|
+
}
|
|
989
|
+
|
|
990
|
+
function extractMongooseModels(content) {
|
|
991
|
+
if (!/\bmongoose\b/.test(content)) return []
|
|
992
|
+
const models = []
|
|
993
|
+
// `const User = mongoose.model('User', userSchema)`
|
|
994
|
+
// or `mongoose.model('User', new Schema({...}))`
|
|
995
|
+
const re = /(?:(?:const|let|var)\s+(\w+)\s*=\s*)?mongoose\.model\s*\(\s*['"`](\w+)['"`]/g
|
|
996
|
+
let m
|
|
997
|
+
while ((m = re.exec(content))) {
|
|
998
|
+
models.push({ kind: 'mongoose', name: m[2], varName: m[1] || m[2], fields: [] })
|
|
999
|
+
}
|
|
1000
|
+
return models
|
|
1001
|
+
}
|
|
1002
|
+
|
|
1003
|
+
// TypeORM `@Entity()` + `class Foo { @Column() bar: string }` — best-effort
|
|
1004
|
+
function extractTypeOrmEntities(content) {
|
|
1005
|
+
if (!/@Entity\s*\(/.test(content)) return []
|
|
1006
|
+
const out = []
|
|
1007
|
+
const re = /@Entity\s*\(\s*(?:['"`](\w+)['"`])?\s*\)\s*(?:export\s+)?class\s+(\w+)/g
|
|
1008
|
+
let m
|
|
1009
|
+
while ((m = re.exec(content))) {
|
|
1010
|
+
out.push({ kind: 'typeorm', name: m[2], tableName: m[1] || null, fields: [] })
|
|
1011
|
+
}
|
|
1012
|
+
return out
|
|
1013
|
+
}
|
|
1014
|
+
|
|
1015
|
+
function extractDbModels(content, ext) {
|
|
1016
|
+
if (!content) return []
|
|
1017
|
+
if (ext === 'prisma') return parsePrismaSchema(content)
|
|
1018
|
+
if (['ts', 'tsx', 'js', 'jsx', 'mjs', 'cjs'].includes(ext)) {
|
|
1019
|
+
return [
|
|
1020
|
+
...extractDrizzleTables(content),
|
|
1021
|
+
...extractMongooseModels(content),
|
|
1022
|
+
...extractTypeOrmEntities(content),
|
|
1023
|
+
]
|
|
1024
|
+
}
|
|
1025
|
+
if (ext === 'py' || ext === 'pyw') return extractSQLAlchemyModels(content)
|
|
1026
|
+
return []
|
|
1027
|
+
}
|
|
1028
|
+
|
|
1029
|
+
// Extract environment-variable references — `process.env.X`,
|
|
1030
|
+
// `os.environ['X']`, `os.Getenv("X")`, shell `${X}`, etc. We require
|
|
1031
|
+
// the name to start with an uppercase letter and contain only
|
|
1032
|
+
// uppercase/digit/underscore to keep false positives low (no random
|
|
1033
|
+
// `process.env.foo` from minified output, etc).
|
|
1034
|
+
function extractEnvUsage(content, ext) {
|
|
1035
|
+
if (!content) return []
|
|
1036
|
+
const found = new Set()
|
|
1037
|
+
const patterns = [
|
|
1038
|
+
// JS / TS / Node / Vite / Next
|
|
1039
|
+
/process\.env\.([A-Z][A-Z0-9_]+)\b/g,
|
|
1040
|
+
/process\.env\[['"`]([A-Z][A-Z0-9_]+)['"`]\]/g,
|
|
1041
|
+
/import\.meta\.env\.([A-Z][A-Z0-9_]+)\b/g,
|
|
1042
|
+
// Python
|
|
1043
|
+
/os\.environ\[['"`]([A-Z][A-Z0-9_]+)['"`]\]/g,
|
|
1044
|
+
/os\.environ\.get\(\s*['"`]([A-Z][A-Z0-9_]+)['"`]/g,
|
|
1045
|
+
/os\.getenv\(\s*['"`]([A-Z][A-Z0-9_]+)['"`]/g,
|
|
1046
|
+
// Go / Java / Rust / Ruby / C
|
|
1047
|
+
/os\.Getenv\(\s*['"`]([A-Z][A-Z0-9_]+)['"`]/g,
|
|
1048
|
+
/System\.getenv\(\s*['"`]([A-Z][A-Z0-9_]+)['"`]/g,
|
|
1049
|
+
/env::var\(\s*['"`]([A-Z][A-Z0-9_]+)['"`]/g,
|
|
1050
|
+
/ENV\[['"`]([A-Z][A-Z0-9_]+)['"`]\]/g,
|
|
1051
|
+
/\bgetenv\(\s*['"`]([A-Z][A-Z0-9_]+)['"`]/g,
|
|
1052
|
+
]
|
|
1053
|
+
for (const re of patterns) {
|
|
1054
|
+
re.lastIndex = 0
|
|
1055
|
+
let m
|
|
1056
|
+
while ((m = re.exec(content))) found.add(m[1])
|
|
1057
|
+
}
|
|
1058
|
+
// Shell-style ${VAR} — only on shell-like files to avoid false hits
|
|
1059
|
+
// from JS template literals.
|
|
1060
|
+
if (ext === 'sh' || ext === 'bash' || ext === 'zsh' || ext === 'ps1' || ext === 'psm1') {
|
|
1061
|
+
const sh = /\$\{?([A-Z][A-Z0-9_]+)(?::-[^}]*)?\}?/g
|
|
1062
|
+
let m
|
|
1063
|
+
while ((m = sh.exec(content))) found.add(m[1])
|
|
1064
|
+
}
|
|
1065
|
+
return [...found]
|
|
1066
|
+
}
|
|
1067
|
+
|
|
1068
|
+
function extractExternalUrls(content) {
|
|
1069
|
+
const seen = new Map() // url -> count
|
|
1070
|
+
let m
|
|
1071
|
+
EXT_URL_RE.lastIndex = 0
|
|
1072
|
+
while ((m = EXT_URL_RE.exec(content))) {
|
|
1073
|
+
let url = m[0]
|
|
1074
|
+
// Strip trailing punctuation that the greedy regex captures from
|
|
1075
|
+
// surrounding prose: "see https://x.com." → "https://x.com"
|
|
1076
|
+
url = url.replace(URL_TRIM_RE, '')
|
|
1077
|
+
if (url.length < 12) continue // arbitrary minimum to drop "http://x"
|
|
1078
|
+
seen.set(url, (seen.get(url) || 0) + 1)
|
|
1079
|
+
}
|
|
1080
|
+
return [...seen.entries()].map(([url, count]) => ({ url, count }))
|
|
1081
|
+
}
|
|
1082
|
+
|
|
1083
|
+
function extractJSApiCalls(content) {
|
|
1084
|
+
const calls = []
|
|
1085
|
+
// fetch('/api/users', { method: 'POST' }) or fetch(`/api/${x}`)
|
|
1086
|
+
const fetchRe = /\bfetch\s*\(\s*['"`]([^'"`]+)['"`]\s*(?:,\s*\{([^}]*)\})?/g
|
|
1087
|
+
let m
|
|
1088
|
+
while ((m = fetchRe.exec(content))) {
|
|
1089
|
+
const url = m[1]
|
|
1090
|
+
if (!looksLikeUrl(url)) continue
|
|
1091
|
+
let method = 'GET'
|
|
1092
|
+
if (m[2]) {
|
|
1093
|
+
const mm = m[2].match(/method\s*:\s*['"`](\w+)['"`]/)
|
|
1094
|
+
if (mm) method = mm[1].toUpperCase()
|
|
1095
|
+
}
|
|
1096
|
+
calls.push({ method, url })
|
|
1097
|
+
}
|
|
1098
|
+
// axios.get('/api/users'), axios.post(...), got.get(...), etc.
|
|
1099
|
+
const axiosRe = new RegExp(
|
|
1100
|
+
`\\b(?:axios|got|ky|request|http|api|client)\\.(${HTTP_METHODS.join('|')})\\s*\\(\\s*['"\`]([^'"\`]+)['"\`]`,
|
|
1101
|
+
'g'
|
|
1102
|
+
)
|
|
1103
|
+
while ((m = axiosRe.exec(content))) {
|
|
1104
|
+
const url = m[2]
|
|
1105
|
+
if (!looksLikeUrl(url)) continue
|
|
1106
|
+
calls.push({ method: m[1].toUpperCase(), url })
|
|
1107
|
+
}
|
|
1108
|
+
// axios({ url: '/api/x', method: 'POST' }) / axios.request({ url, method })
|
|
1109
|
+
const axiosObjRe = /\b(?:axios|got|ky|request|http|api|client)(?:\.request)?\s*\(\s*\{([^}]*)\}/g
|
|
1110
|
+
while ((m = axiosObjRe.exec(content))) {
|
|
1111
|
+
const body = m[1]
|
|
1112
|
+
const um = body.match(/url\s*:\s*['"`]([^'"`]+)['"`]/)
|
|
1113
|
+
if (!um) continue
|
|
1114
|
+
const url = um[1]
|
|
1115
|
+
if (!looksLikeUrl(url)) continue
|
|
1116
|
+
let method = 'GET'
|
|
1117
|
+
const mm = body.match(/method\s*:\s*['"`](\w+)['"`]/)
|
|
1118
|
+
if (mm) method = mm[1].toUpperCase()
|
|
1119
|
+
calls.push({ method, url })
|
|
1120
|
+
}
|
|
1121
|
+
// SWR / React Query convenience: useFetch('/api/x'), useSWR('/api/x')
|
|
1122
|
+
const hookRe = /\buse(?:Fetch|SWR|Query|Data|AsyncData)\s*\(\s*['"`]([^'"`]+)['"`]/g
|
|
1123
|
+
while ((m = hookRe.exec(content))) {
|
|
1124
|
+
const url = m[1]
|
|
1125
|
+
if (!looksLikeUrl(url)) continue
|
|
1126
|
+
calls.push({ method: 'GET', url })
|
|
1127
|
+
}
|
|
1128
|
+
// Nuxt 3 / Nitro: $fetch('/api/x'), ofetch('/api/x')
|
|
1129
|
+
const nitroRe = /(?:\$fetch|\bofetch)\s*\(\s*['"`]([^'"`]+)['"`]\s*(?:,\s*\{([^}]*)\})?/g
|
|
1130
|
+
while ((m = nitroRe.exec(content))) {
|
|
1131
|
+
const url = m[1]
|
|
1132
|
+
if (!looksLikeUrl(url)) continue
|
|
1133
|
+
let method = 'GET'
|
|
1134
|
+
if (m[2]) {
|
|
1135
|
+
const mm = m[2].match(/method\s*:\s*['"`](\w+)['"`]/)
|
|
1136
|
+
if (mm) method = mm[1].toUpperCase()
|
|
1137
|
+
}
|
|
1138
|
+
calls.push({ method, url })
|
|
1139
|
+
}
|
|
1140
|
+
// Template literal with leading static prefix:
|
|
1141
|
+
// fetch(`/api/users/${id}`) → emit '/api/users/'
|
|
1142
|
+
// Route matching downstream treats it as a prefix that the route
|
|
1143
|
+
// regex must consume. Limited to fetch/$fetch + identifier.method to
|
|
1144
|
+
// bound false positives.
|
|
1145
|
+
const templateRe = /\b(?:fetch|\$fetch|ofetch|axios|got|ky|api|client|http|request)(?:\.\w+)?\s*\(\s*`(\/[\w/-]+\/)\$\{/g
|
|
1146
|
+
while ((m = templateRe.exec(content))) {
|
|
1147
|
+
const url = m[1] // e.g. '/api/users/'
|
|
1148
|
+
if (!looksLikeUrl(url) || url.length < 5) continue
|
|
1149
|
+
calls.push({ method: 'ANY', url, partial: true })
|
|
1150
|
+
}
|
|
1151
|
+
// ── SDK instance tracking (P2·4) ──────────────────────────
|
|
1152
|
+
// axios.create() / got.extend() / ky.create() / ofetch.create():
|
|
1153
|
+
// const myClient = axios.create({ baseURL: 'https://x' })
|
|
1154
|
+
// myClient.get('/users') ← treat the same as axios.get('/users')
|
|
1155
|
+
// We collect all such variable names first, then run a second method-
|
|
1156
|
+
// match pass using just those names. baseURL is intentionally ignored
|
|
1157
|
+
// (mixing http://prefix and /path is fuzzy; downstream route↔fetch
|
|
1158
|
+
// matcher only needs the relative path).
|
|
1159
|
+
const sdkInstanceRe = /\b(?:const|let|var)\s+(\w+)\s*=\s*(?:axios|got|ky|ofetch)\.(?:create|extend)\s*\(/g
|
|
1160
|
+
const sdkInstances = new Set()
|
|
1161
|
+
while ((m = sdkInstanceRe.exec(content))) sdkInstances.add(m[1])
|
|
1162
|
+
// Also: const api = useApi() / createApi() patterns common in Vue/React
|
|
1163
|
+
// (these resolve to axios-shaped instances)
|
|
1164
|
+
const factoryRe = /\b(?:const|let|var)\s+(\w+)\s*=\s*(?:useApi|createApi|createClient|useFetch)\s*\(/g
|
|
1165
|
+
while ((m = factoryRe.exec(content))) sdkInstances.add(m[1])
|
|
1166
|
+
for (const name of sdkInstances) {
|
|
1167
|
+
const reLiteral = new RegExp(`\\b${name}\\.(${HTTP_METHODS.join('|')})\\s*\\(\\s*['"\`]([^'"\`]+)['"\`]`, 'g')
|
|
1168
|
+
let mm
|
|
1169
|
+
while ((mm = reLiteral.exec(content))) {
|
|
1170
|
+
const url = mm[2]
|
|
1171
|
+
if (!looksLikeUrl(url)) continue
|
|
1172
|
+
calls.push({ method: mm[1].toUpperCase(), url, via: 'sdk-instance' })
|
|
1173
|
+
}
|
|
1174
|
+
// Template-literal prefix for the same instance
|
|
1175
|
+
const reTpl = new RegExp(`\\b${name}\\.(${HTTP_METHODS.join('|')})\\s*\\(\\s*\`(\\/[\\w/-]+\\/)\\$\\{`, 'g')
|
|
1176
|
+
while ((mm = reTpl.exec(content))) {
|
|
1177
|
+
calls.push({ method: mm[1].toUpperCase(), url: mm[2], via: 'sdk-instance', partial: true })
|
|
1178
|
+
}
|
|
1179
|
+
}
|
|
1180
|
+
// ── tRPC procedure calls (P2·4 — informational, no URL match) ──
|
|
1181
|
+
// trpc.users.list.useQuery() / trpc.posts.create.mutate(...)
|
|
1182
|
+
// We can't match these against server routes (URLs are implicit),
|
|
1183
|
+
// but record them for visibility under apiCalls with method='RPC'.
|
|
1184
|
+
const trpcRe = /\btrpc(?:\.[A-Za-z_][\w]*)+\.(?:useQuery|useMutation|query|mutate)\s*\(/g
|
|
1185
|
+
while ((m = trpcRe.exec(content))) {
|
|
1186
|
+
// extract "trpc.<a>.<b>....method" — keep the procedure path
|
|
1187
|
+
const segs = m[0].replace(/\s*\($/, '').split('.')
|
|
1188
|
+
// ['trpc', 'users', 'list', 'useQuery']
|
|
1189
|
+
const proc = segs.slice(1, -1).join('.') // 'users.list'
|
|
1190
|
+
if (proc) calls.push({ method: 'RPC', url: 'trpc:' + proc, via: 'trpc' })
|
|
1191
|
+
}
|
|
1192
|
+
// Dedup. Same (method, url) → keep the most informative entry:
|
|
1193
|
+
// an entry with `via: 'sdk-instance'` wins over the generic axiosRe
|
|
1194
|
+
// match (because sdk-instance correctly attributed it to the actual
|
|
1195
|
+
// SDK variable, not the bare keyword).
|
|
1196
|
+
const byKey = new Map()
|
|
1197
|
+
for (const c of calls) {
|
|
1198
|
+
const k = c.method + '|' + c.url
|
|
1199
|
+
const prev = byKey.get(k)
|
|
1200
|
+
if (!prev) { byKey.set(k, c); continue }
|
|
1201
|
+
// Prefer entries with via (sdk-instance > unmarked)
|
|
1202
|
+
if (!prev.via && c.via) byKey.set(k, c)
|
|
1203
|
+
}
|
|
1204
|
+
return [...byKey.values()]
|
|
1205
|
+
}
|
|
1206
|
+
|
|
1207
|
+
function extractPyRoutes(content) {
|
|
1208
|
+
const routes = []
|
|
1209
|
+
// Flask:
|
|
1210
|
+
// @app.route('/users', methods=['GET','POST'])
|
|
1211
|
+
// @app.get('/users')
|
|
1212
|
+
// @bp.route('/x')
|
|
1213
|
+
// FastAPI:
|
|
1214
|
+
// @app.get('/users')
|
|
1215
|
+
// @router.post('/users/{id}')
|
|
1216
|
+
const decRe = new RegExp(
|
|
1217
|
+
`@\\s*(?:[\\w]+)\\.(${HTTP_METHODS.join('|')}|route)\\s*\\(\\s*['"]([^'"]+)['"]([^)]*)\\)`,
|
|
1218
|
+
'g'
|
|
1219
|
+
)
|
|
1220
|
+
let m
|
|
1221
|
+
while ((m = decRe.exec(content))) {
|
|
1222
|
+
const verb = m[1].toLowerCase()
|
|
1223
|
+
const path = m[2]
|
|
1224
|
+
// Look one line below the decorator for `def handler_name(...)`
|
|
1225
|
+
// so the file-graph builder can link the route to its handler.
|
|
1226
|
+
const after = content.slice(m.index + m[0].length)
|
|
1227
|
+
const hm = after.match(/^[^\n]*\n\s*(?:async\s+)?def\s+(\w+)/)
|
|
1228
|
+
const handler = hm ? hm[1] : null
|
|
1229
|
+
if (verb === 'route') {
|
|
1230
|
+
const mm = m[3].match(/methods\s*=\s*\[([^\]]+)\]/i)
|
|
1231
|
+
if (mm) {
|
|
1232
|
+
const methods = [...mm[1].matchAll(/['"](\w+)['"]/g)].map(x => x[1].toUpperCase())
|
|
1233
|
+
for (const method of methods) routes.push({ method, path, handler })
|
|
1234
|
+
} else {
|
|
1235
|
+
routes.push({ method: 'GET', path, handler }) // Flask default
|
|
1236
|
+
}
|
|
1237
|
+
} else {
|
|
1238
|
+
routes.push({ method: verb.toUpperCase(), path, handler })
|
|
1239
|
+
}
|
|
1240
|
+
}
|
|
1241
|
+
return routes
|
|
1242
|
+
}
|
|
1243
|
+
|
|
1244
|
+
// Go / Gin / Echo / Chi router calls
|
|
1245
|
+
// r.GET("/users", getUsers)
|
|
1246
|
+
// router.POST("/u/:id", h.UpdateUser)
|
|
1247
|
+
// e.Any("/x", handler)
|
|
1248
|
+
function extractGoRoutes(content) {
|
|
1249
|
+
const routes = []
|
|
1250
|
+
const re = new RegExp(
|
|
1251
|
+
`\\b(?:r|router|engine|app|e|mux|api|grp|group)\\.(${HTTP_METHODS.join('|')}|Any|Handle|HandleFunc)\\s*\\(\\s*"([^"]+)"\\s*,([^)]*)\\)`,
|
|
1252
|
+
'gi'
|
|
1253
|
+
)
|
|
1254
|
+
let m
|
|
1255
|
+
while ((m = re.exec(content))) {
|
|
1256
|
+
const method = m[1].toUpperCase() === 'ANY' || m[1].toUpperCase() === 'HANDLE' || m[1].toUpperCase() === 'HANDLEFUNC' ? 'ANY' : m[1].toUpperCase()
|
|
1257
|
+
const path = m[2]
|
|
1258
|
+
const handlerStr = (m[3] || '').trim()
|
|
1259
|
+
const hm = handlerStr.match(/([A-Za-z_][\w.]*)\s*$/)
|
|
1260
|
+
let handler = null
|
|
1261
|
+
if (hm && !/(func\b|\{)/.test(handlerStr)) {
|
|
1262
|
+
// For receiver.Method form, take the rightmost segment
|
|
1263
|
+
handler = hm[1].split('.').pop()
|
|
1264
|
+
}
|
|
1265
|
+
routes.push({ method, path, handler })
|
|
1266
|
+
}
|
|
1267
|
+
return routes
|
|
1268
|
+
}
|
|
1269
|
+
|
|
1270
|
+
// Spring (Java) / NestJS (TS) / Quarkus (Java) annotation routes
|
|
1271
|
+
// @GetMapping("/users") public List<User> getUsers()
|
|
1272
|
+
// @RequestMapping(value="/u", method=RequestMethod.GET)
|
|
1273
|
+
// @Get('/users') getUsers(): User[]
|
|
1274
|
+
// @Post('/u') @Body() body
|
|
1275
|
+
function extractAnnotationRoutes(content) {
|
|
1276
|
+
const routes = []
|
|
1277
|
+
// Spring single-method mappings
|
|
1278
|
+
const springRe = /@(Get|Post|Put|Delete|Patch|Options|Head)Mapping\s*\(\s*(?:value\s*=\s*)?(?:"|')([^"']+)(?:"|')(?:[^)]*)\)\s*[\s\S]{0,200}?(?:public|private|protected)?\s*[\w<>\[\],?\s.]*\s+(\w+)\s*\(/g
|
|
1279
|
+
let m
|
|
1280
|
+
while ((m = springRe.exec(content))) {
|
|
1281
|
+
routes.push({ method: m[1].toUpperCase(), path: m[2], handler: m[3] })
|
|
1282
|
+
}
|
|
1283
|
+
// @RequestMapping(value="/x", method=RequestMethod.GET)
|
|
1284
|
+
const rmRe = /@RequestMapping\s*\([^)]*?value\s*=\s*"([^"]+)"[^)]*?method\s*=\s*RequestMethod\.(\w+)[^)]*\)\s*[\s\S]{0,200}?(?:public|private|protected)?\s*[\w<>\[\],?\s.]*\s+(\w+)\s*\(/g
|
|
1285
|
+
while ((m = rmRe.exec(content))) {
|
|
1286
|
+
routes.push({ method: m[2].toUpperCase(), path: m[1], handler: m[3] })
|
|
1287
|
+
}
|
|
1288
|
+
// NestJS-style decorators (TypeScript)
|
|
1289
|
+
// @Get('/users') getUsers() { ... }
|
|
1290
|
+
const nestRe = /@(Get|Post|Put|Delete|Patch|Options|Head|All)\s*\(\s*['"`]([^'"`]+)['"`][^)]*\)\s*(?:@\w+\s*\([^)]*\)\s*)*\s*(?:public|private|protected|async)?\s*(\w+)\s*\(/g
|
|
1291
|
+
while ((m = nestRe.exec(content))) {
|
|
1292
|
+
routes.push({ method: m[1].toUpperCase(), path: m[2], handler: m[3] })
|
|
1293
|
+
}
|
|
1294
|
+
return routes
|
|
1295
|
+
}
|
|
1296
|
+
|
|
1297
|
+
// File-system server-route extractors for popular meta-frameworks.
|
|
1298
|
+
// Each helper looks at the path id to decide if a file is a route,
|
|
1299
|
+
// then peeks at the content for method-specific exports.
|
|
1300
|
+
|
|
1301
|
+
const HTTP_VERBS_FS = ['GET', 'POST', 'PUT', 'PATCH', 'DELETE', 'HEAD', 'OPTIONS']
|
|
1302
|
+
|
|
1303
|
+
function methodsExportedFromContent(content) {
|
|
1304
|
+
const found = []
|
|
1305
|
+
for (const verb of HTTP_VERBS_FS) {
|
|
1306
|
+
const re = new RegExp(`export\\s+(?:async\\s+)?(?:function\\s+${verb}\\b|const\\s+${verb}\\s*=)`)
|
|
1307
|
+
if (re.test(content)) found.push(verb)
|
|
1308
|
+
}
|
|
1309
|
+
return found
|
|
1310
|
+
}
|
|
1311
|
+
function normalizeFsDynamic(p) {
|
|
1312
|
+
return p.replace(/\[\.\.\.(\w+)\]/g, '*').replace(/\[(\w+)\]/g, ':$1')
|
|
1313
|
+
}
|
|
1314
|
+
|
|
1315
|
+
// Next.js: src/app/api/<seg>/route.<ext> or src/pages/api/<seg>.<ext>
|
|
1316
|
+
export function extractNextApiRoutes(id, content) {
|
|
1317
|
+
if (!id || !content) return []
|
|
1318
|
+
let m, basePath
|
|
1319
|
+
if ((m = id.match(/^(?:src\/)?app\/(api\/.+)\/route\.(?:tsx|jsx|ts|js)$/))) {
|
|
1320
|
+
basePath = '/' + m[1]
|
|
1321
|
+
} else if ((m = id.match(/^(?:src\/)?pages\/(api\/.+)\.(?:tsx|jsx|ts|js)$/))) {
|
|
1322
|
+
basePath = '/' + m[1].replace(/\/index$/, '')
|
|
1323
|
+
}
|
|
1324
|
+
if (!basePath) return []
|
|
1325
|
+
basePath = normalizeFsDynamic(basePath)
|
|
1326
|
+
const methods = methodsExportedFromContent(content)
|
|
1327
|
+
if (methods.length > 0) return methods.map((method) => ({ method, path: basePath }))
|
|
1328
|
+
if (/export\s+default\s+(?:async\s+)?function/.test(content)) {
|
|
1329
|
+
return [{ method: 'ANY', path: basePath }]
|
|
1330
|
+
}
|
|
1331
|
+
return []
|
|
1332
|
+
}
|
|
1333
|
+
|
|
1334
|
+
// Nuxt 3 / Nitro: server/api/<seg>.<ext> or server/routes/<seg>.<ext>
|
|
1335
|
+
// Filename suffix can carry method: `foo.post.ts` → POST /api/foo
|
|
1336
|
+
// Otherwise body: `defineEventHandler({ method: 'X' })` or default ANY.
|
|
1337
|
+
export function extractNuxtServerRoutes(id, content) {
|
|
1338
|
+
if (!id || !content) return []
|
|
1339
|
+
const m = id.match(/^server\/(api|routes)\/(.+)\.(?:ts|js|mts|cts)$/)
|
|
1340
|
+
if (!m) return []
|
|
1341
|
+
let basePath = '/' + m[1] + '/' + m[2].replace(/\/index$/, '')
|
|
1342
|
+
// Method suffix in filename
|
|
1343
|
+
const suffixMatch = basePath.match(/^(.*)\.(get|post|put|patch|delete|head|options)$/)
|
|
1344
|
+
let methodFromName = null
|
|
1345
|
+
if (suffixMatch) { basePath = suffixMatch[1]; methodFromName = suffixMatch[2].toUpperCase() }
|
|
1346
|
+
basePath = normalizeFsDynamic(basePath)
|
|
1347
|
+
if (methodFromName) return [{ method: methodFromName, path: basePath }]
|
|
1348
|
+
// defineEventHandler({ method: 'POST', ... })
|
|
1349
|
+
const verbs = []
|
|
1350
|
+
const objRe = /defineEventHandler\s*\(\s*\{[^}]*?method\s*:\s*['"`](\w+)['"`]/g
|
|
1351
|
+
let mm
|
|
1352
|
+
while ((mm = objRe.exec(content))) verbs.push(mm[1].toUpperCase())
|
|
1353
|
+
if (verbs.length > 0) return verbs.map((method) => ({ method, path: basePath }))
|
|
1354
|
+
if (/(?:export\s+default\s+)?defineEventHandler/.test(content)) {
|
|
1355
|
+
return [{ method: 'ANY', path: basePath }]
|
|
1356
|
+
}
|
|
1357
|
+
return []
|
|
1358
|
+
}
|
|
1359
|
+
|
|
1360
|
+
// SvelteKit: src/routes/<seg>/+server.<ext> or src/routes/+server.<ext>
|
|
1361
|
+
export function extractSvelteKitServerRoutes(id, content) {
|
|
1362
|
+
if (!id || !content) return []
|
|
1363
|
+
let m, basePath
|
|
1364
|
+
if ((m = id.match(/^(?:src\/)?routes\/(.+)\/\+server\.(?:ts|js)$/))) {
|
|
1365
|
+
basePath = '/' + m[1]
|
|
1366
|
+
} else if (/^(?:src\/)?routes\/\+server\.(?:ts|js)$/.test(id)) {
|
|
1367
|
+
basePath = '/'
|
|
1368
|
+
}
|
|
1369
|
+
if (!basePath) return []
|
|
1370
|
+
basePath = normalizeFsDynamic(basePath)
|
|
1371
|
+
const methods = methodsExportedFromContent(content)
|
|
1372
|
+
if (methods.length === 0) return []
|
|
1373
|
+
return methods.map((method) => ({ method, path: basePath }))
|
|
1374
|
+
}
|
|
1375
|
+
|
|
1376
|
+
function extractPyApiCalls(content) {
|
|
1377
|
+
const calls = []
|
|
1378
|
+
// requests.get('http://...'), httpx.post('/x'), urllib...
|
|
1379
|
+
const re = new RegExp(
|
|
1380
|
+
`\\b(?:requests|httpx|aiohttp|urllib|httplib2|session)\\.(${HTTP_METHODS.join('|')})\\s*\\(\\s*['"]([^'"]+)['"]`,
|
|
1381
|
+
'g'
|
|
1382
|
+
)
|
|
1383
|
+
let m
|
|
1384
|
+
while ((m = re.exec(content))) {
|
|
1385
|
+
const url = m[2]
|
|
1386
|
+
if (!looksLikeUrl(url)) continue
|
|
1387
|
+
calls.push({ method: m[1].toUpperCase(), url })
|
|
1388
|
+
}
|
|
1389
|
+
// session.request('POST', '/x', ...) / client.request(method='POST', url='/x')
|
|
1390
|
+
const reqRe = /\b(?:requests|httpx|aiohttp|session|client)\.request\s*\(\s*['"](\w+)['"]\s*,\s*['"]([^'"]+)['"]/g
|
|
1391
|
+
while ((m = reqRe.exec(content))) {
|
|
1392
|
+
const url = m[2]
|
|
1393
|
+
if (!looksLikeUrl(url)) continue
|
|
1394
|
+
calls.push({ method: m[1].toUpperCase(), url })
|
|
1395
|
+
}
|
|
1396
|
+
return calls
|
|
1397
|
+
}
|
|
1398
|
+
|
|
1399
|
+
// Conservative URL filter: keep things that look like an HTTP path or
|
|
1400
|
+
// an absolute http(s) URL. Reject bare specifiers, file paths, anchors.
|
|
1401
|
+
function looksLikeUrl(s) {
|
|
1402
|
+
if (!s) return false
|
|
1403
|
+
if (s.startsWith('/') && !s.startsWith('//')) return true
|
|
1404
|
+
if (/^https?:\/\//i.test(s)) return true
|
|
1405
|
+
return false
|
|
1406
|
+
}
|
|
1407
|
+
|
|
1408
|
+
// Normalize an API call URL or route path into a comparable form:
|
|
1409
|
+
// strips origin, query, hash, trailing slash; lowercases method.
|
|
1410
|
+
// Returns the path string starting with "/".
|
|
1411
|
+
export function normalizeUrlPath(u) {
|
|
1412
|
+
if (!u) return ''
|
|
1413
|
+
let s = u.split('?')[0].split('#')[0].trim()
|
|
1414
|
+
// Strip protocol+host
|
|
1415
|
+
s = s.replace(/^https?:\/\/[^/]+/i, '')
|
|
1416
|
+
// Trim multiple slashes
|
|
1417
|
+
s = s.replace(/\/+/g, '/')
|
|
1418
|
+
// Ensure leading /
|
|
1419
|
+
if (!s.startsWith('/')) s = '/' + s
|
|
1420
|
+
// Trim trailing slash (except root)
|
|
1421
|
+
if (s.length > 1 && s.endsWith('/')) s = s.slice(0, -1)
|
|
1422
|
+
return s
|
|
1423
|
+
}
|
|
1424
|
+
|
|
1425
|
+
// Build a regex from a route path with dynamic segments.
|
|
1426
|
+
// Supports: :id, {id}, <id>, <int:id>, * → matches one path segment.
|
|
1427
|
+
export function routePathToRegex(p) {
|
|
1428
|
+
const norm = normalizeUrlPath(p)
|
|
1429
|
+
// Two-pass: replace dynamic placeholders with sentinels BEFORE we
|
|
1430
|
+
// escape regex metas, then re-substitute the sentinels with the
|
|
1431
|
+
// actual regex patterns. Avoids order-of-escaping headaches.
|
|
1432
|
+
const PARAM = ''
|
|
1433
|
+
const WILD = ''
|
|
1434
|
+
let s = norm
|
|
1435
|
+
.replace(/:[A-Za-z_][A-Za-z0-9_]*/g, PARAM)
|
|
1436
|
+
.replace(/\{[^}]+\}/g, PARAM)
|
|
1437
|
+
.replace(/<[^>]+>/g, PARAM)
|
|
1438
|
+
.replace(/\*/g, WILD)
|
|
1439
|
+
s = s.replace(/[.+?^$()|[\]\\{}]/g, '\\$&')
|
|
1440
|
+
s = s.replace(new RegExp(PARAM, 'g'), '[^/]+')
|
|
1441
|
+
s = s.replace(new RegExp(WILD, 'g'), '.*')
|
|
1442
|
+
return new RegExp('^' + s + '$')
|
|
1443
|
+
}
|
|
1444
|
+
|
|
1445
|
+
// ─── Path resolution ──────────────────────────────────────────
|
|
1446
|
+
const RESOLVE_EXTS = [
|
|
1447
|
+
'', '.js', '.jsx', '.ts', '.tsx', '.mjs', '.cjs',
|
|
1448
|
+
'.py', '.pyi',
|
|
1449
|
+
'.css', '.scss', '.sass', '.less',
|
|
1450
|
+
'.html', '.vue', '.svelte', '.astro',
|
|
1451
|
+
'.json', '.md', '.mdx', '.rst', '.svg',
|
|
1452
|
+
'.rs', '.go', '.java', '.kt', '.rb', '.php',
|
|
1453
|
+
'.cs', '.swift', '.dart',
|
|
1454
|
+
'.c', '.cc', '.cpp', '.h', '.hpp',
|
|
1455
|
+
'.lsp', '.dcl', '.clj', '.scm',
|
|
1456
|
+
'.sh', '.bash', '.ps1', '.psm1',
|
|
1457
|
+
]
|
|
1458
|
+
|
|
1459
|
+
const INDEX_FILES = [
|
|
1460
|
+
'index.js', 'index.jsx', 'index.ts', 'index.tsx',
|
|
1461
|
+
'index.mjs', 'index.cjs', 'index.html',
|
|
1462
|
+
'__init__.py', 'mod.rs', 'main.go',
|
|
1463
|
+
]
|
|
1464
|
+
|
|
1465
|
+
export function resolveImport(fromAbsPath, spec, rootAbs, validIds, fromExt) {
|
|
1466
|
+
if (!spec || typeof spec !== 'string') return null
|
|
1467
|
+
|
|
1468
|
+
// Strip query / hash
|
|
1469
|
+
spec = spec.split('?')[0].split('#')[0].trim()
|
|
1470
|
+
if (!spec) return null
|
|
1471
|
+
|
|
1472
|
+
// URL or data URI → skip
|
|
1473
|
+
if (/^(?:[a-z]+:)?\/\//i.test(spec) || spec.startsWith('data:')) return null
|
|
1474
|
+
|
|
1475
|
+
// CSS leading ~ (webpack alias) → strip
|
|
1476
|
+
if (spec.startsWith('~')) spec = spec.slice(1)
|
|
1477
|
+
|
|
1478
|
+
// Python relative dots (`.`, `..`, `.foo`)
|
|
1479
|
+
// .ipynb notebooks contain Python imports, treat them the same.
|
|
1480
|
+
if (/^(?:py|pyw|ipynb)$/.test(fromExt) && spec.startsWith('.')) {
|
|
1481
|
+
return resolvePythonRelative(fromAbsPath, spec, rootAbs, validIds)
|
|
1482
|
+
}
|
|
1483
|
+
|
|
1484
|
+
// Python dotted module (a.b.c) → search from the import root. Try flat
|
|
1485
|
+
// layout (package at repo root) AND src-layout (`src/<pkg>/`), which is the
|
|
1486
|
+
// modern standard and would otherwise be missed. (Only these two known
|
|
1487
|
+
// layouts — a broad suffix match would create false edges for stdlib/3rd-
|
|
1488
|
+
// party imports that happen to share an internal filename.)
|
|
1489
|
+
if ((fromExt === 'py' || fromExt === 'ipynb') && !spec.startsWith('.') && !spec.startsWith('/')) {
|
|
1490
|
+
const subPath = spec.replace(/\./g, '/')
|
|
1491
|
+
for (const pfx of ['', 'src/']) {
|
|
1492
|
+
for (const tail of [subPath + '.py', subPath + '/__init__.py']) {
|
|
1493
|
+
if (validIds.has(pfx + tail)) return pfx + tail
|
|
1494
|
+
}
|
|
1495
|
+
}
|
|
1496
|
+
return null
|
|
1497
|
+
}
|
|
1498
|
+
|
|
1499
|
+
// C# dotted namespace (System.Collections.Generic) → search.
|
|
1500
|
+
// Standard convention: namespace path matches directory path. We
|
|
1501
|
+
// try the full path first, then progressively shorter prefixes
|
|
1502
|
+
// (handles `using A.B.C` where the file is `A/B/C.cs` OR `A/B.cs`).
|
|
1503
|
+
if (fromExt === 'cs' && /^[A-Za-z]/.test(spec)) {
|
|
1504
|
+
const parts = spec.split('.')
|
|
1505
|
+
for (let i = parts.length; i >= 1; i--) {
|
|
1506
|
+
const sub = parts.slice(0, i).join('/')
|
|
1507
|
+
const cand = path.join(rootAbs, sub + '.cs')
|
|
1508
|
+
const id = idOf(rootAbs, cand)
|
|
1509
|
+
if (validIds.has(id)) return id
|
|
1510
|
+
}
|
|
1511
|
+
// Also try as a folder containing the namespace's .cs files
|
|
1512
|
+
for (let i = parts.length; i >= 1; i--) {
|
|
1513
|
+
const sub = parts.slice(0, i).join('/')
|
|
1514
|
+
// Just check for *any* .cs file inside (return the first match)
|
|
1515
|
+
for (const id of validIds) {
|
|
1516
|
+
if (id.startsWith(sub + '/') && id.endsWith('.cs')) return id
|
|
1517
|
+
}
|
|
1518
|
+
}
|
|
1519
|
+
// Fully-qualified `using Root.Sub` where the root namespace maps to the
|
|
1520
|
+
// scan root (not a directory) — drop leading segments and match the
|
|
1521
|
+
// suffix: `FluentValidation.Internal` → `Internal/…`.
|
|
1522
|
+
for (let start = 1; start < parts.length; start++) {
|
|
1523
|
+
const sub = parts.slice(start).join('/')
|
|
1524
|
+
const cand = idOf(rootAbs, path.join(rootAbs, sub + '.cs'))
|
|
1525
|
+
if (validIds.has(cand)) return cand
|
|
1526
|
+
for (const id of validIds) {
|
|
1527
|
+
if (id.startsWith(sub + '/') && id.endsWith('.cs')) return id
|
|
1528
|
+
}
|
|
1529
|
+
}
|
|
1530
|
+
return null
|
|
1531
|
+
}
|
|
1532
|
+
|
|
1533
|
+
// Dart — `dart:`/`package:` are external (SDK / pub.dev), skip them.
|
|
1534
|
+
// Everything else is a path string relative to the importing file's
|
|
1535
|
+
// directory: `import 'services/x.dart'` from `lib/main.dart` resolves
|
|
1536
|
+
// to `lib/services/x.dart`. Also handles `export` / `part` / `part of`.
|
|
1537
|
+
if (fromExt === 'dart') {
|
|
1538
|
+
if (spec.startsWith('dart:')) return null
|
|
1539
|
+
if (spec.startsWith('package:')) {
|
|
1540
|
+
// `package:<self>/x.dart` → lib/x.dart; other packages are external.
|
|
1541
|
+
const body = spec.slice('package:'.length)
|
|
1542
|
+
const slash = body.indexOf('/')
|
|
1543
|
+
const pkg = slash < 0 ? body : body.slice(0, slash)
|
|
1544
|
+
const sub = slash < 0 ? '' : body.slice(slash + 1)
|
|
1545
|
+
// `package:<name>/sub` → that workspace package's lib/sub (self OR sibling).
|
|
1546
|
+
const pkgs = loadDartPackages(rootAbs, validIds)
|
|
1547
|
+
const dir = pkgs.get(pkg)
|
|
1548
|
+
if (dir != null && sub) {
|
|
1549
|
+
const cand = (dir ? dir + '/' : '') + 'lib/' + sub
|
|
1550
|
+
if (validIds.has(cand)) return cand
|
|
1551
|
+
}
|
|
1552
|
+
return null
|
|
1553
|
+
}
|
|
1554
|
+
const fromDir = path.dirname(fromAbsPath)
|
|
1555
|
+
const cand = path.resolve(fromDir, spec)
|
|
1556
|
+
const id = idOf(rootAbs, cand)
|
|
1557
|
+
if (validIds.has(id)) return id
|
|
1558
|
+
return null
|
|
1559
|
+
}
|
|
1560
|
+
|
|
1561
|
+
// Swift `import ModuleName` → look for a top-level folder of that
|
|
1562
|
+
// name (Sources/ModuleName/ or just ModuleName/) and return one of
|
|
1563
|
+
// its .swift files. SwiftPM convention.
|
|
1564
|
+
if (fromExt === 'swift' && /^[A-Za-z]/.test(spec)) {
|
|
1565
|
+
const moduleName = spec.split('.')[0] // ignore .Submodule for now
|
|
1566
|
+
const patterns = [
|
|
1567
|
+
`Sources/${moduleName}/`,
|
|
1568
|
+
`${moduleName}/Sources/`,
|
|
1569
|
+
`${moduleName}/`,
|
|
1570
|
+
]
|
|
1571
|
+
for (const pat of patterns) {
|
|
1572
|
+
for (const id of validIds) {
|
|
1573
|
+
if (id.includes(pat) && id.endsWith('.swift')) return id
|
|
1574
|
+
}
|
|
1575
|
+
}
|
|
1576
|
+
return null
|
|
1577
|
+
}
|
|
1578
|
+
|
|
1579
|
+
// Go — `import "github.com/owner/repo/sub/pkg"`.
|
|
1580
|
+
//
|
|
1581
|
+
// Internal vs external is determined by reading the repo's go.mod once
|
|
1582
|
+
// and caching the module declaration; anything prefixed with that path
|
|
1583
|
+
// is internal and the suffix maps to a directory of .go files.
|
|
1584
|
+
if (fromExt === 'go' && /^[A-Za-z0-9_]/.test(spec)) {
|
|
1585
|
+
const loc = goImportLocation(spec, rootAbs, validIds)
|
|
1586
|
+
if (loc) {
|
|
1587
|
+
const prefix = (loc.dir ? loc.dir + '/' : '') + (loc.internalPath ? loc.internalPath + '/' : '')
|
|
1588
|
+
// A representative .go file in the target package dir (immediate child).
|
|
1589
|
+
for (const id of validIds) {
|
|
1590
|
+
if (id.startsWith(prefix) && id.endsWith('.go')
|
|
1591
|
+
&& !id.slice(prefix.length).includes('/')) return id
|
|
1592
|
+
}
|
|
1593
|
+
// Fallback: any .go anywhere under the dir
|
|
1594
|
+
for (const id of validIds) {
|
|
1595
|
+
if (id.startsWith(prefix) && id.endsWith('.go')) return id
|
|
1596
|
+
}
|
|
1597
|
+
}
|
|
1598
|
+
return null
|
|
1599
|
+
}
|
|
1600
|
+
|
|
1601
|
+
// Rust — `use crate::a::b::c`, `use self::x`, `use super::x`, `mod x;`.
|
|
1602
|
+
//
|
|
1603
|
+
// Maps the module path to `src/a/b/c.rs`, `src/a/b/c/mod.rs`, or for
|
|
1604
|
+
// `mod x;` resolved relative to the importing file's directory.
|
|
1605
|
+
if (fromExt === 'rs' && /^(?:::)?[A-Za-z_]/.test(spec)) { // allow leading `::` (absolute path)
|
|
1606
|
+
return resolveRustModule(fromAbsPath, spec, rootAbs, validIds)
|
|
1607
|
+
}
|
|
1608
|
+
|
|
1609
|
+
// Java / Kotlin — `import com.foo.bar.Baz` → look for any file ending
|
|
1610
|
+
// in `com/foo/bar/Baz.{java,kt}`. Source roots vary (`src/main/java/`,
|
|
1611
|
+
// `src/`, etc.), so we suffix-match instead of trying to enumerate
|
|
1612
|
+
// them. We also try shortening the FQN (Spring/Guava `import a.b.*`
|
|
1613
|
+
// wildcards end up as just `a.b` — match it as a directory).
|
|
1614
|
+
if ((fromExt === 'java' || fromExt === 'kt') && /^[A-Za-z_]/.test(spec)) {
|
|
1615
|
+
const cleaned = spec.replace(/\.\*$/, '') // strip wildcard
|
|
1616
|
+
const parts = cleaned.split('.')
|
|
1617
|
+
if (parts.length < 2) return null
|
|
1618
|
+
// Try Foo.java / Foo.kt (innermost segment as the class name)
|
|
1619
|
+
const exts = fromExt === 'kt' ? ['.kt', '.java'] : ['.java', '.kt']
|
|
1620
|
+
for (let i = parts.length; i >= 2; i--) {
|
|
1621
|
+
const tail = parts.slice(0, i).join('/')
|
|
1622
|
+
for (const ext of exts) {
|
|
1623
|
+
const suffix = '/' + tail + ext
|
|
1624
|
+
for (const id of validIds) {
|
|
1625
|
+
// endsWith('/'+...) misses files at the source root (id has no
|
|
1626
|
+
// leading segment, e.g. ROOT=src/main/java → id 'com/foo/Bar.java').
|
|
1627
|
+
if (id.endsWith(suffix) || id === tail + ext) return id
|
|
1628
|
+
}
|
|
1629
|
+
}
|
|
1630
|
+
}
|
|
1631
|
+
return null
|
|
1632
|
+
}
|
|
1633
|
+
|
|
1634
|
+
// PowerShell `Import-Module Name` → look for Name.psm1 / Name/Name.psm1
|
|
1635
|
+
if (fromExt === 'ps1' && /^[A-Za-z]/.test(spec) && !spec.includes('/')) {
|
|
1636
|
+
const candidates = [`${spec}.psm1`, `${spec}/${spec}.psm1`, `${spec}.ps1`]
|
|
1637
|
+
for (const cand of candidates) {
|
|
1638
|
+
const id = idOf(rootAbs, path.join(rootAbs, cand))
|
|
1639
|
+
if (validIds.has(id)) return id
|
|
1640
|
+
}
|
|
1641
|
+
return null
|
|
1642
|
+
}
|
|
1643
|
+
|
|
1644
|
+
// Clojure / Scheme dotted/dashed namespace → file path
|
|
1645
|
+
// Convention: `my.cool.ns` → `my/cool/ns.clj` (also `_` for `-`)
|
|
1646
|
+
if ((fromExt === 'clj' || fromExt === 'scm') && /^[a-z]/.test(spec)) {
|
|
1647
|
+
const subPath = spec.replace(/\./g, '/').replace(/-/g, '_')
|
|
1648
|
+
for (const ext of [`.${fromExt}`, '.cljc']) {
|
|
1649
|
+
const cand = path.join(rootAbs, subPath + ext)
|
|
1650
|
+
const id = idOf(rootAbs, cand)
|
|
1651
|
+
if (validIds.has(id)) return id
|
|
1652
|
+
}
|
|
1653
|
+
return null
|
|
1654
|
+
}
|
|
1655
|
+
|
|
1656
|
+
// PHP — `use Vendor\Pkg\Class;` (PSR-4) resolves like a Java FQN: turn the
|
|
1657
|
+
// namespace separators into slashes and suffix-match a `.php` file. The
|
|
1658
|
+
// leading `\`, `as` aliases and group braces were handled in parsePHP.
|
|
1659
|
+
// require/include string paths (no backslash) fall through to relative.
|
|
1660
|
+
if (fromExt === 'php' && spec.includes('\\')) {
|
|
1661
|
+
const fqcn = spec.replace(/^\\+/, '')
|
|
1662
|
+
// Authoritative composer PSR-4 map first.
|
|
1663
|
+
for (const { prefix, dir } of loadComposerPsr4(rootAbs, validIds)) {
|
|
1664
|
+
if (fqcn === prefix || fqcn.startsWith(prefix + '\\')) {
|
|
1665
|
+
const rest = fqcn.slice(prefix.length).replace(/^\\+/, '').replace(/\\/g, '/')
|
|
1666
|
+
const cand = (rest ? (dir ? dir + '/' + rest : rest) : dir) + '.php'
|
|
1667
|
+
if (validIds.has(cand)) return cand
|
|
1668
|
+
}
|
|
1669
|
+
}
|
|
1670
|
+
// Fallback: suffix match (no composer.json, or unmapped namespace).
|
|
1671
|
+
const rel = fqcn.replace(/\\/g, '/') + '.php'
|
|
1672
|
+
if (validIds.has(rel)) return rel
|
|
1673
|
+
for (const id of validIds) {
|
|
1674
|
+
if (id.endsWith('/' + rel)) return id
|
|
1675
|
+
}
|
|
1676
|
+
return null
|
|
1677
|
+
}
|
|
1678
|
+
|
|
1679
|
+
// Ruby — `require_relative "x"` is relative to the requiring file; `require
|
|
1680
|
+
// "gem/x"` is resolved against the load path (conventionally lib/). Try
|
|
1681
|
+
// both, then a `/x.rb` suffix match. Stdlib/gems (`require "json"`) match
|
|
1682
|
+
// nothing → null.
|
|
1683
|
+
if (fromExt === 'rb') {
|
|
1684
|
+
const relTry = tryResolve(path.resolve(path.dirname(fromAbsPath), spec), rootAbs, validIds)
|
|
1685
|
+
if (relTry) return relTry
|
|
1686
|
+
const want = spec.replace(/\.rb$/, '') + '.rb'
|
|
1687
|
+
if (validIds.has(want)) return want
|
|
1688
|
+
if (validIds.has('lib/' + want)) return 'lib/' + want
|
|
1689
|
+
for (const id of validIds) {
|
|
1690
|
+
if (id.endsWith('/' + want)) return id
|
|
1691
|
+
}
|
|
1692
|
+
return null
|
|
1693
|
+
}
|
|
1694
|
+
|
|
1695
|
+
// Relative path
|
|
1696
|
+
if (spec.startsWith('.') || spec.startsWith('/')) {
|
|
1697
|
+
const fromDir = path.dirname(fromAbsPath)
|
|
1698
|
+
const base = spec.startsWith('/')
|
|
1699
|
+
? path.join(rootAbs, spec)
|
|
1700
|
+
: path.resolve(fromDir, spec)
|
|
1701
|
+
return tryResolve(base, rootAbs, validIds)
|
|
1702
|
+
}
|
|
1703
|
+
|
|
1704
|
+
// HTML / CSS / Markdown / SCSS-style refs commonly omit the `./` prefix.
|
|
1705
|
+
// Treat path-like specifiers (containing `/` or having an extension) as
|
|
1706
|
+
// relative from the source file when the source is one of those formats.
|
|
1707
|
+
const PATH_LIKE_SOURCES = new Set([
|
|
1708
|
+
'html', 'htm', 'css', 'scss', 'sass', 'less', 'md', 'mdx', 'svelte', 'vue',
|
|
1709
|
+
// C/C++: `#include "x.h"` omits the ./ and is relative to the file's dir
|
|
1710
|
+
// (or found via an -I dir — handled by the basename fallback below).
|
|
1711
|
+
'c', 'cc', 'cpp', 'h', 'hpp',
|
|
1712
|
+
])
|
|
1713
|
+
if (PATH_LIKE_SOURCES.has(fromExt) && (spec.includes('/') || spec.includes('.'))) {
|
|
1714
|
+
const fromDir = path.dirname(fromAbsPath)
|
|
1715
|
+
const relTry = tryResolve(path.resolve(fromDir, spec), rootAbs, validIds)
|
|
1716
|
+
if (relTry) return relTry
|
|
1717
|
+
// Also try as if it were rooted (e.g. /assets/x.png in HTML)
|
|
1718
|
+
const rootTry = tryResolve(path.join(rootAbs, spec), rootAbs, validIds)
|
|
1719
|
+
if (rootTry) return rootTry
|
|
1720
|
+
// C/C++ headers are frequently resolved through -I include dirs; fall back
|
|
1721
|
+
// to a unique basename match anywhere in the tree.
|
|
1722
|
+
if (fromExt === 'c' || fromExt === 'cc' || fromExt === 'cpp' || fromExt === 'h' || fromExt === 'hpp') {
|
|
1723
|
+
const base = spec.split('/').pop()
|
|
1724
|
+
for (const id of validIds) {
|
|
1725
|
+
if (id === base || id.endsWith('/' + base)) return id
|
|
1726
|
+
}
|
|
1727
|
+
}
|
|
1728
|
+
return null
|
|
1729
|
+
}
|
|
1730
|
+
|
|
1731
|
+
// Before giving up on a bare specifier, try TypeScript path mapping
|
|
1732
|
+
// (`@excalidraw/common` → `./packages/common/src/index.ts`) for any
|
|
1733
|
+
// ext that uses tsconfig — JS/TS, plus jsconfig for plain JS.
|
|
1734
|
+
if (['ts', 'tsx', 'js', 'jsx', 'mjs', 'cjs'].includes(fromExt)) {
|
|
1735
|
+
const r = resolveTsconfigPath(spec, rootAbs, validIds)
|
|
1736
|
+
if (r) return r
|
|
1737
|
+
}
|
|
1738
|
+
|
|
1739
|
+
// Monorepo workspace package — `import x from '@scope/utils'` (or a subpath)
|
|
1740
|
+
// where `utils` is a SIBLING workspace package (by its package.json name).
|
|
1741
|
+
// Resolve to that package's source entry / subpath, not node_modules.
|
|
1742
|
+
if (['ts', 'tsx', 'js', 'jsx', 'mjs', 'cjs', 'mts', 'cts', 'vue', 'svelte', 'astro'].includes(fromExt)
|
|
1743
|
+
&& /^(?:@[\w.-]+\/)?[\w.-]/.test(spec)) {
|
|
1744
|
+
const ws = loadJsWorkspace(rootAbs, validIds)
|
|
1745
|
+
let best = null
|
|
1746
|
+
for (const name of ws.keys()) {
|
|
1747
|
+
if ((spec === name || spec.startsWith(name + '/')) && (!best || name.length > best.length)) best = name
|
|
1748
|
+
}
|
|
1749
|
+
if (best) {
|
|
1750
|
+
const { dir, entry } = ws.get(best)
|
|
1751
|
+
const tryAt = (rel) => rel == null ? null : tryResolve(path.join(rootAbs, rel.split('/').join(path.sep)), rootAbs, validIds)
|
|
1752
|
+
const pfx = dir ? dir + '/' : ''
|
|
1753
|
+
if (spec === best) {
|
|
1754
|
+
const r = tryAt(entry ? pfx + entry.replace(/^\.\//, '') : null) || tryAt(pfx + 'src') || tryAt(dir || '.')
|
|
1755
|
+
if (r) return r
|
|
1756
|
+
} else {
|
|
1757
|
+
const sub = spec.slice(best.length + 1)
|
|
1758
|
+
const r = tryAt(pfx + 'src/' + sub) || tryAt(pfx + sub)
|
|
1759
|
+
if (r) return r
|
|
1760
|
+
}
|
|
1761
|
+
}
|
|
1762
|
+
}
|
|
1763
|
+
|
|
1764
|
+
// Bare specifier → either an external package or an unresolvable alias.
|
|
1765
|
+
return null
|
|
1766
|
+
}
|
|
1767
|
+
|
|
1768
|
+
// Like resolveImport, but for languages where ONE import pulls in an entire
|
|
1769
|
+
// module/namespace, returns EVERY file in that unit instead of a single
|
|
1770
|
+
// representative — so blast/deps are file-complete. All other languages (and
|
|
1771
|
+
// file-precise imports) return the single resolveImport result as a 1-array,
|
|
1772
|
+
// so the scanner's behaviour is unchanged for them.
|
|
1773
|
+
export function resolveImportAll(fromAbsPath, spec, rootAbs, validIds, fromExt) {
|
|
1774
|
+
spec = (spec || '').split('?')[0].split('#')[0].trim()
|
|
1775
|
+
if (!spec) return []
|
|
1776
|
+
|
|
1777
|
+
// Swift: `import Module` → every .swift under that module's source dir
|
|
1778
|
+
// (a module spans all files in Sources/<Module>/, recursively).
|
|
1779
|
+
if (fromExt === 'swift' && /^[A-Za-z_]/.test(spec)) {
|
|
1780
|
+
const m = spec.split('.')[0]
|
|
1781
|
+
for (const pat of [`Sources/${m}/`, `${m}/Sources/`, `${m}/`]) {
|
|
1782
|
+
const hits = [...validIds].filter((id) => id.includes(pat) && id.endsWith('.swift'))
|
|
1783
|
+
if (hits.length) return hits
|
|
1784
|
+
}
|
|
1785
|
+
return []
|
|
1786
|
+
}
|
|
1787
|
+
|
|
1788
|
+
// C#: `using A.B` → every .cs file that DECLARES namespace A.B. Resolved via
|
|
1789
|
+
// the namespace index (declarations), with C#'s relative lookup through the
|
|
1790
|
+
// importing file's enclosing namespaces. Falls back to a folder match if the
|
|
1791
|
+
// project has no parseable namespace declarations.
|
|
1792
|
+
if (fromExt === 'cs' && /^[A-Za-z]/.test(spec)) {
|
|
1793
|
+
const { nsToFiles, fileToNs } = loadCsNamespaceIndex(rootAbs, validIds)
|
|
1794
|
+
if (nsToFiles.size) {
|
|
1795
|
+
const fromId = idOf(rootAbs, fromAbsPath)
|
|
1796
|
+
const base = (fileToNs.get(fromId) || '').split('.').filter(Boolean)
|
|
1797
|
+
const want = spec.split('.')
|
|
1798
|
+
for (let i = base.length; i >= 0; i--) { // innermost enclosing first
|
|
1799
|
+
const cand = base.slice(0, i).concat(want).join('.')
|
|
1800
|
+
if (nsToFiles.has(cand)) return [...nsToFiles.get(cand)].filter((id) => id !== fromId)
|
|
1801
|
+
}
|
|
1802
|
+
return []
|
|
1803
|
+
}
|
|
1804
|
+
// Fallback: mirrored folder, immediate children only.
|
|
1805
|
+
const parts = spec.split('.')
|
|
1806
|
+
const folders = []
|
|
1807
|
+
for (let i = parts.length; i >= 1; i--) folders.push(parts.slice(0, i).join('/'))
|
|
1808
|
+
for (let s = 1; s < parts.length; s++) folders.push(parts.slice(s).join('/'))
|
|
1809
|
+
for (const sub of folders) {
|
|
1810
|
+
const hits = [...validIds].filter((id) =>
|
|
1811
|
+
id.endsWith('.cs') && id.startsWith(sub + '/') && !id.slice(sub.length + 1).includes('/'))
|
|
1812
|
+
if (hits.length) return hits
|
|
1813
|
+
}
|
|
1814
|
+
return []
|
|
1815
|
+
}
|
|
1816
|
+
|
|
1817
|
+
// Go: `import "mod/sub/pkg"` → every .go file in that package directory
|
|
1818
|
+
// (a Go package is the whole directory; importing it pulls in all its files).
|
|
1819
|
+
if (fromExt === 'go' && /^[A-Za-z0-9_]/.test(spec)) {
|
|
1820
|
+
const loc = goImportLocation(spec, rootAbs, validIds)
|
|
1821
|
+
if (!loc) return []
|
|
1822
|
+
const prefix = (loc.dir ? loc.dir + '/' : '') + (loc.internalPath ? loc.internalPath + '/' : '')
|
|
1823
|
+
return [...validIds].filter((id) =>
|
|
1824
|
+
id.startsWith(prefix) && id.endsWith('.go') && !id.slice(prefix.length).includes('/'))
|
|
1825
|
+
}
|
|
1826
|
+
|
|
1827
|
+
const one = resolveImport(fromAbsPath, spec, rootAbs, validIds, fromExt)
|
|
1828
|
+
return one ? [one] : []
|
|
1829
|
+
}
|
|
1830
|
+
|
|
1831
|
+
function tryResolve(basePath, rootAbs, validIds) {
|
|
1832
|
+
// If basePath itself is an indexed file (e.g. `foo.wasm` / `foo.node`),
|
|
1833
|
+
// match directly. Without this, FFI imports like `require('./addon.node')`
|
|
1834
|
+
// would never resolve because tryResolve only appends extensions.
|
|
1835
|
+
const directId = idOf(rootAbs, basePath)
|
|
1836
|
+
if (validIds.has(directId)) return directId
|
|
1837
|
+
// TypeScript NodeNext: an import written as './x.js' (or .jsx/.mjs/.cjs)
|
|
1838
|
+
// commonly resolves to the sibling TS source './x.ts'. TS-style ESM
|
|
1839
|
+
// *requires* the .js extension in the specifier even though the file on
|
|
1840
|
+
// disk is .ts — so try the TS twin before the generic extension sweep.
|
|
1841
|
+
const tsTwin = basePath.match(/^(.*)\.(jsx|mjs|cjs|js)$/)
|
|
1842
|
+
if (tsTwin) {
|
|
1843
|
+
const twinExts = tsTwin[2] === 'jsx' ? ['.tsx']
|
|
1844
|
+
: tsTwin[2] === 'mjs' ? ['.mts']
|
|
1845
|
+
: tsTwin[2] === 'cjs' ? ['.cts']
|
|
1846
|
+
: ['.ts', '.tsx'] // .js → .ts then .tsx
|
|
1847
|
+
for (const ext of twinExts) {
|
|
1848
|
+
const id = idOf(rootAbs, tsTwin[1] + ext)
|
|
1849
|
+
if (validIds.has(id)) return id
|
|
1850
|
+
}
|
|
1851
|
+
}
|
|
1852
|
+
// Direct + extensions
|
|
1853
|
+
for (const ext of RESOLVE_EXTS) {
|
|
1854
|
+
const cand = basePath + ext
|
|
1855
|
+
const id = idOf(rootAbs, cand)
|
|
1856
|
+
if (validIds.has(id)) return id
|
|
1857
|
+
}
|
|
1858
|
+
// Directory + index files
|
|
1859
|
+
for (const idx of INDEX_FILES) {
|
|
1860
|
+
const cand = path.join(basePath, idx)
|
|
1861
|
+
const id = idOf(rootAbs, cand)
|
|
1862
|
+
if (validIds.has(id)) return id
|
|
1863
|
+
}
|
|
1864
|
+
return null
|
|
1865
|
+
}
|
|
1866
|
+
|
|
1867
|
+
function resolvePythonRelative(fromAbsPath, spec, rootAbs, validIds) {
|
|
1868
|
+
// spec like ".module", "..pkg.sub", "."
|
|
1869
|
+
const dots = spec.match(/^\.+/)[0].length
|
|
1870
|
+
const rest = spec.slice(dots).replace(/\./g, '/')
|
|
1871
|
+
let base = path.dirname(fromAbsPath)
|
|
1872
|
+
for (let i = 1; i < dots; i++) base = path.dirname(base)
|
|
1873
|
+
const candPath = rest ? path.join(base, rest) : base
|
|
1874
|
+
for (const ext of ['.py', '/__init__.py']) {
|
|
1875
|
+
const cand = candPath + ext
|
|
1876
|
+
const id = idOf(rootAbs, cand)
|
|
1877
|
+
if (validIds.has(id)) return id
|
|
1878
|
+
}
|
|
1879
|
+
return null
|
|
1880
|
+
}
|
|
1881
|
+
|
|
1882
|
+
function idOf(rootAbs, absPath) {
|
|
1883
|
+
return path.relative(rootAbs, absPath).split(path.sep).join('/')
|
|
1884
|
+
}
|
|
1885
|
+
|
|
1886
|
+
// ─── tsconfig.json paths cache ──────────────────────────────────
|
|
1887
|
+
// Reads `compilerOptions.paths` (and `baseUrl`) from the project's
|
|
1888
|
+
// root tsconfig.json once per rootAbs. Lets us resolve TypeScript
|
|
1889
|
+
// path mapping like `@excalidraw/common` → `./packages/common/src/index.ts`.
|
|
1890
|
+
//
|
|
1891
|
+
// JSON-with-comments tolerant: strips // line comments and /* block */
|
|
1892
|
+
// comments before JSON.parse so real-world tsconfig files don't fail.
|
|
1893
|
+
const _tsconfigCache = new Map() // rootAbs → { baseUrl, paths: [{ pattern, targets }] }
|
|
1894
|
+
function loadTsconfigPaths(rootAbs) {
|
|
1895
|
+
if (_tsconfigCache.has(rootAbs)) return _tsconfigCache.get(rootAbs)
|
|
1896
|
+
let cfg = { baseUrl: '.', paths: [] }
|
|
1897
|
+
try {
|
|
1898
|
+
// Read root tsconfig.json first; fall back to jsconfig.json.
|
|
1899
|
+
const candidates = [path.join(rootAbs, 'tsconfig.json'), path.join(rootAbs, 'jsconfig.json')]
|
|
1900
|
+
let raw = null
|
|
1901
|
+
for (const c of candidates) {
|
|
1902
|
+
if (fs.existsSync(c)) { raw = fs.readFileSync(c, 'utf8'); break }
|
|
1903
|
+
}
|
|
1904
|
+
if (raw) {
|
|
1905
|
+
const stripped = raw
|
|
1906
|
+
.replace(/\/\*[\s\S]*?\*\//g, '')
|
|
1907
|
+
.replace(/(^|[^:])\/\/[^\n]*/g, '$1')
|
|
1908
|
+
.replace(/,(\s*[}\]])/g, '$1') // trailing commas
|
|
1909
|
+
const parsed = JSON.parse(stripped)
|
|
1910
|
+
const co = parsed?.compilerOptions || {}
|
|
1911
|
+
cfg.baseUrl = co.baseUrl || '.'
|
|
1912
|
+
if (co.paths) {
|
|
1913
|
+
for (const [pattern, targets] of Object.entries(co.paths)) {
|
|
1914
|
+
cfg.paths.push({ pattern, targets: Array.isArray(targets) ? targets : [targets] })
|
|
1915
|
+
}
|
|
1916
|
+
}
|
|
1917
|
+
}
|
|
1918
|
+
} catch {}
|
|
1919
|
+
_tsconfigCache.set(rootAbs, cfg)
|
|
1920
|
+
return cfg
|
|
1921
|
+
}
|
|
1922
|
+
|
|
1923
|
+
// Composer PSR-4 autoload map (`composer.json`) — the authoritative way a
|
|
1924
|
+
// PHP `use Vendor\Pkg\Class;` maps to a file. A prefix may strip namespace
|
|
1925
|
+
// segments that aren't mirrored in the path (e.g. `GuzzleHttp\Psr7\` → `src/`),
|
|
1926
|
+
// which a plain suffix match cannot recover. Read once per root (cf. go.mod).
|
|
1927
|
+
// C# namespace index — C# namespaces are declared explicitly and are NOT
|
|
1928
|
+
// bound to file paths (a folder `Resources/Languages/` may declare namespace
|
|
1929
|
+
// `Foo.Resources`). To fan a `using A.B` out to the right files we must read
|
|
1930
|
+
// the `namespace` declarations rather than guess from the folder. Built once
|
|
1931
|
+
// per root (cf. composer / go.mod caches).
|
|
1932
|
+
const _csNsCache = new Map() // rootAbs → { nsToFiles: Map<ns,Set<id>>, fileToNs: Map<id,ns> }
|
|
1933
|
+
function loadCsNamespaceIndex(rootAbs, validIds) {
|
|
1934
|
+
if (_csNsCache.has(rootAbs)) return _csNsCache.get(rootAbs)
|
|
1935
|
+
const nsToFiles = new Map(), fileToNs = new Map()
|
|
1936
|
+
const NS = /^\s*namespace\s+([A-Za-z_][\w.]*)/gm
|
|
1937
|
+
for (const id of validIds) {
|
|
1938
|
+
if (!id.endsWith('.cs')) continue
|
|
1939
|
+
let txt
|
|
1940
|
+
try { txt = fs.readFileSync(path.join(rootAbs, id.split('/').join(path.sep)), 'utf8') } catch { continue }
|
|
1941
|
+
if (txt.charCodeAt(0) === 0xFEFF) txt = txt.slice(1) // strip BOM
|
|
1942
|
+
let m, first = null
|
|
1943
|
+
NS.lastIndex = 0
|
|
1944
|
+
while ((m = NS.exec(txt))) {
|
|
1945
|
+
const ns = m[1]
|
|
1946
|
+
if (!first) first = ns
|
|
1947
|
+
if (!nsToFiles.has(ns)) nsToFiles.set(ns, new Set())
|
|
1948
|
+
nsToFiles.get(ns).add(id)
|
|
1949
|
+
}
|
|
1950
|
+
if (first) fileToNs.set(id, first)
|
|
1951
|
+
}
|
|
1952
|
+
const idx = { nsToFiles, fileToNs }
|
|
1953
|
+
_csNsCache.set(rootAbs, idx)
|
|
1954
|
+
return idx
|
|
1955
|
+
}
|
|
1956
|
+
|
|
1957
|
+
const _composerCache = new Map() // rootAbs → [{ prefix, dir }] longest-first
|
|
1958
|
+
function loadComposerPsr4(rootAbs, validIds) {
|
|
1959
|
+
if (_composerCache.has(rootAbs)) return _composerCache.get(rootAbs)
|
|
1960
|
+
let maps = []
|
|
1961
|
+
// Scan EVERY composer.json (monorepos have one per package), not just the
|
|
1962
|
+
// root — each PSR-4 dir is relative to its own composer.json location.
|
|
1963
|
+
const composerFiles = validIds
|
|
1964
|
+
? [...validIds].filter((id) => (id === 'composer.json' || id.endsWith('/composer.json')) && !id.includes('vendor/'))
|
|
1965
|
+
: ['composer.json']
|
|
1966
|
+
for (const cf of composerFiles) {
|
|
1967
|
+
try {
|
|
1968
|
+
const abs = path.join(rootAbs, cf.split('/').join(path.sep))
|
|
1969
|
+
if (!fs.existsSync(abs)) continue
|
|
1970
|
+
const parsed = JSON.parse(fs.readFileSync(abs, 'utf8'))
|
|
1971
|
+
const baseDir = cf.includes('/') ? cf.slice(0, cf.lastIndexOf('/')) : ''
|
|
1972
|
+
const sources = [parsed?.autoload?.['psr-4'], parsed?.['autoload-dev']?.['psr-4']]
|
|
1973
|
+
for (const m of sources) {
|
|
1974
|
+
if (!m) continue
|
|
1975
|
+
for (const [prefix, dir] of Object.entries(m)) {
|
|
1976
|
+
for (const one of (Array.isArray(dir) ? dir : [dir])) {
|
|
1977
|
+
let d = String(one).replace(/^\.\//, '').replace(/\/+$/, '')
|
|
1978
|
+
if (baseDir) d = d ? baseDir + '/' + d : baseDir
|
|
1979
|
+
maps.push({ prefix: prefix.replace(/\\+$/, ''), dir: d })
|
|
1980
|
+
}
|
|
1981
|
+
}
|
|
1982
|
+
}
|
|
1983
|
+
} catch {}
|
|
1984
|
+
}
|
|
1985
|
+
maps.sort((a, b) => b.prefix.length - a.prefix.length) // longest prefix wins
|
|
1986
|
+
_composerCache.set(rootAbs, maps)
|
|
1987
|
+
return maps
|
|
1988
|
+
}
|
|
1989
|
+
|
|
1990
|
+
// Dart packages (`pubspec.yaml`) — `package:<name>/x.dart` resolves to that
|
|
1991
|
+
// package's `lib/x.dart`. A melos/workspace repo has many pubspec.yaml, so map
|
|
1992
|
+
// EVERY package name → its dir (not just the root), to resolve both self- and
|
|
1993
|
+
// sibling-package imports. Cached per root.
|
|
1994
|
+
const _pubspecCache = new Map() // rootAbs → Map<name, packageDir>
|
|
1995
|
+
function loadDartPackages(rootAbs, validIds) {
|
|
1996
|
+
if (_pubspecCache.has(rootAbs)) return _pubspecCache.get(rootAbs)
|
|
1997
|
+
const byName = new Map()
|
|
1998
|
+
const files = validIds
|
|
1999
|
+
? [...validIds].filter((id) => id === 'pubspec.yaml' || id.endsWith('/pubspec.yaml'))
|
|
2000
|
+
: ['pubspec.yaml']
|
|
2001
|
+
for (const pf of files) {
|
|
2002
|
+
try {
|
|
2003
|
+
const abs = path.join(rootAbs, pf.split('/').join(path.sep))
|
|
2004
|
+
if (!fs.existsSync(abs)) continue
|
|
2005
|
+
const m = fs.readFileSync(abs, 'utf8').match(/^name:\s*(\S+)/m)
|
|
2006
|
+
if (m) byName.set(m[1], pf.includes('/') ? pf.slice(0, pf.lastIndexOf('/')) : '')
|
|
2007
|
+
} catch {}
|
|
2008
|
+
}
|
|
2009
|
+
_pubspecCache.set(rootAbs, byName)
|
|
2010
|
+
return byName
|
|
2011
|
+
}
|
|
2012
|
+
|
|
2013
|
+
// JS/TS monorepo workspace index — `import … from '@scope/pkg'` (a sibling
|
|
2014
|
+
// workspace package by its package.json name) should resolve to that package's
|
|
2015
|
+
// SOURCE, not node_modules. Map every package.json name → its dir + entry.
|
|
2016
|
+
// Cached per root. Complements tsconfig path aliases.
|
|
2017
|
+
const _jsWsCache = new Map() // rootAbs → Map<name, { dir, entry }>
|
|
2018
|
+
function loadJsWorkspace(rootAbs, validIds) {
|
|
2019
|
+
if (_jsWsCache.has(rootAbs)) return _jsWsCache.get(rootAbs)
|
|
2020
|
+
const byName = new Map()
|
|
2021
|
+
const files = validIds
|
|
2022
|
+
? [...validIds].filter((id) => (id === 'package.json' || id.endsWith('/package.json')) && !id.includes('node_modules/'))
|
|
2023
|
+
: []
|
|
2024
|
+
for (const pf of files) {
|
|
2025
|
+
try {
|
|
2026
|
+
const parsed = JSON.parse(fs.readFileSync(path.join(rootAbs, pf.split('/').join(path.sep)), 'utf8'))
|
|
2027
|
+
if (!parsed.name) continue
|
|
2028
|
+
const dir = pf.includes('/') ? pf.slice(0, pf.lastIndexOf('/')) : ''
|
|
2029
|
+
// Source entry: prefer exports['.'] / module / main (these may point at a
|
|
2030
|
+
// built dist/, which simply won't resolve → we fall back to src/index).
|
|
2031
|
+
let entry = null
|
|
2032
|
+
const exp = parsed.exports
|
|
2033
|
+
if (typeof exp === 'string') entry = exp
|
|
2034
|
+
else if (exp && typeof exp === 'object') {
|
|
2035
|
+
const dot = exp['.']
|
|
2036
|
+
entry = typeof dot === 'string' ? dot : (dot && (dot.import || dot.default || dot.require || dot.types))
|
|
2037
|
+
}
|
|
2038
|
+
entry = entry || parsed.module || parsed.main || null
|
|
2039
|
+
byName.set(parsed.name, { dir, entry })
|
|
2040
|
+
} catch {}
|
|
2041
|
+
}
|
|
2042
|
+
_jsWsCache.set(rootAbs, byName)
|
|
2043
|
+
return byName
|
|
2044
|
+
}
|
|
2045
|
+
|
|
2046
|
+
// Resolve an import spec via tsconfig path mapping. Returns the
|
|
2047
|
+
// matched file id, or null if no path mapping applies / no file
|
|
2048
|
+
// matches the resolved target.
|
|
2049
|
+
function resolveTsconfigPath(spec, rootAbs, validIds) {
|
|
2050
|
+
const cfg = loadTsconfigPaths(rootAbs)
|
|
2051
|
+
if (!cfg.paths.length) return null
|
|
2052
|
+
const baseDir = path.resolve(rootAbs, cfg.baseUrl)
|
|
2053
|
+
for (const { pattern, targets } of cfg.paths) {
|
|
2054
|
+
// Patterns may end with `/*` for prefix mapping; otherwise exact match.
|
|
2055
|
+
if (pattern.endsWith('/*')) {
|
|
2056
|
+
const prefix = pattern.slice(0, -2) // '@excalidraw/common'
|
|
2057
|
+
if (spec === prefix || spec.startsWith(prefix + '/')) {
|
|
2058
|
+
const rest = spec === prefix ? '' : spec.slice(prefix.length + 1)
|
|
2059
|
+
for (const tgt of targets) {
|
|
2060
|
+
const tgtRel = tgt.endsWith('/*') ? tgt.slice(0, -2) : tgt
|
|
2061
|
+
const cand = rest
|
|
2062
|
+
? path.join(baseDir, tgtRel, rest)
|
|
2063
|
+
: path.join(baseDir, tgtRel)
|
|
2064
|
+
const r = tryResolve(cand, rootAbs, validIds)
|
|
2065
|
+
if (r) return r
|
|
2066
|
+
}
|
|
2067
|
+
}
|
|
2068
|
+
} else if (spec === pattern) {
|
|
2069
|
+
for (const tgt of targets) {
|
|
2070
|
+
const cand = path.join(baseDir, tgt)
|
|
2071
|
+
const r = tryResolve(cand, rootAbs, validIds)
|
|
2072
|
+
if (r) return r
|
|
2073
|
+
// Some targets are direct file paths without extension fallback
|
|
2074
|
+
const direct = idOf(rootAbs, cand)
|
|
2075
|
+
if (validIds.has(direct)) return direct
|
|
2076
|
+
}
|
|
2077
|
+
}
|
|
2078
|
+
}
|
|
2079
|
+
return null
|
|
2080
|
+
}
|
|
2081
|
+
|
|
2082
|
+
// ─── Go module prefix cache ─────────────────────────────────────
|
|
2083
|
+
// Reads `module github.com/owner/repo` from the repo's go.mod once per
|
|
2084
|
+
// rootAbs. Used to decide which imports point inside the repo vs
|
|
2085
|
+
// external packages.
|
|
2086
|
+
const _goModCache = new Map()
|
|
2087
|
+
function getGoModulePrefix(rootAbs) {
|
|
2088
|
+
if (_goModCache.has(rootAbs)) return _goModCache.get(rootAbs)
|
|
2089
|
+
let prefix = null
|
|
2090
|
+
try {
|
|
2091
|
+
const p = path.join(rootAbs, 'go.mod')
|
|
2092
|
+
if (fs.existsSync(p)) {
|
|
2093
|
+
const txt = fs.readFileSync(p, 'utf8')
|
|
2094
|
+
const m = txt.match(/^\s*module\s+(\S+)/m)
|
|
2095
|
+
if (m) prefix = m[1]
|
|
2096
|
+
}
|
|
2097
|
+
} catch {}
|
|
2098
|
+
_goModCache.set(rootAbs, prefix)
|
|
2099
|
+
return prefix
|
|
2100
|
+
}
|
|
2101
|
+
|
|
2102
|
+
// Go modules — a repo may hold several modules (go.work / nested go.mod), each
|
|
2103
|
+
// with its own `module <prefix>` declaration rooted at its own dir. Map every
|
|
2104
|
+
// go.mod → { prefix, dir } so an import is resolved within the RIGHT module.
|
|
2105
|
+
const _goModsCache = new Map()
|
|
2106
|
+
function loadGoModules(rootAbs, validIds) {
|
|
2107
|
+
if (_goModsCache.has(rootAbs)) return _goModsCache.get(rootAbs)
|
|
2108
|
+
const mods = []
|
|
2109
|
+
const files = validIds
|
|
2110
|
+
? [...validIds].filter((id) => id === 'go.mod' || id.endsWith('/go.mod'))
|
|
2111
|
+
: ['go.mod']
|
|
2112
|
+
for (const gf of files) {
|
|
2113
|
+
try {
|
|
2114
|
+
const txt = fs.readFileSync(path.join(rootAbs, gf.split('/').join(path.sep)), 'utf8')
|
|
2115
|
+
const m = txt.match(/^\s*module\s+(\S+)/m)
|
|
2116
|
+
if (m) mods.push({ prefix: m[1], dir: gf.includes('/') ? gf.slice(0, gf.lastIndexOf('/')) : '' })
|
|
2117
|
+
} catch {}
|
|
2118
|
+
}
|
|
2119
|
+
mods.sort((a, b) => b.prefix.length - a.prefix.length) // longest module path first
|
|
2120
|
+
_goModsCache.set(rootAbs, mods)
|
|
2121
|
+
return mods
|
|
2122
|
+
}
|
|
2123
|
+
// Map a Go import path to { dir, internalPath } within a known module, or null.
|
|
2124
|
+
function goImportLocation(spec, rootAbs, validIds) {
|
|
2125
|
+
for (const { prefix, dir } of loadGoModules(rootAbs, validIds)) {
|
|
2126
|
+
if (spec === prefix) return { dir, internalPath: '' }
|
|
2127
|
+
if (spec.startsWith(prefix + '/')) return { dir, internalPath: spec.slice(prefix.length + 1) }
|
|
2128
|
+
}
|
|
2129
|
+
return null
|
|
2130
|
+
}
|
|
2131
|
+
|
|
2132
|
+
// Cargo workspace index — real Rust projects are almost always multi-crate
|
|
2133
|
+
// (a `crates/*/` workspace, or a Tauri `src-tauri/`), so the crate root is NOT
|
|
2134
|
+
// `<scanRoot>/src`. Read every Cargo.toml to learn each crate's name and src
|
|
2135
|
+
// dir, so `crate::`/`self::` resolve within the FILE's crate and cross-crate
|
|
2136
|
+
// `use other_crate::…` resolves into that crate. Cached per root.
|
|
2137
|
+
const _cargoCache = new Map() // rootAbs → { crateSrcs: [srcRootId...], nameToSrc: Map<name_,srcRoot> }
|
|
2138
|
+
function loadCargoWorkspace(rootAbs, validIds) {
|
|
2139
|
+
if (_cargoCache.has(rootAbs)) return _cargoCache.get(rootAbs)
|
|
2140
|
+
const crateSrcs = [], nameToSrc = new Map()
|
|
2141
|
+
for (const id of validIds) {
|
|
2142
|
+
if (!id.endsWith('Cargo.toml') || id.includes('/target/')) continue
|
|
2143
|
+
const dir = id.slice(0, id.length - 'Cargo.toml'.length).replace(/\/$/, '')
|
|
2144
|
+
const srcRoot = dir ? dir + '/src' : 'src'
|
|
2145
|
+
let txt
|
|
2146
|
+
try { txt = fs.readFileSync(path.join(rootAbs, id.split('/').join(path.sep)), 'utf8') } catch { continue }
|
|
2147
|
+
// Only [package] crates have a name + src/; pure [workspace] roots don't.
|
|
2148
|
+
const pkg = txt.match(/\[package\]([\s\S]*?)(?=\n\s*\[|$)/)
|
|
2149
|
+
if (!pkg) continue
|
|
2150
|
+
const nm = pkg[1].match(/\bname\s*=\s*["']([^"']+)["']/)
|
|
2151
|
+
if (!nm) continue
|
|
2152
|
+
nameToSrc.set(nm[1].replace(/-/g, '_'), srcRoot)
|
|
2153
|
+
crateSrcs.push(srcRoot)
|
|
2154
|
+
}
|
|
2155
|
+
crateSrcs.sort((a, b) => b.length - a.length) // longest (most specific) first
|
|
2156
|
+
const idx = { crateSrcs, nameToSrc }
|
|
2157
|
+
_cargoCache.set(rootAbs, idx)
|
|
2158
|
+
return idx
|
|
2159
|
+
}
|
|
2160
|
+
|
|
2161
|
+
// Invalidate the per-root resolution caches. The long-running scanner / desktop
|
|
2162
|
+
// app must call this when the underlying inputs change (a .cs file's namespace,
|
|
2163
|
+
// tsconfig paths, composer PSR-4, pubspec name, go.mod module) — otherwise the
|
|
2164
|
+
// dependency graph silently goes stale after edits, and every project ever
|
|
2165
|
+
// opened leaks its cache entry. Pass a root to clear just that project, or
|
|
2166
|
+
// nothing to clear everything.
|
|
2167
|
+
export function clearParserCaches(rootAbs) {
|
|
2168
|
+
const caches = [_tsconfigCache, _csNsCache, _composerCache, _pubspecCache, _goModCache, _goModsCache, _cargoCache, _jsWsCache]
|
|
2169
|
+
if (rootAbs == null) { for (const c of caches) c.clear(); return }
|
|
2170
|
+
for (const c of caches) c.delete(rootAbs)
|
|
2171
|
+
}
|
|
2172
|
+
|
|
2173
|
+
// ─── Rust module resolution ─────────────────────────────────────
|
|
2174
|
+
// Maps `use crate::a::b`, `use self::x`, `use super::x`, `mod x;` into
|
|
2175
|
+
// a file path inside the repo. Best-effort: we look at `src/` first
|
|
2176
|
+
// (cargo convention) and also resolve relative to the importing file
|
|
2177
|
+
// for `self::` / `super::` / bare `mod x;`.
|
|
2178
|
+
function resolveRustModule(fromAbsPath, spec, rootAbs, validIds) {
|
|
2179
|
+
// Resolve a Rust module path to a file using the real 2018 module tree, and
|
|
2180
|
+
// — crucially for real projects — the Cargo WORKSPACE: a file's crate may
|
|
2181
|
+
// live at `crates/<name>/src/` or `app/src-tauri/src/`, not `<root>/src/`,
|
|
2182
|
+
// and `use other_crate::…` crosses into a sibling crate.
|
|
2183
|
+
const relId = idOf(rootAbs, fromAbsPath)
|
|
2184
|
+
const ws = loadCargoWorkspace(rootAbs, validIds)
|
|
2185
|
+
|
|
2186
|
+
// This file's crate src root (longest matching). Fall back to scan-root
|
|
2187
|
+
// `src/` for a single-crate-at-root layout or synthetic/unit inputs.
|
|
2188
|
+
let crateSrc = null
|
|
2189
|
+
for (const s of ws.crateSrcs) { if (relId === s || relId.startsWith(s + '/')) { crateSrc = s; break } }
|
|
2190
|
+
const srcPrefix = crateSrc ? crateSrc + '/' : (relId.startsWith('src/') ? 'src/' : '')
|
|
2191
|
+
|
|
2192
|
+
// The importing file's module path, relative to its crate src root.
|
|
2193
|
+
let p = relId.startsWith(srcPrefix) ? relId.slice(srcPrefix.length) : relId
|
|
2194
|
+
p = p.replace(/\.rs$/, '')
|
|
2195
|
+
let myMod = p.split('/').filter(Boolean)
|
|
2196
|
+
if (myMod[myMod.length - 1] === 'mod') myMod.pop()
|
|
2197
|
+
else if (myMod.length === 1 && (myMod[0] === 'lib' || myMod[0] === 'main')) myMod = []
|
|
2198
|
+
|
|
2199
|
+
const raw = spec.split('::').filter((s) => s && s !== '*')
|
|
2200
|
+
if (!raw.length) return null
|
|
2201
|
+
|
|
2202
|
+
// `lib.rs`/`main.rs` are reserved crate-root filenames — only the empty path
|
|
2203
|
+
// (the crate root itself) may map to them, never a named submodule.
|
|
2204
|
+
const fileForIn = (pfx, segs) => segs.length
|
|
2205
|
+
? [pfx + segs.join('/') + '.rs', pfx + segs.join('/') + '/mod.rs']
|
|
2206
|
+
.filter((c) => c !== pfx + 'lib.rs' && c !== pfx + 'main.rs')
|
|
2207
|
+
: [pfx + 'lib.rs', pfx + 'main.rs']
|
|
2208
|
+
// Walk prefixes longest→shortest: the deepest segment that maps to a real
|
|
2209
|
+
// file is the target (segments below it are inline submodules/items in that
|
|
2210
|
+
// file). `rooted` paths may collapse to the crate root; bare paths may not.
|
|
2211
|
+
const walk = (pfx, segs, rooted) => {
|
|
2212
|
+
const minN = rooted ? 0 : segs.length
|
|
2213
|
+
for (let n = segs.length; n >= minN; n--) {
|
|
2214
|
+
for (const cand of fileForIn(pfx, segs.slice(0, n))) if (validIds.has(cand)) return cand
|
|
2215
|
+
}
|
|
2216
|
+
return null
|
|
2217
|
+
}
|
|
2218
|
+
|
|
2219
|
+
if (raw[0] === 'crate') return walk(srcPrefix, raw.slice(1), true)
|
|
2220
|
+
if (raw[0] === 'self' || raw[0] === 'super') {
|
|
2221
|
+
const base = myMod.slice()
|
|
2222
|
+
let i = 0
|
|
2223
|
+
while (i < raw.length && raw[i] === 'super') { if (base.length) base.pop(); i++ }
|
|
2224
|
+
if (raw[i] === 'self') i++
|
|
2225
|
+
return walk(srcPrefix, base.concat(raw.slice(i)), true)
|
|
2226
|
+
}
|
|
2227
|
+
// Bare head: cross-crate `use other_crate::a::b` if it names a workspace
|
|
2228
|
+
// crate (Cargo uses hyphens, code uses underscores — normalize).
|
|
2229
|
+
const head = raw[0].replace(/-/g, '_')
|
|
2230
|
+
if (ws.nameToSrc.has(head)) return walk(ws.nameToSrc.get(head) + '/', raw.slice(1), true)
|
|
2231
|
+
// Otherwise `mod foo;` (a child of the current module) or an external crate
|
|
2232
|
+
// (no repo file → null). Full path only — don't collapse onto self/root.
|
|
2233
|
+
return walk(srcPrefix, myMod.concat(raw), false)
|
|
2234
|
+
}
|