mirror of
https://github.com/colbymchenry/codegraph.git
synced 2026-10-25 12:00:02 +08:00
fix(resolution): honour TypeScript type modifiers on imports and re-exports (#1955)
Import and re-export lists are now read with TypeScript's `type`
modifiers (`export type { A } from`, `export { type A } from`,
`export type * from`), comments inside the list, ES2022 string names
and JSDoc @import tags. A type re-exported from a package, like vben's
`export type { VxeGridProps } from 'vxe-table'`, no longer links to a
project type of the same name, and type imports link through the
import rather than by name. The import-related suites pass (609 tests)
and indexing CPU time on vben is unchanged.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 5.5
parent
8afb970a23
commit
9dc3bc3adc
@@ -1186,24 +1186,140 @@ export function extractImportMappings(
|
||||
return mappings;
|
||||
}
|
||||
|
||||
/** A JavaScript identifier — `$`, `_` and Unicode letters included. Use with the `u` flag. */
|
||||
const JS_IDENT = '[$_\\p{ID_Start}][$\\u200c\\u200d\\p{ID_Continue}]*';
|
||||
/** A quoted string, escapes included — an ES2022 string-named specifier. */
|
||||
const JS_STRING = `"(?:[^"\\\\\\n]|\\\\.)*"|'(?:[^'\\\\\\n]|\\\\.)*'`;
|
||||
/** A specifier's name: an identifier, or an ES2022 string name (`"my-fn"`). */
|
||||
const SPECIFIER_NAME = `(?:${JS_IDENT}|${JS_STRING})`;
|
||||
/** TypeScript's `type` modifier, or Flow's `typeof`. */
|
||||
const TYPE_MODIFIER = '(?:type(?:of)?\\s+)';
|
||||
const SPECIFIER_ALIAS = new RegExp(`^${TYPE_MODIFIER}?(${SPECIFIER_NAME})\\s+as\\s+(${SPECIFIER_NAME})$`, 'u');
|
||||
const SPECIFIER_PLAIN = new RegExp(`^${TYPE_MODIFIER}?(${SPECIFIER_NAME})$`, 'u');
|
||||
|
||||
/**
|
||||
* A `{ … }` specifier list: everything up to the closing `}`, with no length
|
||||
* cap, so a barrel's thousand-name list still parses. A list never holds a
|
||||
* brace, so the scan also stops at a `{`: an unclosed list ends at the next
|
||||
* statement's brace instead of rescanning to the end of the file, which keeps
|
||||
* a file of unclosed lists linear. (A brace inside a comment or string name in
|
||||
* the list ends it early, as it always has; handling that needs a real
|
||||
* scanner, not a regex.)
|
||||
*/
|
||||
const SPECIFIER_LIST = '[^{}]+';
|
||||
|
||||
/**
|
||||
* `import [type] [Default][, ]{ … }|* as ns from '…'`. Each run of whitespace
|
||||
* belongs to exactly one quantifier — adjacent optional `\\s*`s backtrack
|
||||
* polynomially on long blank runs. A modifier — TypeScript `type`, Flow
|
||||
* `typeof`, or `defer` (`import defer * as ns`) — is followed directly by
|
||||
* `{`/`*`, or by whitespace and a name that is not `from` — `import type from
|
||||
* '…'` (any spacing) is a default import NAMED `type`. A default binding is
|
||||
* never followed by `{`, so the `${` of a code generator's
|
||||
* `` `import ${name} from '${src}'` `` is no binding.
|
||||
*/
|
||||
const ES_IMPORT_RE = new RegExp(
|
||||
`(?<![$.\\p{ID_Continue}])import(?:\\s+|(?=[{*]))(?:(?:type(?:of)?|defer)(?:\\s*(?=[{*])|\\s+(?!from(?![$\\p{ID_Continue}]))(?=[$_\\p{ID_Start}])))?` +
|
||||
`(?:(${JS_IDENT})(?![$\\u200c\\u200d\\p{ID_Continue}{])\\s*(?:,\\s*)?)?` +
|
||||
`(?:\\{(${SPECIFIER_LIST})\\}\\s*)?` +
|
||||
`(?:(\\*)\\s*as\\s+(${JS_IDENT})\\s*)?` +
|
||||
`from\\s*['"]([^'"]+)['"]`,
|
||||
'gu'
|
||||
);
|
||||
|
||||
/** `export [type] * [as ns] from '…'`. */
|
||||
const WILDCARD_REEXPORT_RE = new RegExp(
|
||||
`(?<![$.\\p{ID_Continue}])export(?![$\\p{ID_Continue}])\\s*(?:type(?![$\\p{ID_Continue}])\\s*)?\\*(?:\\s*as(?:\\s+(${JS_IDENT})|\\s*(${JS_STRING})))?\\s*from\\s*['"]([^'"]+)['"]`,
|
||||
'gu'
|
||||
);
|
||||
|
||||
/** `export [type] { … } from '…'`. */
|
||||
const NAMED_REEXPORT_RE = new RegExp(
|
||||
`(?<![$.\\p{ID_Continue}])export(?![$\\p{ID_Continue}])\\s*(?:type(?![$\\p{ID_Continue}])\\s*)?\\{(${SPECIFIER_LIST})\\}\\s*from\\s*['"]([^'"]+)['"]`,
|
||||
'gu'
|
||||
);
|
||||
|
||||
/**
|
||||
* Split a comment-free specifier list on the commas between specifiers — not
|
||||
* those inside a string name (`"a,b" as c`).
|
||||
*/
|
||||
function splitSpecifierList(list: string): string[] {
|
||||
const out: string[] = [];
|
||||
let start = 0;
|
||||
let quote: string | null = null;
|
||||
for (let i = 0; i < list.length; i++) {
|
||||
const ch = list[i]!;
|
||||
if (quote !== null) {
|
||||
if (ch === '\\') i++;
|
||||
else if (ch === quote) quote = null;
|
||||
} else if (ch === '"' || ch === "'") {
|
||||
quote = ch;
|
||||
} else if (ch === ',') {
|
||||
out.push(list.slice(start, i));
|
||||
start = i + 1;
|
||||
}
|
||||
}
|
||||
out.push(list.slice(start));
|
||||
return out;
|
||||
}
|
||||
|
||||
const SIMPLE_ESCAPES: Record<string, string> = { n: '\n', t: '\t', r: '\r', b: '\b', f: '\f', v: '\v', '0': '\0' };
|
||||
|
||||
/** A captured `{ … }` list with its comments stripped (only when it has a `/` — most don't). */
|
||||
function cleanSpecifierList(list: string): string {
|
||||
return list.includes('/') ? stripJsComments(list) : list;
|
||||
}
|
||||
|
||||
/** A multi-line JSDoc `@import { … }` list without its ` * ` line prefixes. */
|
||||
function withoutJsDocPrefixes(list: string): string {
|
||||
return list.replace(/^[ \t]*\*(?!\/)/gm, '');
|
||||
}
|
||||
|
||||
/** A string-named specifier's name without its quotes, escapes decoded. */
|
||||
function unquoteSpecifier(name: string): string {
|
||||
if (!/^["']/.test(name)) return name;
|
||||
return name.slice(1, -1).replace(
|
||||
/\\(u\{[0-9a-fA-F]+\}|u[0-9a-fA-F]{4}|x[0-9a-fA-F]{2}|[\s\S])/g,
|
||||
(_, esc: string) => {
|
||||
if (esc[0] === 'u' || esc[0] === 'x') {
|
||||
const hex = esc[1] === '{' ? esc.slice(2, -1) : esc.slice(1);
|
||||
const code = parseInt(hex, 16);
|
||||
return code <= 0x10ffff ? String.fromCodePoint(code) : esc;
|
||||
}
|
||||
return SIMPLE_ESCAPES[esc] ?? esc;
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* One `{ … }` import/export specifier — `a`, `a as b`, `"a-b" as c`, and the
|
||||
* same with TypeScript's inline `type` modifier (`type a`, `type a as b`) — as
|
||||
* the name it takes from the module and the name it binds (Flow's `typeof`
|
||||
* modifier likewise). `type` alone, or `type as t`, is a specifier NAMED
|
||||
* `type`. Null for anything else. Expects one comment-free item of a list
|
||||
* split by {@link splitSpecifierList}.
|
||||
*/
|
||||
function parseSpecifier(raw: string): { imported: string; local: string } | null {
|
||||
const item = raw.trim();
|
||||
const alias = SPECIFIER_ALIAS.exec(item);
|
||||
if (alias) return { imported: unquoteSpecifier(alias[1]!), local: unquoteSpecifier(alias[2]!) };
|
||||
const plain = SPECIFIER_PLAIN.exec(item);
|
||||
if (plain) {
|
||||
const name = unquoteSpecifier(plain[1]!);
|
||||
return { imported: name, local: name };
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Extract JS/TS import mappings
|
||||
*/
|
||||
function extractJSImports(content: string): ImportMapping[] {
|
||||
const mappings: ImportMapping[] = [];
|
||||
|
||||
// ES6 imports. `import type { X }` / `import type * as ns` is TypeScript's
|
||||
// type-only form, not a default import named `type` — which every such
|
||||
// line used to add, making `type.innerType()` a call on an import.
|
||||
// (`import type from './x'` still binds `type`: backtracking gives it back.)
|
||||
// A binding is any JS identifier, `$` included: `\w` stops at the `$` of
|
||||
// `import $store from './store'`, failing the whole statement. A default
|
||||
// binding is never followed by `{`, so the `${` of a code generator's
|
||||
// `` `import ${name} from '${src}'` `` is no binding.
|
||||
const importRegex = /import\s+(?:type\s+(?=[{*]|(?!from(?![\w$]))[A-Za-z_$]))?(?:([A-Za-z_$][\w$]*)(?![\w${])\s*,?\s*)?(?:\{([^}]+)\})?\s*(?:(\*)\s+as\s+([A-Za-z_$][\w$]*))?\s*from\s*['"]([^'"]+)['"]/g;
|
||||
|
||||
let match;
|
||||
while ((match = importRegex.exec(content)) !== null) {
|
||||
// ES6 imports (see ES_IMPORT_RE). matchAll iterates a copy, so the shared
|
||||
// regex's lastIndex never leaks between calls.
|
||||
for (const match of content.matchAll(ES_IMPORT_RE)) {
|
||||
const [, defaultImport, namedImports, star, namespaceAlias, source] = match;
|
||||
|
||||
// Default import
|
||||
@@ -1219,26 +1335,21 @@ function extractJSImports(content: string): ImportMapping[] {
|
||||
|
||||
// Named imports
|
||||
if (namedImports) {
|
||||
// `{ util, type objectUtil }`: an inline `type` modifier is not part of the name.
|
||||
const names = namedImports.split(',').map((s) => s.trim().replace(/^type\s+(?=[A-Za-z_$])/, ''));
|
||||
for (const name of names) {
|
||||
const aliasMatch = name.match(/([A-Za-z_$][\w$]*)\s+as\s+([A-Za-z_$][\w$]*)/);
|
||||
if (aliasMatch) {
|
||||
// Comments inside the list are stripped first so a `,` in one can't split
|
||||
// it. (Only the list: this regex also runs over whole SFCs, where a
|
||||
// file-wide comment scan would desync on markup like `Don't`.)
|
||||
// A JSDoc `@import` tag's list carries ` * ` line prefixes when it wraps.
|
||||
const list = content[match.index! - 1] === '@' ? withoutJsDocPrefixes(namedImports) : namedImports;
|
||||
for (const name of splitSpecifierList(cleanSpecifierList(list))) {
|
||||
const specifier = parseSpecifier(name);
|
||||
if (specifier) {
|
||||
mappings.push({
|
||||
localName: aliasMatch[2]!,
|
||||
exportedName: aliasMatch[1]!,
|
||||
localName: specifier.local,
|
||||
exportedName: specifier.imported,
|
||||
source: source!,
|
||||
// `{ default as X }` is the default import spelled as a named one;
|
||||
// no module declares an export named `default` to find.
|
||||
isDefault: aliasMatch[1] === 'default',
|
||||
isNamespace: false,
|
||||
});
|
||||
} else if (name) {
|
||||
mappings.push({
|
||||
localName: name,
|
||||
exportedName: name,
|
||||
source: source!,
|
||||
isDefault: false,
|
||||
isDefault: specifier.imported === 'default',
|
||||
isNamespace: false,
|
||||
});
|
||||
}
|
||||
@@ -1259,6 +1370,7 @@ function extractJSImports(content: string): ImportMapping[] {
|
||||
|
||||
// Require statements — each declarator of a list too (`var express = require('../'),
|
||||
// request = require('supertest')`), and a member of the module (`require('./utils').methods`).
|
||||
let match: RegExpExecArray | null;
|
||||
const requireRegex = /(?:\b(?:const|let|var)\s+|,\s*)(?:([A-Za-z_$][\w$]*)|{([^}]+)})\s*=\s*require\(\s*['"]([^'"]+)['"]\s*\)(?:\s*\.\s*([A-Za-z_$][\w$]*))?/g;
|
||||
while ((match = requireRegex.exec(content)) !== null) {
|
||||
const [, defaultName, destructured, source, member] = match;
|
||||
@@ -1555,15 +1667,18 @@ export function clearImportMappingCache(): void {
|
||||
|
||||
/**
|
||||
* Strip JS line + block comments from `content` while preserving
|
||||
* string literals (so `"//"` inside a string stays intact). Used by
|
||||
* {@link extractReExports} so commented-out export-from statements
|
||||
* don't generate phantom re-export edges.
|
||||
* string literals (so `"//"` inside a string stays intact). A block
|
||||
* comment becomes one space, so the tokens either side stay apart. Used by
|
||||
* {@link extractReExports} over a whole file, so commented-out export-from
|
||||
* statements don't generate phantom re-export edges, and by
|
||||
* {@link extractJSImports} over just an import's `{ … }` list (which can come
|
||||
* from an SFC — never run it over SFC markup: `Don't` would open a string).
|
||||
*
|
||||
* Scanner is deliberately small: it only tracks the three contexts
|
||||
* relevant for JS/TS — single-quote string, double-quote string, and
|
||||
* template literal. Comment recognition is the JS spec subset, no
|
||||
* regex-literal awareness (which is fine for our use case: we don't
|
||||
* apply this to function bodies, only to top-level files).
|
||||
* apply this to function bodies, only to top-level files and import lists).
|
||||
*/
|
||||
function stripJsComments(content: string): string {
|
||||
let out = '';
|
||||
@@ -1596,6 +1711,8 @@ function stripJsComments(content: string): string {
|
||||
i += 2;
|
||||
while (i < content.length && !(content[i] === '*' && content[i + 1] === '/')) i++;
|
||||
i += 2;
|
||||
// A space, not nothing: `A/**/as B` must stay two words.
|
||||
out += ' ';
|
||||
continue;
|
||||
}
|
||||
out += ch;
|
||||
@@ -1613,6 +1730,8 @@ function stripJsComments(content: string): string {
|
||||
* export * from './a';
|
||||
* export * as ns from './a'; (treated as wildcard for chasing)
|
||||
* export { default as Foo } from './a';
|
||||
* and TypeScript's `type` forms of each: `export type { foo } from`,
|
||||
* `export { type foo } from`, `export type * from`.
|
||||
*
|
||||
* The walker intentionally stays regex-based — the import-resolver
|
||||
* elsewhere in this file already chooses regex over a fresh
|
||||
@@ -1638,37 +1757,32 @@ export function extractReExports(content: string, language: Language): ReExport[
|
||||
// out of scope.)
|
||||
const cleaned = stripJsComments(content);
|
||||
|
||||
// Wildcard: `export * from '...'`; `export * as ns from '...'` exports `ns` alone.
|
||||
const wildcardRe = /export\s*\*(?:\s+as\s+([A-Za-z_$][\w$]*))?\s*from\s*['"]([^'"]+)['"]/g;
|
||||
let m: RegExpExecArray | null;
|
||||
while ((m = wildcardRe.exec(cleaned)) !== null) {
|
||||
out.push(m[1] ? { kind: 'namespace', exportedName: m[1], source: m[2]! } : { kind: 'wildcard', source: m[2]! });
|
||||
// Wildcard: `export * from '...'`, optionally `export type *`; `export * as ns
|
||||
// from '...'` exports `ns` alone.
|
||||
for (const m of cleaned.matchAll(WILDCARD_REEXPORT_RE)) {
|
||||
// `export * as ns` / `export * as "my ns"` (ES2022) exports the namespace alone.
|
||||
const ns = m[1] ?? (m[2] ? m[2].slice(1, -1) : undefined);
|
||||
out.push(ns ? { kind: 'namespace', exportedName: ns, source: m[3]! } : { kind: 'wildcard', source: m[3]! });
|
||||
}
|
||||
|
||||
// Named: `export { a, b as c } from '...'`
|
||||
const namedRe = /export\s*\{([^}]+)\}\s*from\s*['"]([^'"]+)['"]/g;
|
||||
while ((m = namedRe.exec(cleaned)) !== null) {
|
||||
// Named: `export { a, b as c } from '...'`, with TypeScript's `type`
|
||||
// modifier on the list (`export type { … } from`) or an item (`{ type a }`).
|
||||
// `[^{}]`, not `[^}]`: a specifier list never holds a brace, so an unclosed
|
||||
// `{` stops at the next one instead of rescanning to the end of the file.
|
||||
for (const m of cleaned.matchAll(NAMED_REEXPORT_RE)) {
|
||||
const inner = m[1]!;
|
||||
const source = m[2]!;
|
||||
for (const raw of inner.split(',')) {
|
||||
const item = raw.trim();
|
||||
if (!item) continue;
|
||||
const aliasMatch = item.match(/^([A-Za-z_$][\w$]*)\s+as\s+([A-Za-z_$][\w$]*)$/);
|
||||
if (aliasMatch) {
|
||||
out.push({
|
||||
kind: 'named',
|
||||
exportedName: aliasMatch[2]!,
|
||||
originalName: aliasMatch[1]!,
|
||||
source,
|
||||
});
|
||||
} else if (/^[A-Za-z_$][\w$]*$/.test(item)) {
|
||||
out.push({
|
||||
kind: 'named',
|
||||
exportedName: item,
|
||||
originalName: item,
|
||||
source,
|
||||
});
|
||||
}
|
||||
// Stripped again here: the file-wide pass above can lose its place (a JSX
|
||||
// `Don't`, a regex literal holding a quote) and leave a comment in the list.
|
||||
for (const raw of splitSpecifierList(cleanSpecifierList(inner))) {
|
||||
const specifier = parseSpecifier(raw);
|
||||
if (!specifier) continue;
|
||||
out.push({
|
||||
kind: 'named',
|
||||
exportedName: specifier.local,
|
||||
originalName: specifier.imported,
|
||||
source,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user