mirror of
https://github.com/colbymchenry/codegraph.git
synced 2026-11-15 05:59:11 +08:00
fix(js): an export default line inside a template literal no longer names the default (#3068)
statedDefaultExport reads a JS/TS file's own export default statement
by scanning comment-stripped source for line-initial `export default`
heads. Strings are deliberately not masked there (a masker that loses
step on nested templates blanked real statements), and a head counted
when it named a node of the file, the first such head winning. A code
generator's template text breaks that: in
export function App() {} export function Main() {}
export const template = `
export default App
`;
export default Main;
the template's line names the real App, so `import X from './gen'`
bound to App instead of Main (issue #2968). A template holding
`export default () => …` also made a module whose real default is an
expression read as anonymous, which keeps every guess away.
templateLiteralTextAt (strip-comments.ts) scans the source once for
template-literal text: it follows `${…}` nesting (a template inside an
interpolation, a brace inside a regex there), and skips comments,
quoted strings and regex literals, which may hold a backtick; a `/`
after a value divides, anywhere else it opens a regex. A head inside
template text is now skipped. When some template never closes the scan
vouches for nothing and every head counts, as before. Nothing else
changes: a head outside template text is read exactly as it was.
Validation: default-export-declaration.test.ts gains the issue's file,
a template returned by a function behind a nested template, one passed
at module scope and one in a class field, a template's anonymous
default above an expression default, and controls (a regex and a string
holding a backtick, a division after a parenthesis, and a file whose
scan can't close a template); strip-comments.test.ts tests the scan
directly. Red with main's resolver: 2 of 10 failed; green: all pass.
Checked against tree-sitter on every line-initial head of 4,191
JS/TS files in 16 corpora (outline, excalidraw, vben, bitwarden apps,
mastodon, shadcn docs, zod, bulletproof-react, express, astro, react,
trezor-suite, this repo's src, tests and ui): of 4,149 real export
default statements the scan puts none inside template text, and all
322 heads it does place there are template text. A/B on excalidraw
d3a9c2a1, vben 61c07936, astro e3bec3d2 (packages/astro/src and
packages/integrations), with gin-gonic/gin 0f09c3a9 and psf/requests
3c7baaa0 as controls, both arms on main 4b0f72b7 through the native
kernel (and again on 66449255 through wasm): all six byte-identical,
since no default import there is decided by template text; index time
unchanged.
Not in this change: hasEsmDefaultExport, which only gates
require('./x').default, still reads the raw text.
Reimplements PR #3042 by @johnhldiver-beep.
Fixes #2968
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 5.5
parent
617769279d
commit
ed6b88e53a
@@ -12,7 +12,7 @@ import { UnresolvedRef, ResolvedRef, ResolutionContext, ImportMapping, ReExport
|
||||
import { applyAliases, aliasAndBaseUrlCandidates } from './path-aliases';
|
||||
import { extractLocalExportAliases } from './alias-binding';
|
||||
import { resolveWorkspaceImport, workspaceImportTargets, type WorkspaceImportTargets } from './workspace-packages';
|
||||
import { stripCommentsForRegex } from './strip-comments';
|
||||
import { stripCommentsForRegex, templateLiteralTextAt } from './strip-comments';
|
||||
import { makeLineAt } from './synth-utils';
|
||||
import { dartDirectiveFile } from './dart-libraries';
|
||||
import { ambientModuleDeclares, findAmbientModuleExport, findAmbientModuleFile, inBlock, isInModuleBlock, namespaceBlocks, type NamespaceBlock } from './ambient-modules';
|
||||
@@ -207,8 +207,12 @@ function readDefaultExport(code: string, at: number): { name: string; nameAt: nu
|
||||
* 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.
|
||||
* name a node of the file instead, and of several statements, one that does
|
||||
* wins. A line of a template's text is no statement either, though it can
|
||||
* name one: a code generator's `export default App` names the file's own
|
||||
* `App` (#2968). So a head inside template text is skipped, when a scan that
|
||||
* follows `${…}` nesting and regex literals closes every template; one that
|
||||
* doesn't vouches for nothing, and every head counts.
|
||||
*/
|
||||
function statedDefaultExport(filePath: string, idx: FileExportIndex, context: ResolutionContext): StatedDefault {
|
||||
if (idx.statedDefault !== undefined) return idx.statedDefault;
|
||||
@@ -217,8 +221,10 @@ function statedDefaultExport(filePath: string, idx: FileExportIndex, context: Re
|
||||
if (source && /\bexport\s+default\b/.test(source)) {
|
||||
const code = stripCommentsForRegex(source, 'typescript');
|
||||
const lineAt = makeLineAt(code, 1);
|
||||
const inTemplateText = templateLiteralTextAt(source);
|
||||
EXPORT_DEFAULT_HEAD.lastIndex = 0;
|
||||
for (let m = EXPORT_DEFAULT_HEAD.exec(code); m !== null; m = EXPORT_DEFAULT_HEAD.exec(code)) {
|
||||
if (inTemplateText?.(m.index)) continue;
|
||||
const value = readDefaultExport(code, m.index + m[0].length);
|
||||
if (value === 'anonymous') {
|
||||
stated ??= 'anonymous';
|
||||
|
||||
@@ -82,6 +82,93 @@ export function blankStringContents(text: string, regexLiterals?: number[]): str
|
||||
return out.join('');
|
||||
}
|
||||
|
||||
/** A keyword after which a `/` opens a regex literal, at the end of the text before it. */
|
||||
const REGEX_AFTER_KEYWORD = /(?:^|[^\w$])(?:return|throw|case|yield|await|else|do|typeof|void|delete|new|in|of|instanceof)$/;
|
||||
|
||||
/**
|
||||
* Whether the `/` at `at` opens a regex literal: it does where an expression
|
||||
* starts, and divides after a value — a `)`, `]`, string, name or number.
|
||||
*/
|
||||
function slashOpensRegex(src: string, at: number): boolean {
|
||||
let j = at - 1;
|
||||
while (j >= 0 && (src[j] === ' ' || src[j] === '\t' || src[j] === '\n' || src[j] === '\r')) j--;
|
||||
if (j < 0) return true;
|
||||
const before = src[j]!;
|
||||
if (before === ')' || before === ']' || before === '"' || before === "'" || before === '`') return false;
|
||||
if (/[\w$]/.test(before)) return REGEX_AFTER_KEYWORD.test(src.slice(Math.max(0, j - 10), j + 1));
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Where JS/TS template-literal text lies in `src`: a test of whether offset
|
||||
* `at` falls in a template's literal text — not its `${…}` code — or null when
|
||||
* a template never closes, so the scan vouches for nothing. Offsets match
|
||||
* {@link stripCommentsForRegex}'s output, which keeps them.
|
||||
*
|
||||
* Unlike {@link blankStringContents} it follows `${…}` nesting (a template in
|
||||
* an interpolation, a brace in a regex there) and skips comments, strings and
|
||||
* regex literals, which may hold a backtick (`/[`]/`).
|
||||
*/
|
||||
export function templateLiteralTextAt(src: string): ((at: number) => boolean) | null {
|
||||
/** Template text spans, `[start, end)` pairs in order. */
|
||||
const spans: number[] = [];
|
||||
/** Innermost last: an open template's current text start, or an open `${…}`'s unclosed braces. */
|
||||
const open: Array<{ text: number } | { braces: number }> = [];
|
||||
const n = src.length;
|
||||
for (let i = 0; i < n; i++) {
|
||||
const c = src[i];
|
||||
const top = open[open.length - 1];
|
||||
if (top && 'text' in top) {
|
||||
if (c === '\\') i++;
|
||||
else if (c === '`') {
|
||||
spans.push(top.text, i);
|
||||
open.pop();
|
||||
} else if (c === '$' && src[i + 1] === '{') {
|
||||
spans.push(top.text, i);
|
||||
open.push({ braces: 0 });
|
||||
i++;
|
||||
}
|
||||
continue;
|
||||
}
|
||||
if (c === '`') open.push({ text: i + 1 });
|
||||
else if (c === '"' || c === "'") {
|
||||
for (i++; i < n && src[i] !== c && src[i] !== '\n'; i++) if (src[i] === '\\') i++;
|
||||
} else if (c === '/' && src[i + 1] === '/') {
|
||||
while (i < n && src[i] !== '\n') i++;
|
||||
} else if (c === '/' && src[i + 1] === '*') {
|
||||
const close = src.indexOf('*/', i + 2);
|
||||
i = close < 0 ? n : close + 1;
|
||||
} else if (c === '/' && slashOpensRegex(src, i)) {
|
||||
let inClass = false;
|
||||
for (let j = i + 1; j < n && src[j] !== '\n'; j++) {
|
||||
if (src[j] === '\\') j++;
|
||||
else if (src[j] === '[') inClass = true;
|
||||
else if (src[j] === ']') inClass = false;
|
||||
else if (src[j] === '/' && !inClass) {
|
||||
i = j;
|
||||
break;
|
||||
}
|
||||
}
|
||||
} else if (top && c === '{') top.braces++;
|
||||
else if (top && c === '}' && top.braces-- === 0) {
|
||||
open.pop();
|
||||
(open[open.length - 1] as { text: number }).text = i + 1;
|
||||
}
|
||||
}
|
||||
if (open.length > 0) return null;
|
||||
return (at) => {
|
||||
// The first span ending after `at`, then whether it starts at or before it.
|
||||
let lo = 0;
|
||||
let hi = spans.length / 2;
|
||||
while (lo < hi) {
|
||||
const mid = (lo + hi) >> 1;
|
||||
if (spans[2 * mid + 1]! <= at) lo = mid + 1;
|
||||
else hi = mid;
|
||||
}
|
||||
return lo < spans.length / 2 && spans[2 * lo]! <= at;
|
||||
};
|
||||
}
|
||||
|
||||
export type CommentLang =
|
||||
| 'python'
|
||||
| 'javascript'
|
||||
|
||||
Reference in New Issue
Block a user