diff --git a/CHANGELOG.md b/CHANGELOG.md index f1b8b242d..b4c92c1d8 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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 ` items={…} />` or excalidraw's ` …>`, 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, `` 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 `` 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. diff --git a/__tests__/ts-type-modifier-imports.test.ts b/__tests__/ts-type-modifier-imports.test.ts new file mode 100644 index 000000000..e5882aba0 --- /dev/null +++ b/__tests__/ts-type-modifier-imports.test.ts @@ -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 = `\n`; + 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 = () =>

Don't

;\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' }]); + }); +}); diff --git a/src/resolution/import-resolver.ts b/src/resolution/import-resolver.ts index a8548d7f5..af6d1988e 100644 --- a/src/resolution/import-resolver.ts +++ b/src/resolution/import-resolver.ts @@ -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( + `(? = { 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, + }); } }