diff --git a/CHANGELOG.md b/CHANGELOG.md
index 5ff9e3ba3..5aea6ce2a 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -212,6 +212,7 @@ and adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
- `codegraph sync` and the file watcher now move a React Router route to its page when the router file loads that page lazily into a value of its own, like `const Docs = lazy(() => import('./pages/Docs'))` rendered by ` } />`, and the page's file is added after the router was indexed, comes back after being deleted, or gets or changes its default export in a later edit. Before, the route kept pointing at the `Docs` declaration, or at the page's old component, until the router file itself changed or the project was re-indexed, unless the page's component happened to share the declaration's name. The same now holds for a `lazy: () => import('./pages/Docs')` route whose page switches its default export to another component. Re-index React projects after upgrading to correct routes an earlier version left behind.
- `codegraph sync` and the file watcher now link a component to the JSX that renders it, like ` `, when the component is added in a file with no JSX of its own, as in `export default function Team() { return null; }`. Before, the link waited for an unrelated change to a file with JSX, or for a re-index, so the new component looked unused.
- In JavaScript and TypeScript, a default import now links to what the module's `export default` statement exports, wherever the statement sits in the file. Before, a module that declares its default, like `export default function Vans() {…}` or `export default class Store {…}`, had its default import linked to the first function it exports instead: in a React Router data-router app, where a page exports its `loader` or `action` above the page component, `import Vans, { loader as vansLoader } from './pages/Vans'` made the route `/vans` and every use of `Vans` lead to the loader. A component file that also exports a styled or memoized component no longer hands its default import to that component, and an anonymous default, like `export default function () {…}` or `export default () => …`, is no longer linked to a function exported beside it or nested inside it. A module loaded with `require('./x').default` is read the same way. Re-index JavaScript and TypeScript projects after upgrading.
+- In JavaScript and TypeScript, an import now links to what its module passes on when the module exports an import of its own, like outline's `server/presenters/index.ts`, which imports each presenter and lists it in `export { presentUser, presentPolicies, … }`, or an `index.ts` written `import Footer from './FooterCenter'; export { Footer }` or `export default Avatar`, and `export { createThing as default }` is read too. A default export written as an expression now links as well: `export default new Logger()` to the methods of that `Logger` class, and a wrapper like `export default connect(mapState)(Profile)`, `observer(DocumentNew)` or `traceFunction({ … })(accountProvisioner)` to the component or function it wraps. Before, such an import was matched by its name alone, so it could land on a namesake elsewhere in the project, as when outline's server logging calls linked to the browser app's `Logger` and excalidraw's public `Footer` to an internal component of that name, or on whichever function the module happened to export first. Re-index JavaScript and TypeScript projects after upgrading.
- In React and Vue, a tag for a component the file imports from a package, like `` from antd, `` from `@mui/material/Typography`, ` ` from react-router-dom or `` from ant-design-vue, no longer links to a component, function or class in your repository that only shares its name. Before, the tag linked to whatever the repository had by that name, so `codegraph_explore`, callers and impact followed renders that never happen: in SigNoz, antd's `` tags showed up as callers of a styled link on its 404 page, and in Material UI dashboards like mantis and berry, `` and `` tags showed up as callers of the theme's style overrides. A package here is one that a `package.json` above the file lists and that no file in the repository answers to. A tag imported through a path alias like `components/Modal`, from a workspace package of the repository, or under a name no `package.json` lists still links as before. Re-index React and Vue projects after upgrading.
- 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.
diff --git a/__tests__/import-forwarding.test.ts b/__tests__/import-forwarding.test.ts
new file mode 100644
index 000000000..eee27ed8c
--- /dev/null
+++ b/__tests__/import-forwarding.test.ts
@@ -0,0 +1,193 @@
+/**
+ * An import reaches the declaration its module exports, when the module
+ * exports an import of its own or writes its default export as an expression.
+ *
+ * A module that hands on what it imports — outline's `presenters/index.ts`
+ * is `import presentUser from './user'; … export { presentUser, … }`, and an
+ * `index.ts` beside a component is `import Avatar from './Avatar'; export
+ * default Avatar` — declares nothing under the exported name, so the import
+ * fell through to a name match, which took any namesake in the project. A
+ * default written as an expression had only the first-exported-function guess
+ * behind it: `export default new Logger()` sent a server's logging calls to
+ * the browser app's `Logger` (outline makes one in each), and a function a
+ * wrapper hands on (`traceFunction({…})(accountProvisioner)`) or one `export
+ * { createThing as default }` names was replaced by whatever the module
+ * exported first.
+ *
+ * The sync cases compare the synced graph with a full index of the final tree
+ * in a folder of its own (#2411).
+ */
+import { describe, it, expect, afterEach } from 'vitest';
+import * as fs from 'fs';
+import * as os from 'os';
+import * as path from 'path';
+import CodeGraph from '../src/index';
+import { DatabaseConnection, getDatabasePath } from '../src/db';
+
+type Files = Record;
+
+let roots: string[] = [];
+let graphs: CodeGraph[] = [];
+
+afterEach(() => {
+ for (const graph of graphs) graph.close();
+ graphs = [];
+ for (const root of roots) fs.rmSync(root, { recursive: true, force: true });
+ roots = [];
+});
+
+function tempRoot(): string {
+ const root = fs.mkdtempSync(path.join(os.tmpdir(), 'cg-forwarding-'));
+ roots.push(root);
+ return root;
+}
+
+function write(root: string, files: Files): void {
+ for (const [file, text] of Object.entries(files)) {
+ fs.mkdirSync(path.dirname(path.join(root, file)), { recursive: true });
+ fs.writeFileSync(path.join(root, file), text);
+ }
+}
+
+async function index(files: Files, root = tempRoot()): Promise {
+ write(root, files);
+ const graph = await CodeGraph.init(root, { index: true });
+ graphs.push(graph);
+ return graph;
+}
+
+/** Where the calls out of `source` landed: ` ::`. */
+function callsOf(graph: CodeGraph, source: string): string[] {
+ const nodes = graph.getFiles().flatMap((file) => graph.getNodesInFile(file.path));
+ const from = nodes.filter((n) => n.name === source).map((n) => n.id);
+ return graph.getOutgoingEdgesFrom(from)
+ .filter((e) => e.kind === 'calls')
+ .map((e) => {
+ const target = graph.getNode(e.target)!;
+ return `${target.kind} ${target.filePath}::${target.qualifiedName}`;
+ })
+ .sort();
+}
+
+/** Every edge by its ends' natural keys, and every reference left unresolved. */
+function stateOf(root: string, graph: CodeGraph): string[] {
+ const keys = new Map(
+ graph.getFiles()
+ .flatMap((file) => graph.getNodesInFile(file.path))
+ .map((n) => [n.id, `${n.kind} ${n.filePath}:${n.qualifiedName}:${n.startLine}`] as const)
+ );
+ const edges = graph.getOutgoingEdgesFrom([...keys.keys()])
+ .map((e) => `${e.kind} ${keys.get(e.source)} -> ${keys.get(e.target) ?? e.target} @${e.line}:${e.column} ${JSON.stringify(e.metadata ?? {})}`);
+ const db = DatabaseConnection.open(getDatabasePath(root));
+ try {
+ const refs = (db.getDb()
+ .prepare('SELECT reference_name, reference_kind, file_path, line, col, status, name_tail FROM unresolved_refs')
+ .all() as Array>)
+ .map((r) => `ref ${r.file_path}:${r.line}:${r.col} ${r.reference_kind} ${r.reference_name} ${r.status} ${r.name_tail}`);
+ return [...edges, ...refs].sort();
+ } finally {
+ db.close();
+ }
+}
+
+/** Index `before`, write `after` over it, sync; return the graph and what a full index of `after` holds. */
+async function syncEdit(before: Files, after: Files): Promise<{ root: string; graph: CodeGraph; full: string[] }> {
+ const root = tempRoot();
+ const graph = await index(before, root);
+ write(root, after);
+ await graph.sync();
+ const fullRoot = tempRoot();
+ const full = stateOf(fullRoot, await index({ ...before, ...after }, fullRoot));
+ return { root, graph, full };
+}
+
+/** A function of the decoy app, named like every one the tests import. */
+const decoy = (...names: string[]): Files => ({
+ 'a-decoy/lib.ts': names.map((n) => `export function ${n}() { return 'decoy'; }\n`).join(''),
+});
+
+describe('an import of a module that exports an import of its own', () => {
+ it('reaches what the module imports, under its own name or another', async () => {
+ const graph = await index({
+ ...decoy('presentUser', 'presentTeam'),
+ 'presenters/user.ts': "export default function presentUser() { return 'user'; }\n",
+ 'presenters/team.ts': "export function presentTeam() { return 'team'; }\n",
+ 'presenters/index.ts': "import presentUser from './user';\nimport { presentTeam } from './team';\nexport { presentUser, presentTeam as team };\n",
+ 'api/users.ts': "import { presentUser, team } from '../presenters';\nexport function show() { return presentUser() + team(); }\n",
+ 'api/teams.ts': "import * as presenters from '../presenters';\nexport function list() { return presenters.presentUser(); }\n",
+ });
+ expect(callsOf(graph, 'show')).toEqual([
+ 'function presenters/team.ts::presentTeam',
+ 'function presenters/user.ts::presentUser',
+ ]);
+ expect(callsOf(graph, 'list')).toEqual(['function presenters/user.ts::presentUser']);
+ });
+
+ it('reaches the default export a module imports and exports as its own', async () => {
+ const graph = await index({
+ ...decoy('makeAvatar', 'Marker'),
+ 'lib/avatar/makeAvatar.ts': "export default function makeAvatar() { return 'avatar'; }\n",
+ 'lib/avatar/index.ts': "import makeAvatar from './makeAvatar';\nexport default makeAvatar;\n",
+ 'lib/map/marker.ts': "export function Marker() { return 'marker'; }\n",
+ 'lib/map/index.ts': "import { Marker } from './marker';\nexport { Marker as default };\n",
+ 'main.ts': "import makeAvatar from './lib/avatar';\nimport placeMarker from './lib/map';\nexport function start() { return makeAvatar() + placeMarker(); }\n",
+ });
+ expect(callsOf(graph, 'start')).toEqual([
+ 'function lib/avatar/makeAvatar.ts::makeAvatar',
+ 'function lib/map/marker.ts::Marker',
+ ]);
+ });
+
+ it('follows the module when a sync changes the import it exports as its default', async () => {
+ const lib = (from: string): Files => ({ 'lib/index.ts': `import impl from './${from}';\nexport default impl;\n` });
+ const before = {
+ 'lib/a.ts': "export default function runA() { return 'a'; }\n",
+ 'lib/b.ts': "export default function runB() { return 'b'; }\n",
+ 'main.ts': "import run from './lib';\nexport function start() { return run(); }\n",
+ ...lib('a'),
+ };
+ const { root, graph, full } = await syncEdit(before, lib('b'));
+ expect(callsOf(graph, 'start')).toEqual(['function lib/b.ts::runB']);
+ expect(stateOf(root, graph)).toEqual(full);
+ }, 120_000);
+});
+
+describe('a default export written as an expression', () => {
+ it('is an instance of the class it makes, whose methods its importers call', async () => {
+ const logger = 'class Logger {\n info(m: string) { return m; }\n warn(m: string) { return m; }\n}\nexport default new Logger();\n';
+ const graph = await index({
+ 'app/logger.ts': `export function format(m: string) { return m; }\n${logger}`,
+ 'server/logger.ts': logger,
+ 'server/jobs.ts': "import Logger from './logger';\nexport function work() { Logger.info('start'); return Logger.warn('done'); }\n",
+ });
+ expect(callsOf(graph, 'work')).toEqual(['method server/logger.ts::Logger::info', 'method server/logger.ts::Logger::warn']);
+ });
+
+ it('is what a wrapper call hands on, or the declaration a clause names', async () => {
+ const graph = await index({
+ ...decoy('provision', 'make'),
+ 'tracing.ts': 'export function traceFunction(opts: object) { return (fn: T) => fn; }\nexport function withRetry(fn: T) { return fn; }\n',
+ 'provisioner.ts': "import { traceFunction } from './tracing';\nexport function helperFirst() { return 1; }\nasync function accountProvisioner() { return { ok: true }; }\nexport default traceFunction({ spanName: 'accountProvisioner' })(accountProvisioner);\n",
+ 'job.ts': "import { withRetry } from './tracing';\nexport function helperFirst() { return 1; }\nfunction SyncJob() { return 2; }\nexport default withRetry(SyncJob);\n",
+ 'svc.ts': 'export function first() { return 1; }\nfunction createThing() { return 2; }\nexport { createThing as default };\n',
+ 'main.ts': "import provision from './provisioner';\nimport job from './job';\nimport make from './svc';\nexport async function start() { await provision(); job(); return make(); }\n",
+ });
+ expect(callsOf(graph, 'start')).toEqual([
+ 'function job.ts::SyncJob',
+ 'function provisioner.ts::accountProvisioner',
+ 'function svc.ts::createThing',
+ ]);
+ });
+
+ it('is not the argument of a factory, nor anything past the call it ends with', async () => {
+ const graph = await index({
+ 'factory.ts': 'export function createStore(r: (s: unknown) => unknown) { return { dispatch(a: unknown) { return r(a); } }; }\n',
+ 'store.ts': "import { createStore } from './factory';\nfunction reducer(state: unknown) { return state; }\nexport default createStore(reducer);\n",
+ 'client.ts': 'class Client { configure() { return this; } send() { return 1; } }\nexport function connect() { return 0; }\nexport default new Client()\n .configure();\n',
+ 'main.ts': "import store from './store';\nimport client from './client';\nexport function start() { client.send(); return store.dispatch({ type: 'go' }); }\n",
+ });
+ const calls = callsOf(graph, 'start');
+ expect(calls.some((c) => c.endsWith('::reducer'))).toBe(false);
+ expect(calls.some((c) => c.startsWith('method client.ts::Client::'))).toBe(false);
+ });
+});
diff --git a/src/resolution/import-resolver.ts b/src/resolution/import-resolver.ts
index d7e844771..796419a41 100644
--- a/src/resolution/import-resolver.ts
+++ b/src/resolution/import-resolver.ts
@@ -14,6 +14,7 @@ import { extractLocalExportAliases } from './alias-binding';
import { resolveWorkspaceImport } from './workspace-packages';
import { stripCommentsForRegex } from './strip-comments';
import { makeLineAt } from './synth-utils';
+import { matchBracket, skipString } from './frameworks/object-literal';
import { dartDirectiveFile } from './dart-libraries';
import { ambientModuleDeclares, findAmbientModuleExport, findAmbientModuleFile, inBlock, isInModuleBlock, namespaceBlocks, type NamespaceBlock } from './ambient-modules';
import { isTestFile } from '../search/query-utils';
@@ -126,6 +127,12 @@ interface FileExportIndex {
* `byName`. `undefined` until first read through {@link exportedByName}.
*/
clauseAliases?: Map;
+ /**
+ * Names a local export clause exports that the file declares nothing of —
+ * an import it hands on, `import Marker from './marker'; export { Marker }`
+ * — by local name. Filled with `clauseAliases`.
+ */
+ clauseImports?: Map;
}
const DEFAULT_BINDING_KINDS = new Set(['function', 'class', 'component', 'constant', 'variable']);
@@ -136,10 +143,26 @@ const JS_FAMILY_FILE = /\.(?:[cm]?[jt]sx?)$/;
/**
* What a file's own `export default` statement exports: the node it declares
- * or names, `'anonymous'` for a function or class with no name (no node
+ * or names, the import it hands on (`import Page from './Page'; export
+ * default Page`), `'anonymous'` for a function or class with no name (no node
* stands for it), `'unstated'` when the file says nothing a node answers.
*/
-type StatedDefault = Node | 'anonymous' | 'unstated';
+type StatedDefault = Node | { imported: ImportMapping } | 'anonymous' | 'unstated';
+
+/**
+ * A default export's value as its statement writes it: a declaration, found
+ * by position (`declared`); the class an instance is made from (`instance`,
+ * `new Logger()`); what a wrapper call hands on (`handed`, `observer(Login)`),
+ * which only a function, class or component of the file answers; or a
+ * binding named whole, which may be an import.
+ */
+interface DefaultValue {
+ name: string;
+ nameAt: number;
+ declared: boolean;
+ instance?: boolean;
+ handed?: boolean;
+}
const EXPORT_DEFAULT_HEAD = /^[ \t]*export\s+default\s+/gm;
/** `[async] function[*] [NAME]` / `[abstract] class [NAME]`; the name is group 1 or 2. */
@@ -149,14 +172,19 @@ const DEFAULT_DECLARATION_RE =
const DEFAULT_ARROW_RE = /^(?:async\s+)?(?:[A-Za-z_$][\w$]*\s*=>|\()/;
/** `NAME` alone on its line. */
const DEFAULT_BINDING_RE = /^([A-Za-z_$][\w$]*)\s*;?[ \t]*(?:\r?\n|$)/;
+/** `new NAME(` / `new NAME<…>(`; the name is group 1. */
+const DEFAULT_INSTANCE_RE = /^new\s+([A-Za-z_$][\w$]*)\s*(?:<[^()]*>)?\s*\(/;
+/** A name or member path about to be called, type arguments included. */
+const CALLEE_RE = /^[A-Za-z_$][\w$]*(?:\s*\.\s*[A-Za-z_$][\w$]*)*\s*(?:<[^()]*>)?\s*(?=\()/;
/**
* The value of the `export default` statement whose value starts at `at`, in
- * comment-stripped code: the name a declaration or a binding gives it,
- * `'anonymous'` for a function, class or arrow function without one, or null
- * for any other expression.
+ * comment-stripped code: the name a declaration or a binding gives it, the
+ * class `new Logger()` makes an instance of, or what a wrapper call hands on
+ * (`handedOn`); `'anonymous'` for a function, class or arrow function without
+ * a name, or null for any other expression.
*/
-function readDefaultExport(code: string, at: number): { name: string; nameAt: number; declared: boolean } | 'anonymous' | null {
+function readDefaultExport(code: string, at: number): DefaultValue | 'anonymous' | null {
const rest = code.slice(at, at + 2048);
const decl = DEFAULT_DECLARATION_RE.exec(rest);
if (decl) {
@@ -177,7 +205,106 @@ function readDefaultExport(code: string, at: number): { name: string; nameAt: nu
return null;
}
const binding = DEFAULT_BINDING_RE.exec(rest);
- return binding ? { name: binding[1]!, nameAt: at, declared: false } : null;
+ if (binding) return { name: binding[1]!, nameAt: at, declared: false };
+ const instance = DEFAULT_INSTANCE_RE.exec(rest);
+ if (instance) {
+ const close = matchBracket(rest, instance[0].length - 1);
+ return close >= 0 && endsStatement(rest, close + 1)
+ ? { name: instance[1]!, nameAt: at + instance[0].indexOf(instance[1]!, 3), declared: false, instance: true }
+ : null;
+ }
+ const call = callChainAt(rest, 0);
+ const handed = call && endsStatement(rest, call.end) ? handedOn(rest, call) : null;
+ return handed ? { ...handed, nameAt: at + handed.nameAt } : null;
+}
+
+/** A call chain, `callee(…)(…)…`: where it ends, how many argument lists it has, and its last list's first argument. */
+interface CallChain {
+ end: number;
+ lists: number;
+ first: { text: string; at: number };
+}
+
+/** The call chain written at `from` in `s`, or null when none starts there. */
+function callChainAt(s: string, from: number): CallChain | null {
+ const callee = CALLEE_RE.exec(s.slice(from, from + 512));
+ if (!callee) return null;
+ let open = from + callee[0].length;
+ const chain: CallChain = { end: open, lists: 0, first: { text: '', at: open } };
+ while (s[open] === '(') {
+ const close = matchBracket(s, open);
+ if (close < 0) return null;
+ chain.first = firstArgument(s, open + 1, close);
+ chain.lists++;
+ chain.end = close + 1;
+ for (open = chain.end; s[open] === ' ' || s[open] === '\t'; ) open++;
+ }
+ return chain;
+}
+
+/** The first argument of the list in `s[from, to)`, trimmed, and where it starts. */
+function firstArgument(s: string, from: number, to: number): { text: string; at: number } {
+ let start = from;
+ while (start < to && /\s/.test(s[start]!)) start++;
+ let i = start;
+ for (; i < to && s[i] !== ','; i++) {
+ const ch = s[i]!;
+ const end = ch === '"' || ch === "'" || ch === '`' ? skipString(s, i) : ch === '(' || ch === '[' || ch === '{' ? matchBracket(s, i) : i;
+ if (end < 0) break;
+ i = end;
+ }
+ return { text: s.slice(start, i).trimEnd(), at: start };
+}
+
+/**
+ * What a wrapper call exported as the default hands on: its last argument
+ * list's first argument, when that is a name the call can't have made
+ * something else of — a capitalized one, as a component or class is
+ * (`observer(Login)`, `React.memo(Row, areEqual)`), or the last of a curried
+ * chain (`connect(mapState)(Login)`, outline's `traceFunction({…})(provision)`)
+ * — or a function or class written there (`memo(function Sandpack() {…})`),
+ * through wrappers around it (`withTranslation()(withRouter(Login))`). A lone
+ * call's lowercase argument is a factory's input, not what it returns:
+ * `createStore(reducer)` is a store. So is a value: `createContext(Theme)` is
+ * a context, so a name hands on only a function, class or component
+ * (statedDefaultExport). Offsets are `s`'s.
+ */
+function handedOn(s: string, call: CallChain): DefaultValue | null {
+ const { text, at } = call.first;
+ if (/^[A-Za-z_$][\w$]*$/.test(text)) {
+ return call.lists > 1 || /^[A-Z]/.test(text) ? { name: text, nameAt: at, declared: false, handed: true } : null;
+ }
+ const decl = DEFAULT_DECLARATION_RE.exec(text);
+ if (decl) {
+ const name = decl[1] ?? decl[2];
+ return name ? { name, nameAt: at + decl[0].length - name.length, declared: true } : null;
+ }
+ const inner = callChainAt(s, at);
+ return inner && inner.end === at + text.length ? handedOn(s, inner) : null;
+}
+
+/**
+ * Whether the expression ending at `end` ends its statement: a `;`, a type
+ * assertion (`as T`, `satisfies T`), or a line break the next line doesn't
+ * carry the expression past — `new Client()` with `.configure()` on the line
+ * below is no instance of Client.
+ */
+function endsStatement(s: string, end: number): boolean {
+ const after = /^[ \t]*(?:;|(?:as|satisfies)\s|(\r?\n\s*|$))/.exec(s.slice(end, end + 256));
+ if (!after) return false;
+ if (after[1] === undefined) return true;
+ const next = s[end + after[0].length];
+ return next === undefined || !/[.([`+\-*/%<>=&|^?,]/.test(next);
+}
+
+/** The language a JS-family file is written in, by its extension. */
+function jsFamilyLanguage(filePath: string): Language {
+ return filePath.endsWith('.tsx') ? 'tsx' : /\.[cm]?ts$/.test(filePath) ? 'typescript' : filePath.endsWith('.jsx') ? 'jsx' : 'javascript';
+}
+
+/** The import `filePath` binds `name` with, unless it is a namespace import. */
+function importBinding(filePath: string, name: string, context: ResolutionContext): ImportMapping | undefined {
+ return context.getImportMappings(filePath, jsFamilyLanguage(filePath)).find((m) => m.localName === name && !m.isNamespace);
}
/**
@@ -190,25 +317,33 @@ function readDefaultExport(code: string, at: number): { name: string; nameAt: nu
* - `export default Home` — the binding it names, exported at its
* declaration or not: `const Home = () => …` and the namespace object
* `const UploadApi = { uploadARCapture }` have no `export_statement`
- * ancestor, so the `isExported` index never sees them.
+ * ancestor, so the `isExported` index never sees them. A binding the file
+ * imports is the import (`import Home from './Home'; export default Home`),
+ * followed by {@link esmDefaultExport}; looked up by name, it reached a
+ * namesake anywhere in the project. So does `export { Home as default }`.
+ * - `export default new Logger()` — the class of the instance, whose methods
+ * its importers call (outline's server and app each make their own
+ * `Logger` so).
+ * - `export default observer(Login)`, `traceFunction({…})(provision)` — what
+ * the wrapper hands on ({@link handedOn}).
* - `export default function () {…}`, `export default class {…}`, `export
* default () => …` — anonymous: no node is the default export, and any
* exported function beside it (or nested in it, which the extractor flags
* exported too) would be the wrong one.
- * Anything else — an expression (`memo(Card)`, `new Service()`, `{ … }`), a
- * binding the file doesn't declare, no statement at all, a non-JS file — is
- * `'unstated'`. Comments are stripped first. Strings are not masked: a
- * template whose `${…}` holds another template, or a regex with a backtick,
- * sets a masker out of step, and it blanked the real statement after it
- * (outline's 2,900-line styled-components `Styles.ts`). A statement must
- * name a node of the file instead — one a template only writes as text
+ * Anything else — another expression (`createStore(reducer)`, `{ … }`), a
+ * binding the file neither declares nor imports, no statement at all, a
+ * non-JS file — is `'unstated'`. Comments are stripped first. Strings are not
+ * masked: a template whose `${…}` holds another template, or a regex with a
+ * backtick, sets a masker out of step, and it blanked the real statement
+ * after it (outline's 2,900-line styled-components `Styles.ts`). A statement
+ * must name a node of the file instead — one a template only writes as text
* names none — and of several statements, one that does wins.
*/
function statedDefaultExport(filePath: string, idx: FileExportIndex, context: ResolutionContext): StatedDefault {
if (idx.statedDefault !== undefined) return idx.statedDefault;
let stated: StatedDefault | undefined;
const source = JS_FAMILY_FILE.test(filePath) ? context.readFile(filePath) : null;
- if (source && /\bexport\s+default\b/.test(source)) {
+ if (source && /\bexport\s+default\b|\bas\s+default\b/.test(source)) {
const code = stripCommentsForRegex(source, 'typescript');
const lineAt = makeLineAt(code, 1);
EXPORT_DEFAULT_HEAD.lastIndex = 0;
@@ -220,25 +355,48 @@ function statedDefaultExport(filePath: string, idx: FileExportIndex, context: Re
}
stated ??= 'unstated';
if (value === null) continue;
- const named = nodesInFileNamed(filePath, value.name, context);
// A declaration starts on the statement's line, or on its name's when
// the statement breaks before it (`export default\nfunction X`).
const from = lineAt(m.index);
const to = lineAt(value.nameAt);
- const node = value.declared
- ? named.find((n) => DEFAULT_DECLARATION_KINDS.has(n.kind) && n.startLine >= from && n.startLine <= to)
- : named
- .filter((n) => DEFAULT_BINDING_KINDS.has(n.kind))
- .sort((a, b) => a.startLine - b.startLine || a.startColumn - b.startColumn)[0];
- if (node) {
- stated = node;
+ const found = value.declared
+ ? nodesInFileNamed(filePath, value.name, context)
+ .find((n) => DEFAULT_DECLARATION_KINDS.has(n.kind) && n.startLine >= from && n.startLine <= to)
+ : value.instance || value.handed
+ ? firstDeclared(filePath, value.name, context, value.instance ? INSTANCE_KINDS : DEFAULT_DECLARATION_KINDS)
+ : bindingOrImport(filePath, value.name, context);
+ if (found) {
+ stated = found;
break;
}
}
+ // `export { Home as default }` names it in a clause.
+ if (stated === undefined || stated === 'unstated') {
+ const local = extractLocalExportAliases(code).find((a) => a.exportedName === 'default')?.localName;
+ const found = local === undefined ? undefined : bindingOrImport(filePath, local, context);
+ if (found) stated = found;
+ }
}
idx.statedDefault = stated ?? 'unstated';
return idx.statedDefault;
}
+
+const INSTANCE_KINDS = new Set(['class']);
+
+/** The file's first declaration of `name` of one of `kinds`, by position. */
+function firstDeclared(filePath: string, name: string, context: ResolutionContext, kinds: ReadonlySet = DEFAULT_BINDING_KINDS): Node | undefined {
+ return nodesInFileNamed(filePath, name, context)
+ .filter((n) => kinds.has(n.kind))
+ .sort((a, b) => a.startLine - b.startLine || a.startColumn - b.startColumn)[0];
+}
+
+/** What a default export naming `name` whole is: the file's declaration of it, else the import that binds it. */
+function bindingOrImport(filePath: string, name: string, context: ResolutionContext): Node | { imported: ImportMapping } | undefined {
+ const node = firstDeclared(filePath, name, context);
+ if (node) return node;
+ const imported = importBinding(filePath, name, context);
+ return imported && { imported };
+}
const fileExportIndexes = new WeakMap>();
/**
@@ -291,17 +449,62 @@ function nodesInFileNamed(filePath: string, name: string, context: ResolutionCon
/**
* The declaration a module default-exports: what its own `export default`
- * statement declares or names (see {@link statedDefaultExport}), and nothing
- * for an anonymous default. Only a default the statement leaves unnamed
- * (`export default memo(Card)`, `new Service()`), or a module with no
- * statement, falls back: to the component a single-file component file is,
- * else to the first exported function or class.
+ * statement declares or names (see {@link statedDefaultExport}), followed
+ * into the module an import it names comes from, and nothing for an
+ * anonymous default. Only a default the statement leaves unnamed (`export
+ * default createStore(reducer)`), or a module with no statement, falls back:
+ * to the component a single-file component file is, else to the first
+ * exported function or class.
*/
-function esmDefaultExport(filePath: string, idx: FileExportIndex, context: ResolutionContext): Node | undefined {
+function esmDefaultExport(
+ filePath: string,
+ idx: FileExportIndex,
+ language: Language,
+ context: ResolutionContext,
+ visited: Set,
+ depth: number
+): Node | undefined {
const stated = statedDefaultExport(filePath, idx, context);
if (stated === 'anonymous') return undefined;
- if (stated !== 'unstated') return stated;
- return idx.defaultComponent ?? idx.defaultFnClass;
+ if (stated === 'unstated') return idx.defaultComponent ?? idx.defaultFnClass;
+ return 'imported' in stated ? importedDeclaration(filePath, stated.imported, language, context, visited, depth) : stated;
+}
+
+/**
+ * What `name` is when `filePath` exports an import of its own under it — a
+ * module handing on what it imports, as outline's `presenters/index.ts` does
+ * every presenter: `import presentUser from './user'; export { presentUser }`
+ * (or `export { presentUser as user }`). Looked up by name instead, the
+ * binding reached a namesake anywhere in the project.
+ */
+function forwardedExport(
+ filePath: string,
+ idx: FileExportIndex,
+ name: string,
+ language: Language,
+ context: ResolutionContext,
+ visited: Set,
+ depth: number
+): Node | undefined {
+ const local = JS_FAMILY_FILE.test(filePath) ? idx.clauseImports?.get(name) : undefined;
+ const imported = local === undefined ? undefined : importBinding(filePath, local, context);
+ return imported ? importedDeclaration(filePath, imported, language, context, visited, depth) : undefined;
+}
+
+/** The declaration `filePath`'s import `imported` binds, in the module it names; undefined for one outside the project. */
+function importedDeclaration(
+ filePath: string,
+ imported: ImportMapping,
+ language: Language,
+ context: ResolutionContext,
+ visited: Set,
+ depth: number
+): Node | undefined {
+ const next = resolveImportPath(imported.source, filePath, jsFamilyLanguage(filePath), context);
+ if (!next) return undefined;
+ const isDefault = imported.isDefault || imported.exportedName === 'default';
+ const want = { isDefault, isNamespace: false, exportedName: isDefault ? 'default' : imported.exportedName, memberName: null };
+ return findExportedSymbol(next, want, language, context, visited, depth + 1);
}
/**
@@ -325,14 +528,17 @@ function exportedByName(filePath: string, idx: FileExportIndex, name: string, co
// Bind names introduced by a local export clause to their declarations, so
// an importer asking for the renamed name gets the real symbol instead of
// falling through to the name-matcher (which cannot cross the rename).
- // The declaration is the file's first node of that name, exported or not.
+ // The declaration is the file's first node of that name, exported or not;
+ // a name with none may be an import the file hands on (forwardedExport).
idx.clauseAliases = new Map();
+ idx.clauseImports = new Map();
const content = context.readFile(filePath);
if (content && content.includes('export')) {
for (const { exportedName, localName } of extractLocalExportAliases(content)) {
- if (idx.byName.has(exportedName) || idx.clauseAliases.has(exportedName)) continue;
+ if (idx.byName.has(exportedName) || idx.clauseAliases.has(exportedName) || idx.clauseImports.has(exportedName)) continue;
const decl = nodesInFileNamed(filePath, localName, context)[0];
if (decl) idx.clauseAliases.set(exportedName, decl);
+ else idx.clauseImports.set(exportedName, localName);
}
}
}
@@ -3910,7 +4116,7 @@ function findExportedSymbolWalk(
// fallback prefers the component node. Without it, an `export { default
// as X } from './X.svelte'` barrel never resolves and the component shows
// a false 0 callers (#629).
- const direct = esmDefaultExport(filePath, exportIndex, context);
+ const direct = esmDefaultExport(filePath, exportIndex, language, context, visited, depth);
if (direct) return direct;
// CommonJS: `module.exports = createApplication`, or `= require('./lib/express')`.
const commonJs = commonJsDefaultExport(filePath, context);
@@ -3918,16 +4124,16 @@ function findExportedSymbolWalk(
const next = resolveImportPath(commonJs.source, filePath, language, context);
if (next) return findExportedSymbol(next, want, language, context, visited, depth + 1);
} else if (commonJs?.name) {
- const bound = nodesInFileNamed(filePath, commonJs.name, context)
- .filter((n) => DEFAULT_BINDING_KINDS.has(n.kind))
- .sort((a, b) => a.startLine - b.startLine || a.startColumn - b.startColumn)[0];
+ const bound = firstDeclared(filePath, commonJs.name, context);
if (bound) return bound;
}
} else if (want.isNamespace && want.memberName) {
- const direct = exportedByName(filePath, exportIndex, want.memberName, context);
+ const direct = exportedByName(filePath, exportIndex, want.memberName, context) ??
+ forwardedExport(filePath, exportIndex, want.memberName, language, context, visited, depth);
if (direct) return direct;
} else {
- const direct = exportedByName(filePath, exportIndex, want.exportedName, context);
+ const direct = exportedByName(filePath, exportIndex, want.exportedName, context) ??
+ forwardedExport(filePath, exportIndex, want.exportedName, language, context, visited, depth);
if (direct) return direct;
// `require('./x').default` and `const { default: X } = require('./x')` read
// the `default` property of module.exports. A CommonJS module sets it by
@@ -3942,7 +4148,7 @@ function findExportedSymbolWalk(
ESM_IMPORT_LANGUAGES.has(language) &&
hasEsmDefaultExport(filePath, exportIndex, context)
) {
- const esmDefault = esmDefaultExport(filePath, exportIndex, context);
+ const esmDefault = esmDefaultExport(filePath, exportIndex, language, context, visited, depth);
if (esmDefault) return esmDefault;
}
}