Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
145 changes: 145 additions & 0 deletions crates/codegraph-core/src/extractors/javascript.rs
Original file line number Diff line number Diff line change
Expand Up @@ -3289,8 +3289,46 @@ fn handle_reexport(node: &Node, source_node: &Node, source: &[u8], symbols: &mut
symbols.imports.push(imp);
}

/// Recover the export relationship tree-sitter-javascript/typescript drops
/// for a bare `export` keyword followed by a newline before certain
/// declarations (#2459). Mirrors `recoverBareExportMisparse` in
/// `src/extractors/javascript.ts` — see that function's doc comment for the
/// full ECMAScript-grammar rationale and the reserved-word argument for why
/// this can't misfire on a legitimate identifier reference. Reuses
/// `handle_export_declaration`, the same function a correctly-parsed
/// `export_statement`'s declaration goes through, so the recovered symbol is
/// classified identically to a real export (and inherits that function's own
/// gaps, e.g. `enum_declaration` isn't tracked either way — see #2560 —
/// rather than this fix silently papering over a different bug).
///
/// Restricted to direct children of `program`: `export` is not valid syntax
/// anywhere else a bare single-identifier expression statement could appear.
/// Comment nodes between the bare `export` and the declaration are skipped
/// when walking forward, since comments are ordinary siblings in this
/// grammar, not children of either statement.
fn recover_bare_export_misparse(bare_export_stmt: &Node, source: &[u8], symbols: &mut FileSymbols) {
let Some(parent) = bare_export_stmt.parent() else {
return;
};
if parent.kind() != "program" {
return;
}
let mut sib = bare_export_stmt.next_sibling();
while let Some(s) = sib {
if s.kind() != "comment" {
handle_export_declaration(&s, source, symbols);
return;
}
sib = s.next_sibling();
}
}

fn handle_expr_stmt(node: &Node, source: &[u8], symbols: &mut FileSymbols) {
let Some(expr) = node.child(0) else { return };
if expr.kind() == "identifier" && node_text(&expr, source) == "export" {
recover_bare_export_misparse(node, source, symbols);
return;
}
if expr.kind() != "assignment_expression" {
return;
}
Expand Down Expand Up @@ -10629,6 +10667,113 @@ mod tests {
);
}

// #2459: tree-sitter-javascript/typescript misparses `export` followed by
// a newline before const/let/var/class/function/interface/type as a
// standalone `(expression_statement (identifier))` rather than a single
// `export_statement` — `export default`/`{`/`*` ARE handled correctly
// across a newline (see the two tests directly above, which use
// `export default` for exactly that reason), which is why this needed
// recover_bare_export_misparse rather than a line-computation fix.
#[test]
fn recovers_an_exported_const_split_across_a_newline_from_the_export_keyword() {
let s = parse_js("export\nconst onOwnLine = 5;");
assert!(
s.definitions
.iter()
.any(|d| d.name == "onOwnLine" && d.kind == "constant" && d.line == 2),
"expected 'onOwnLine' defined as constant at line 2; got: {:?}",
s.definitions
);
assert!(
s.exports
.iter()
.any(|e| e.name == "onOwnLine" && e.kind == "constant" && e.line == 2),
"expected 'onOwnLine' exported as constant at line 2; got: {:?}",
s.exports
);
}

#[test]
fn recovers_an_exported_class_split_across_a_newline_from_the_export_keyword() {
let s = parse_js("export\nclass Widget {}");
assert!(
s.exports
.iter()
.any(|e| e.name == "Widget" && e.kind == "class" && e.line == 2),
"expected 'Widget' exported as class at line 2; got: {:?}",
s.exports
);
}

#[test]
fn recovers_an_exported_function_split_across_a_newline_from_the_export_keyword() {
let s = parse_js("export\nfunction greet() {}");
assert!(
s.exports
.iter()
.any(|e| e.name == "greet" && e.kind == "function" && e.line == 2),
"expected 'greet' exported as function at line 2; got: {:?}",
s.exports
);
}

#[test]
fn recovers_an_exported_ts_interface_split_across_a_newline_from_the_export_keyword() {
let s = parse_ts("export\ninterface Shape {}");
assert!(
s.exports
.iter()
.any(|e| e.name == "Shape" && e.kind == "interface" && e.line == 2),
"expected 'Shape' exported as interface at line 2; got: {:?}",
s.exports
);
}

#[test]
fn recovers_an_exported_ts_type_alias_split_across_a_newline_from_the_export_keyword() {
let s = parse_ts("export\ntype Id = string;");
assert!(
s.exports
.iter()
.any(|e| e.name == "Id" && e.kind == "type" && e.line == 2),
"expected 'Id' exported as type at line 2; got: {:?}",
s.exports
);
}

#[test]
fn skips_a_comment_between_the_export_keyword_and_the_declaration() {
let s = parse_js("export\n// why is this exported\nconst withComment = 1;");
assert!(
s.exports
.iter()
.any(|e| e.name == "withComment" && e.kind == "constant" && e.line == 3),
"expected 'withComment' exported as constant at line 3; got: {:?}",
s.exports
);
}

#[test]
fn still_exports_a_same_line_declaration_normally_no_regression_from_the_recovery_path() {
let s = parse_js("export const sameLine = 6;");
assert!(
s.exports
.iter()
.any(|e| e.name == "sameLine" && e.kind == "constant" && e.line == 1),
"expected 'sameLine' exported as constant at line 1; got: {:?}",
s.exports
);
}

#[test]
fn does_not_export_a_plain_top_level_statement_referencing_an_unrelated_identifier() {
// Sanity check that recovery is keyed on the literal text "export" (a
// reserved word — this can only ever be the misparse), not on "any
// bare identifier expression statement followed by a declaration".
let s = parse_js("notExport;\nconst untouched = 1;");
assert!(!s.exports.iter().any(|e| e.name == "untouched"));
}

#[test]
fn does_not_export_let_var_destructured_bindings() {
// Mirrors skips_let_var_destructured_bindings below — the Export side
Expand Down
8 changes: 8 additions & 0 deletions src/domain/parser.ts
Original file line number Diff line number Diff line change
Expand Up @@ -176,6 +176,14 @@ const COMMON_QUERY_PATTERNS: string[] = [
'(method_definition name: (string) @meth_name) @meth_node',
'(import_statement source: (string) @imp_source) @imp_node',
'(export_statement) @exp_node',
// #2459: recovers the export relationship tree-sitter drops for a bare
// `export` keyword misparsed as a standalone identifier expression
// statement (see recoverBareExportMisparse in extractors/javascript.ts for
// the full rationale and the reserved-word argument for why this can't
// misfire on a legitimate identifier reference). Filtered/dispatched in
// JS, not via a query predicate — this codebase has no existing predicate
// usage, so filtering in dispatchQueryMatch matches the established idiom.
'(expression_statement (identifier) @bare_export_kw) @bare_export_stmt',
'(call_expression function: (identifier) @callfn_name) @callfn_node',
'(call_expression function: (member_expression) @callmem_fn) @callmem_node',
'(call_expression function: (subscript_expression) @callsub_fn) @callsub_node',
Expand Down
8 changes: 8 additions & 0 deletions src/domain/wasm-worker-entry.ts
Original file line number Diff line number Diff line change
Expand Up @@ -121,6 +121,14 @@ const COMMON_QUERY_PATTERNS: string[] = [
'(method_definition name: (string) @meth_name) @meth_node',
'(import_statement source: (string) @imp_source) @imp_node',
'(export_statement) @exp_node',
// #2459: recovers the export relationship tree-sitter drops for a bare
// `export` keyword misparsed as a standalone identifier expression
// statement (see recoverBareExportMisparse in extractors/javascript.ts for
// the full rationale and the reserved-word argument for why this can't
// misfire on a legitimate identifier reference). Filtered/dispatched in
// JS, not via a query predicate — this codebase has no existing predicate
// usage, so filtering in dispatchQueryMatch matches the established idiom.
'(expression_statement (identifier) @bare_export_kw) @bare_export_stmt',
'(call_expression function: (identifier) @callfn_name) @callfn_node',
'(call_expression function: (member_expression) @callmem_fn) @callmem_node',
'(call_expression function: (subscript_expression) @callsub_fn) @callsub_node',
Expand Down
58 changes: 58 additions & 0 deletions src/extractors/javascript.ts
Original file line number Diff line number Diff line change
Expand Up @@ -394,6 +394,16 @@ function dispatchQueryMatch(
handleImportCapture(c, imports);
} else if (c.exp_node) {
handleExportCapture(c, exps, imports);
} else if (c.bare_export_stmt) {
// #2459 — see recoverBareExportMisparse's doc comment. The query pattern
// matches every bare single-identifier expression statement (there's no
// existing predicate usage in this codebase's queries to filter by text
// at the query level — see the pattern's own comment in parser.ts /
// wasm-worker-entry.ts), so the "is this actually the reserved word"
// check happens here, matching the walk path's equivalent check.
if (c.bare_export_kw!.text === 'export') {
recoverBareExportMisparse(c.bare_export_stmt, exps);
}
} else if (c.callfn_node) {
// Route through extractCallInfo so special identifier calls (eval) get classified.
const callfnInfo = extractCallInfo(c.callfn_name!, c.callfn_node);
Expand Down Expand Up @@ -2499,6 +2509,50 @@ function handleImportStmt(node: TreeSitterNode, ctx: ExtractorOutput): void {
}
}

/**
* Recover the export relationship tree-sitter-javascript/typescript drops for
* a bare `export` keyword followed by a newline before certain declarations
* (#2459). Per the ECMAScript grammar `export Declaration` has no
* `[no LineTerminator here]` restriction — unlike `return`/`throw`, ASI does
* not apply — so `export\nconst x = 5;` is valid, correctly-exported JS in
* every real engine. The grammar's ASI-like heuristic special-cases
* `default`/`{`/`*` as valid same-line continuations after `export` but not
* `const`/`let`/`var`/`class`/`function`/`interface`/`type` directly, so it
* misparses `export` alone as a standalone `(expression_statement
* (identifier))`, and the declaration that follows becomes an ordinary,
* non-exported top-level statement — never wrapped in an `export_statement`,
* so neither extraction path's normal export detection (which requires a
* real `export_statement` node) can ever see it.
*
* `export` is a reserved word, so a genuine `identifier` node whose text is
* exactly "export" can only occur via this misparse — never a legitimate
* variable reference — making recovery here unambiguous. Reuses
* `collectExportedDeclarations`, the same function a correctly-parsed
* `export_statement`'s declaration goes through, so the recovered symbol is
* classified identically to a real export (and inherits any of that
* function's own gaps, e.g. `enum_declaration` isn't tracked either way —
* see #2560 — rather than this fix silently papering over a different bug).
*
* Restricted to direct children of `program`: `export` is not valid syntax
* anywhere else a bare single-identifier expression statement could appear
* (nested blocks, function bodies, etc.), so this can't misfire on unrelated
* code — it is simply never reached there because `bareExportStmt.parent`
* won't be `program`.
*
* Comment nodes between the bare `export` and the declaration (e.g.
* `export\n// why\nconst x = 5;`) are skipped when walking forward, since
* comments are ordinary siblings in this grammar, not children of either
* statement — without this, a comment would be handed to
* `collectExportedDeclarations`, which recognizes neither its own node type
* nor any of `EXPORT_DECL_KIND`'s, and silently no-ops.
*/
function recoverBareExportMisparse(bareExportStmt: TreeSitterNode, exps: Export[]): void {
if (bareExportStmt.parent?.type !== 'program') return;
let sib = bareExportStmt.nextSibling;
while (sib?.type === 'comment') sib = sib.nextSibling;
if (sib) collectExportedDeclarations(sib, exps);
}

function handleExportStmt(node: TreeSitterNode, ctx: ExtractorOutput): void {
const decl = node.childForFieldName('declaration');
if (decl) collectExportedDeclarations(decl, ctx.exports);
Expand All @@ -2525,6 +2579,10 @@ function handleExportStmt(node: TreeSitterNode, ctx: ExtractorOutput): void {

function handleExpressionStmt(node: TreeSitterNode, ctx: ExtractorOutput): void {
const expr = node.child(0);
if (expr && expr.type === 'identifier' && expr.text === 'export') {
recoverBareExportMisparse(node, ctx.exports);
return;
}
if (expr && expr.type === 'assignment_expression') {
const left = expr.childForFieldName('left');
const right = expr.childForFieldName('right');
Expand Down
39 changes: 39 additions & 0 deletions tests/engines/query-walk-parity.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -125,6 +125,25 @@ standalone();
this.foo();
arr[0].bar();
a.b.c();
`,
},
{
// #2459: bare `export` + newline before a declaration misparses as a
// standalone identifier expression statement in tree-sitter-javascript;
// both paths must recover the export relationship identically.
name: 'bare export keyword split across a newline from its declaration (#2459)',
file: 'test.js',
code: `
export
const onOwnLine = 5;

export const sameLine = 6;

export
class Widget {}

notExport;
const untouched = 1;
`,
},
{
Expand Down Expand Up @@ -426,3 +445,23 @@ describe('Query vs Walk parity', () => {
});
}
});

describe('bare export keyword recovery — query path correctness (#2459)', () => {
// The parity loop above only proves the query path AGREES with the walk
// path; it doesn't independently confirm either is actually correct. The
// walk path's expected content is asserted directly in
// tests/parsers/javascript.test.ts — this asserts the query path's own
// `bare_export_stmt`/`bare_export_kw` capture (parser.ts /
// wasm-worker-entry.ts) fires and recovers the right export on its own.
it('recovers an exported const split across a newline via the query-based extractor', async () => {
const result = await queryExtract(`export\nconst onOwnLine = 5;`, 'test.js');
expect(result.exports).toContainEqual(
expect.objectContaining({ name: 'onOwnLine', kind: 'constant', line: 2 }),
);
});

it('does not export via the query path when the identifier is not literally "export"', async () => {
const result = await queryExtract(`notExport;\nconst untouched = 1;`, 'test.js');
expect(result.exports.some((e) => e.name === 'untouched')).toBe(false);
});
});
Loading
Loading