mirror of
https://github.com/colbymchenry/codegraph.git
synced 2026-10-24 19:30:10 +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
@@ -203,6 +203,7 @@ and adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
|
||||
- In React with TypeScript, a component that renders a generic component and passes it type arguments, like outline's `<PaginatedList<Document> items={…} />` or excalidraw's `<DropdownMenuItemContentRadio<"contain" | "overlap"> …>`, now links to the component it renders. Before, a tag followed by type arguments was skipped, so the generic component looked unused by the screens that render it, and `codegraph_explore`'s call paths, callers and impact stopped short of it. Re-index React projects after upgrading.
|
||||
- In React, a component that a file declares itself as a value is now what that file's JSX renders, whatever else in the repository shares its name. A lazily loaded page, like `const RegisterPage = Loadable(lazy(() => import('./pages/Register')))`, a `dynamic(() => import(…))` component or one loaded only in development (`IS_DEV ? lazy(…) : null`), links to the component its module exports, including the one its loader picks and one an `export default observer(Login)` wraps; a wrapped component like `const Settings = observer(function Settings() {…})`, `React.memo(Rows, areEqual)` or `forwardRef(ComboboxInner) as {…}` links to the function it wraps; and an alias like `const DialogPortal = DialogPrimitive.Portal` links to its own declaration. Before, `<RegisterPage />` was linked to any lone component of that name elsewhere, often in another app of the same repository, so `codegraph_explore`, callers and impact followed it into the wrong app while the page it really renders had no caller. React Router layouts declared this way follow the same rule, and a TSX type named like a value the file declares now stays in that file. Re-index React projects after upgrading.
|
||||
- In React, a JSX tag imported from one of your own modules now links to the component that module exports under that name, read the way a lazily loaded page's module already is. That includes going through an `index` file or barrel that passes the component on, like outline's `Sidebar/index.ts` with `import Sidebar from './App'; export default Sidebar` or its `Avatar/index.ts` with `const Avatar = AvatarWithHoverCard`, and past a wrapper such as `export default observer(AppSidebar)`, `React.memo(StaticCanvas, areEqual)` or mastodon's `const MemoizedBlurhash = memo(Blurhash)`. Before, a tag was matched by its name and the import only broke ties, so outline's `<Sidebar>` linked to an unrelated styled `Sidebar` in a sibling file, a default import under another name linked to whatever other component its module exported, and an import through a barrel could land on another app's component of the same name. A tag whose module exports an unnamed component, like `export default () => …`, now links to nothing instead of to a namesake elsewhere. A lazily loaded React Router page exported as such a value or wrapper links to the component it holds too, the one with the markup. `codegraph sync` and the file watcher redraw these links when only the file that passes the component on changes. Re-index React projects after upgrading.
|
||||
- A TypeScript type re-exported through a barrel (`export type { Foo } from`, `export { type Foo } from`, `export type * from`) now links to its real declaration instead of a same-named type elsewhere in the project, and import lists with comments, string names, or a JSDoc `@import` are read correctly. Re-index to update an existing project.
|
||||
- Git sync hooks now refresh the project they were installed for, so a project inside a larger repository stays current after commit, pull and checkout; installing them no longer breaks an existing hook written in another language, and runs even when your own hook ends with `exit`.
|
||||
- In TypeScript and JavaScript, a call through a field the calling class inherits, like `this._store.add(…)` in a class whose base class declares `_store`, now links to the method of the field's class, with the base class found the way the subclass's file imports it, under its own name, an alias or a re-export. Before, such a call linked only when some other class with the calling class's name happened to declare the field, which could be another app's class: that last resort now needs a type the calling file imports or declares. A call on a field or variable whose type comes from a package outside your project, like `TelemetryReporter` from `@vscode/extension-telemetry`, no longer links to a project class that only shares the type's name, unless a file in your project declares that package (`declare module '…'`). Re-index TypeScript and JavaScript projects after upgrading.
|
||||
- In TypeScript and JavaScript, a call on a variable, parameter or field whose class the file imports, like `store.add(…)` with `store: DisposableStore`, now links to the method of the class that import names when other classes in the project share its name, such as a vendored copy, a test mock or another app's class. Before, it linked to whichever of them was indexed first, or to the one in the nearest folder: in vscode, calls on classes imported from vscode's own `vs/base` and `vs/platform` reached the copies the Copilot extension vendors or its test fixtures, in bitwarden the desktop app's `initService.init()` reached the browser extension's `InitService`, and in react-native `Keyboard` and `WebSocket` reached the Jest mock of `NativeEventEmitter`. A call whose first match was a test file's class, which then linked to nothing, now reaches the imported class too. The import is followed through re-exports, `import type`, namespace imports like `new async.Delayer()` and `require(…)`. Re-index TypeScript and JavaScript projects after upgrading.
|
||||
|
||||
@@ -0,0 +1,286 @@
|
||||
/**
|
||||
* TypeScript `type` modifiers on imports and re-exports.
|
||||
*
|
||||
* `import { type Cfg }` recorded the local name `"type Cfg"`, so the name
|
||||
* never matched and a reference to `Cfg` fell through to name-matching —
|
||||
* which picked whichever `Cfg` came first, often the wrong file.
|
||||
* `import type { Cfg }` recorded a bogus default import named `type`;
|
||||
* `import type X from` / `import type * as ns from` were not recognised at
|
||||
* all; and `export { type Foo } from` / `export type { Foo } from` dropped the
|
||||
* re-export, breaking barrel resolution for types.
|
||||
*/
|
||||
import { describe, it, expect, beforeAll, afterAll } from 'vitest';
|
||||
import * as fs from 'fs';
|
||||
import * as os from 'os';
|
||||
import * as path from 'path';
|
||||
import { CodeGraph } from '../src';
|
||||
import { extractImportMappings, extractReExports } from '../src/resolution/import-resolver';
|
||||
|
||||
function mappings(content: string) {
|
||||
return extractImportMappings('src/a.ts', content, 'typescript').map((m) => ({
|
||||
local: m.localName,
|
||||
exported: m.exportedName,
|
||||
source: m.source,
|
||||
isDefault: m.isDefault,
|
||||
isNamespace: m.isNamespace,
|
||||
}));
|
||||
}
|
||||
|
||||
describe('import mappings with a `type` modifier', () => {
|
||||
it('inline `type` on a named import', () => {
|
||||
expect(mappings(`import { type Cfg, run } from './cfg';`)).toEqual([
|
||||
{ local: 'Cfg', exported: 'Cfg', source: './cfg', isDefault: false, isNamespace: false },
|
||||
{ local: 'run', exported: 'run', source: './cfg', isDefault: false, isNamespace: false },
|
||||
]);
|
||||
});
|
||||
|
||||
it('inline `type` with an alias', () => {
|
||||
expect(mappings(`import { type Cfg as Config } from './cfg';`)).toEqual([
|
||||
{ local: 'Config', exported: 'Cfg', source: './cfg', isDefault: false, isNamespace: false },
|
||||
]);
|
||||
});
|
||||
|
||||
it('`import type { … }` records no default import named `type`', () => {
|
||||
expect(mappings(`import type { Cfg, Opts as O } from './cfg';`)).toEqual([
|
||||
{ local: 'Cfg', exported: 'Cfg', source: './cfg', isDefault: false, isNamespace: false },
|
||||
{ local: 'O', exported: 'Opts', source: './cfg', isDefault: false, isNamespace: false },
|
||||
]);
|
||||
});
|
||||
|
||||
it('`import type X from` and `import type * as ns from`', () => {
|
||||
expect(mappings(`import type Cfg from './cfg';\nimport type * as types from './types';`)).toEqual([
|
||||
{ local: 'Cfg', exported: 'default', source: './cfg', isDefault: true, isNamespace: false },
|
||||
{ local: 'types', exported: '*', source: './types', isDefault: false, isNamespace: true },
|
||||
]);
|
||||
});
|
||||
|
||||
it('ignores comments beside a specifier', () => {
|
||||
const content = `import {\n // the config\n type Cfg as Config, /* runner */ run,\n load, // eslint-disable-line\n} from './cfg';`;
|
||||
expect(mappings(content).map((m) => `${m.exported}->${m.local}`)).toEqual(['Cfg->Config', 'run->run', 'load->load']);
|
||||
});
|
||||
|
||||
it('keeps `$` and non-ASCII identifiers', () => {
|
||||
const content = `import $ from 'jquery';\nimport { component$, $ as dollar, type Café } from './q';\nimport * as $$ from './all';`;
|
||||
expect(mappings(content).map((m) => `${m.exported}->${m.local}`)).toEqual([
|
||||
'default->$',
|
||||
'component$->component$',
|
||||
'$->dollar',
|
||||
'Café->Café',
|
||||
'*->$$',
|
||||
]);
|
||||
});
|
||||
|
||||
it('a comma inside a comment does not split the list; a block comment still separates words', () => {
|
||||
const content = `import {\n A, // used by foo, bar\n B, /* one, two */\n type/* inline */C,\n D/**/as E,\n} from './x';`;
|
||||
expect(mappings(content).map((m) => `${m.exported}->${m.local}`)).toEqual(['A->A', 'B->B', 'C->C', 'D->E']);
|
||||
});
|
||||
|
||||
it('leaves the rest of a Vue/Svelte file alone (markup is not JavaScript)', () => {
|
||||
const sfc = `<template><p>Don't panic</p></template>\n<script setup lang="ts">\nimport X from 'https://esm.sh/x';\nimport { type B } from './b';\nimport C from './c';\n</script>`;
|
||||
expect(extractImportMappings('a.vue', sfc, 'vue').map((m) => `${m.localName}@${m.source}`)).toEqual([
|
||||
'X@https://esm.sh/x',
|
||||
'B@./b',
|
||||
'C@./c',
|
||||
]);
|
||||
});
|
||||
|
||||
it('a multi-line JSDoc `@import`', () => {
|
||||
const js = `/**\n * @import {\n * Foo,\n * Bar as B\n * } from './types'\n */\nexport function g() {}`;
|
||||
expect(extractImportMappings('a.js', js, 'javascript').map((m) => `${m.exportedName}->${m.localName}`)).toEqual([
|
||||
'Foo->Foo',
|
||||
'Bar->B',
|
||||
]);
|
||||
});
|
||||
|
||||
it('phase imports: `defer` is a modifier; a `source` import binds no export', () => {
|
||||
expect(mappings(`import defer * as ns from 'x';\nimport source wasm from './m.wasm';\nimport defer from 'y';`).map((m) => `${m.exported}->${m.local}`)).toEqual([
|
||||
'*->ns',
|
||||
'default->defer',
|
||||
]);
|
||||
});
|
||||
|
||||
it('an import example inside an ordinary doc comment is not a JSDoc `@import`', () => {
|
||||
const js = `/**\n * Example:\n * import {\n * createStore,\n * } from 'redux'\n */\nexport function createStore() {}`;
|
||||
expect(extractImportMappings('a.js', js, 'javascript')).toEqual([]);
|
||||
});
|
||||
|
||||
it('imports written with no spaces', () => {
|
||||
expect(mappings(`import{a as b}from"x";import*as ns from"y";`).map((m) => `${m.exported}->${m.local}@${m.source}`)).toEqual([
|
||||
'a->b@x',
|
||||
'*->ns@y',
|
||||
]);
|
||||
});
|
||||
|
||||
it('keeps JSDoc `@import` tags', () => {
|
||||
const js = `/** @import { Foo } from './types' */\n/** @param {Foo} f */\nexport function g(f) {}`;
|
||||
expect(extractImportMappings('a.js', js, 'javascript').map((m) => `${m.localName}@${m.source}`)).toEqual(['Foo@./types']);
|
||||
});
|
||||
|
||||
it('`type` modifier with no space, and `type` named default with extra space', () => {
|
||||
expect(mappings(`import type{ A } from './a';\nimport type*as ns from './n';`).map((m) => `${m.exported}->${m.local}`)).toEqual([
|
||||
'A->A',
|
||||
'*->ns',
|
||||
]);
|
||||
expect(mappings(`import type from './t';\nimport type\n from './u';`).map((m) => `${m.exported}->${m.local}@${m.source}`)).toEqual([
|
||||
'default->type@./t',
|
||||
'default->type@./u',
|
||||
]);
|
||||
});
|
||||
|
||||
it('string-named specifiers (ES2022)', () => {
|
||||
expect(mappings(`import { "my-fn" as myFn, 'a b' as ab } from './m';`).map((m) => `${m.exported}->${m.local}`)).toEqual([
|
||||
'my-fn->myFn',
|
||||
'a b->ab',
|
||||
]);
|
||||
const re = extractReExports(`export { "my-fn" as myFn } from './m';`, 'typescript');
|
||||
expect(re.map((r) => r.kind === 'named' && `${r.originalName}->${r.exportedName}`)).toEqual(['my-fn->myFn']);
|
||||
});
|
||||
|
||||
it('a block comment in a list ends at its own `*/`', () => {
|
||||
const content = `import { a /* one */ } from './a';\nimport { b /* two */ } from './b';`;
|
||||
expect(mappings(content).map((m) => `${m.local}@${m.source}`)).toEqual(['a@./a', 'b@./b']);
|
||||
});
|
||||
|
||||
it('string names with commas, braces or escaped quotes; Flow `typeof`', () => {
|
||||
const content = `import { "a,b" as c, 'it\\'s' as e, typeof Foo as Bar } from './a';`;
|
||||
expect(mappings(content).map((m) => `${m.exported}->${m.local}`)).toEqual(['a,b->c', "it's->e", 'Foo->Bar']);
|
||||
const re = extractReExports(`export { "a-b" } from './a';\nexport * as "ns-x" from './b';`, 'typescript');
|
||||
expect(re.map((r) => (r.kind === 'named' ? `${r.originalName}->${r.exportedName}` : `*@${r.source}`))).toEqual([
|
||||
'*@./b',
|
||||
'a-b->a-b',
|
||||
]);
|
||||
});
|
||||
|
||||
it('Flow statement-level `typeof`; escapes in string names are decoded', () => {
|
||||
expect(mappings(`import typeof { a } from 'x';\nimport typeof Foo from 'y';`).map((m) => `${m.exported}->${m.local}@${m.source}`)).toEqual([
|
||||
'a->a@x',
|
||||
'default->Foo@y',
|
||||
]);
|
||||
expect(mappings(`import { "\\u0041" as d, "a\\nb" as e, "\\x41\\u0042" as f } from 'x';`).map((m) => `${JSON.stringify(m.exported)}->${m.local}`)).toEqual([
|
||||
'"A"->d',
|
||||
'"a\\nb"->e',
|
||||
'"AB"->f',
|
||||
]);
|
||||
});
|
||||
|
||||
it('parses a large barrel list (no length cap)', () => {
|
||||
const names = Array.from({ length: 1000 }, (_, i) => `Icon${i}`);
|
||||
expect(mappings(`import { ${names.join(', ')} } from 'icons';`)).toHaveLength(1000);
|
||||
expect(extractReExports(`export { ${names.join(', ')} } from './icons';`, 'typescript')).toHaveLength(1000);
|
||||
});
|
||||
|
||||
it('stays fast on long whitespace after `import`', () => {
|
||||
// Main took ~5 s on this input (~40 s on 800 spaces); linear now.
|
||||
// Kept small on purpose: a synchronous regex can't be interrupted, so a
|
||||
// reintroduced blow-up must fail in seconds, not hang the suite.
|
||||
const content = `import type${' '.repeat(400)}\n// we import them${'\n'.repeat(400)}`;
|
||||
const t = Date.now();
|
||||
mappings(content);
|
||||
expect(Date.now() - t).toBeLessThan(1000);
|
||||
});
|
||||
|
||||
it('still treats `type` as a name where it is one', () => {
|
||||
// A default import named `type`, a named import `type`, and one aliased.
|
||||
expect(mappings(`import type from './t';\nimport { type } from './u';\nimport { type as kind } from './v';`)).toEqual([
|
||||
{ local: 'type', exported: 'default', source: './t', isDefault: true, isNamespace: false },
|
||||
{ local: 'type', exported: 'type', source: './u', isDefault: false, isNamespace: false },
|
||||
{ local: 'kind', exported: 'type', source: './v', isDefault: false, isNamespace: false },
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('re-exports with a `type` modifier', () => {
|
||||
const named = (content: string) =>
|
||||
extractReExports(content, 'typescript')
|
||||
.filter((r) => r.kind === 'named')
|
||||
.map((r) => r.kind === 'named' && `${r.originalName}->${r.exportedName}@${r.source}`);
|
||||
|
||||
it('inline `type` in a re-export list', () => {
|
||||
expect(named(`export { type Cfg, run, type Opts as O } from './cfg';`)).toEqual([
|
||||
'Cfg->Cfg@./cfg',
|
||||
'run->run@./cfg',
|
||||
'Opts->O@./cfg',
|
||||
]);
|
||||
});
|
||||
|
||||
it('`export type * from`', () => {
|
||||
const wild = extractReExports(`export type * from './a';`, 'typescript')
|
||||
.filter((r) => r.kind === 'wildcard')
|
||||
.map((r) => r.source);
|
||||
expect(wild).toEqual(['./a']);
|
||||
});
|
||||
|
||||
it('comments in a re-export list are stripped even when the file-wide scan loses its place', () => {
|
||||
const tsx = `const A = () => <p>Don't</p>;\nexport {\n a, // old, b\n c,\n} from './a';`;
|
||||
expect(named(tsx)).toEqual(['a->a@./a', 'c->c@./a']);
|
||||
});
|
||||
|
||||
it('`export` must be a whole word', () => {
|
||||
expect(extractReExports(`reexport { a } from './a';\nmyexport * from './b';\nexporttype { c } from './c';`, 'typescript')).toEqual([]);
|
||||
});
|
||||
|
||||
it('`export type { … } from`', () => {
|
||||
expect(named(`export type { Cfg, Opts as O } from './cfg';`)).toEqual(['Cfg->Cfg@./cfg', 'Opts->O@./cfg']);
|
||||
});
|
||||
});
|
||||
|
||||
describe('a type imported with a `type` modifier resolves to its declaration', () => {
|
||||
let dir: string;
|
||||
let cg: CodeGraph;
|
||||
|
||||
beforeAll(async () => {
|
||||
dir = fs.mkdtempSync(path.join(os.tmpdir(), 'cg-ts-type-mod-'));
|
||||
const w = (rel: string, text: string) => {
|
||||
fs.mkdirSync(path.dirname(path.join(dir, rel)), { recursive: true });
|
||||
fs.writeFileSync(path.join(dir, rel), text);
|
||||
};
|
||||
// A same-named decoy that name-matching alone would pick first.
|
||||
w('app/cfg-local.ts', 'export interface Cfg { local: number }\n');
|
||||
w('lib/cfg.ts', 'export interface Cfg { real: number }\n');
|
||||
w('lib/index.ts', "export type { Cfg } from './cfg';\n");
|
||||
w('app/inline.ts', "import { type Cfg } from '../lib/cfg';\nexport function inline(c: Cfg) { return c; }\n");
|
||||
w('app/barrel.ts', "import type { Cfg } from '../lib';\nexport function barrel(c: Cfg) { return c; }\n");
|
||||
// `$` / non-ASCII names, same-named in two files: the import decides.
|
||||
const dollar = 'export function $fetch() {}\nexport function user$() {}\nexport function café() {}\n';
|
||||
w('fx/a.ts', dollar);
|
||||
w('fx/b.ts', dollar);
|
||||
w('fx/main.ts', "import { $fetch, user$, café } from './b';\nexport function main() { $fetch(); user$(); café(); }\n");
|
||||
cg = await CodeGraph.init(dir, { index: true });
|
||||
});
|
||||
|
||||
afterAll(() => {
|
||||
cg?.destroy();
|
||||
fs.rmSync(dir, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
const typeRefs = (fn: string) => {
|
||||
const node = cg.getNodesByName(fn).find((n) => n.kind === 'function');
|
||||
expect(node).toBeDefined();
|
||||
return cg
|
||||
.getCallees(node!.id)
|
||||
.filter(({ node: t }) => t.name === 'Cfg')
|
||||
.map(({ node: t }) => t.filePath);
|
||||
};
|
||||
|
||||
it('`$` and non-ASCII named imports bind to the file they are imported from', () => {
|
||||
const main = cg.getNodesByName('main').find((n) => n.kind === 'function' && n.filePath === 'fx/main.ts');
|
||||
expect(main).toBeDefined();
|
||||
const targets = cg.getCallees(main!.id).map(({ node }) => `${node.name}@${node.filePath}`).sort();
|
||||
expect(targets).toEqual(['$fetch@fx/b.ts', 'café@fx/b.ts', 'user$@fx/b.ts']);
|
||||
});
|
||||
|
||||
it('`import { type Cfg }` → lib/cfg.ts', () => {
|
||||
expect(typeRefs('inline')).toEqual(['lib/cfg.ts']);
|
||||
});
|
||||
|
||||
it('`import type { Cfg }` through a type-only barrel → lib/cfg.ts', () => {
|
||||
expect(typeRefs('barrel')).toEqual(['lib/cfg.ts']);
|
||||
});
|
||||
});
|
||||
|
||||
describe('string-named namespace re-export', () => {
|
||||
it('`export * as "aa" from` exports the namespace, not every member', () => {
|
||||
expect(extractReExports(`export * as "aa" from './a';\nexport * from './b';\n`, 'typescript'))
|
||||
.toEqual([{ kind: 'namespace', exportedName: 'aa', source: './a' }, { kind: 'wildcard', source: './b' }]);
|
||||
});
|
||||
});
|
||||
@@ -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