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
12 changes: 9 additions & 3 deletions src/expressions.zig
Original file line number Diff line number Diff line change
Expand Up @@ -3539,6 +3539,10 @@ fn parseParenthesized(p: *Parser) Error!NodeIndex {
const saved_diag_len = p.diagnostics.items.len;
const saved_nodes_len = p.nodes.len;
const saved_extra_len = p.extra_data.items.len;
// Speculatively parsing the return type can emit scope events (e.g. a type
// that looks like a function type) — discard them if we backtrack so they
// don't leak into the enclosing scope (#62).
const saved_ev = p.eventMark();
// Snapshot scratch contents so we can restore even after scratchPop.
const params_count = p.scratch.items.len - scratch_top;
var params_snapshot: [16]u32 = undefined;
Expand Down Expand Up @@ -3612,16 +3616,17 @@ fn parseParenthesized(p: *Parser) Error!NodeIndex {
// Backtrack to bare-paren and let the caller see `:` again.
if (saved_cc and p.peek() != .colon and can_snapshot) {
p.tok_i = saved_tok;
p.diagnostics.shrinkRetainingCapacity(saved_diag_len);
p.truncateDiagnostics(saved_diag_len);
p.nodes.len = @intCast(saved_nodes_len);
p.extra_data.shrinkRetainingCapacity(saved_extra_len);
p.resetEventsTo(saved_ev); // discard the speculative return-type's events
// Restore scratch contents (scratchPop above truncated to scratch_top).
p.scratch.shrinkRetainingCapacity(scratch_top);
for (params_snapshot[0..params_count]) |raw| {
try p.scratch.append(p.gpa, raw);
}
// Fall through to the bare-paren interpretation below.
// (emit_arrow_scope is false here, so no scope events were emitted.)
// (emit_arrow_scope is false here, so no arrow-scope events were emitted.)
} else {
if (emit_arrow_scope) try p.emitScopeClose(.none);
const extra = try p.addExtra(ast.ArrowData, .{
Expand All @@ -3641,9 +3646,10 @@ fn parseParenthesized(p: *Parser) Error!NodeIndex {
} else {
// Type didn't parse or no `=>` — backtrack the type annotation only.
p.tok_i = saved_tok;
p.diagnostics.shrinkRetainingCapacity(saved_diag_len);
p.truncateDiagnostics(saved_diag_len);
p.nodes.len = @intCast(saved_nodes_len);
p.extra_data.shrinkRetainingCapacity(saved_extra_len);
p.resetEventsTo(saved_ev); // discard the speculative return-type's events
}
}

Expand Down
29 changes: 28 additions & 1 deletion src/parser.zig
Original file line number Diff line number Diff line change
Expand Up @@ -1142,11 +1142,24 @@ pub const Parser = struct {
}

/// The current scope-event high-water mark. Paired with `rehomeParamRefs`
/// to bracket the events emitted while parsing an arrow's parameter list.
/// to bracket the events emitted while parsing an arrow's parameter list,
/// or with `resetEventsTo` to discard events emitted during a speculative
/// parse that is later backtracked.
pub inline fn eventMark(self: *const Parser) u32 {
return @intCast(self.ev_len);
}

/// Discard every scope event emitted at or after `mark` — the event-stream
/// analogue of restoring `tok_i`/`nodes`/`extra_data` after a speculative
/// parse fails. Stale `ref_event_idx` entries left pointing past the new
/// length are harmless: their consumers re-check `ev_idx < ev_len` and the
/// event's node before acting, and `emitReference` overwrites the slot when
/// the node index is reused after the backtrack.
pub inline fn resetEventsTo(self: *Parser, mark: u32) void {
if (!self.emit_scope_events) return;
if (mark < self.ev_len) self.ev_len = mark;
}

/// Re-home parameter default-initializer (and computed-key) references from
/// the enclosing scope into the just-opened arrow/parameter scope.
///
Expand Down Expand Up @@ -8792,6 +8805,20 @@ pub const Parser = struct {
self.extra_data.shrinkRetainingCapacity(s.extra_len);
}

/// Truncate the diagnostics list to `len`, freeing the message strings of the
/// discarded entries. emitDiagnostic allocPrints messages with `gpa` and the
/// tree's deinit frees them — but only those still in the list, so a plain
/// `shrinkRetainingCapacity` after a speculative parse would leak the dropped
/// messages. Use this when backtracking a speculation that may have diagnosed.
pub fn truncateDiagnostics(self: *Parser, len: usize) void {
var i = self.diagnostics.items.len;
while (i > len) {
i -= 1;
self.gpa.free(self.diagnostics.items[i].message);
}
self.diagnostics.shrinkRetainingCapacity(len);
}

/// Restore parser position from a checkpoint.
pub fn restore(self: *Parser, saved: u32) void {
self.tok_i = saved;
Expand Down
76 changes: 57 additions & 19 deletions src/typescript.zig
Original file line number Diff line number Diff line change
Expand Up @@ -848,30 +848,68 @@ fn looksLikeFunctionTypeParams(p: *Parser) bool {
}
}

// Scan forward to find closing `)` and check for `=>`
// No annotation/optional/comma matched above, so the only remaining function-
// type forms are a single bare parameter: `(id) =>`, `(id, …) =>`, `({pat}) =>`,
// `([pat]) =>`, or `(id = default) =>`. Recognise them by skipping exactly ONE
// parameter binding and requiring a parameter-separator next (`:` `,` `=`) or a
// `)` immediately followed by `=>`.
//
// Crucially, a reserved keyword that forms a bare type on its own
// (`void`/`null`/`true`/`false`/`this`), a leading `(`, or a type operator after
// the first identifier (`T | U`, `T & U`, `T[]`) means the parens are a
// parenthesized TYPE — so a trailing `=>` belongs to an *enclosing* arrow, not a
// function type here. (Matches TS's isUnambiguouslyStartOfFunctionType: the old
// "scan to `)` then check `=>`" heuristic mis-read `(void) =>` etc. as fn types,
// discarding the enclosing arrow — see #62.)
p.restore(saved);
_ = p.advance(); // skip `(`
var depth: u32 = 1;
var limit: u32 = 0;
while (depth > 0 and !p.isAtEnd() and limit < 200) : (limit += 1) {
switch (p.peek()) {
.l_paren => {
depth += 1;
_ = p.advance();
},
.r_paren => {
depth -= 1;
if (depth == 0) {
_ = p.advance(); // consume `)`
return p.peek() == .arrow;
// Skip a leading parameter-property modifier (public/private/protected/readonly)
// when another binding token follows — `(public B) =>` is a function type
// (mirrors parseFunctionTypeParam). A modifier not followed by a binding stays
// the parameter name (`(public) =>`).
if (p.peekAt(1) == .identifier or p.peekAt(1) == .kw_this or
p.peekAt(1) == .l_brace or p.peekAt(1) == .l_bracket)
{
const is_modifier = p.peek() == .kw_readonly or (p.peek() == .identifier and blk: {
const txt = p.tokenText(p.tokIdx());
break :blk std.mem.eql(u8, txt, "public") or std.mem.eql(u8, txt, "private") or
std.mem.eql(u8, txt, "protected") or std.mem.eql(u8, txt, "readonly");
});
if (is_modifier) _ = p.advance();
}
switch (p.peek()) {
// Destructuring-pattern parameter — skip the balanced `{…}`/`[…]`.
.l_brace, .l_bracket => {
_ = p.advance();
var depth: u32 = 1;
var limit: u32 = 0;
while (depth > 0 and !p.isAtEnd() and limit < 200) : (limit += 1) {
switch (p.peek()) {
.l_brace, .l_bracket => depth += 1,
.r_brace, .r_bracket => depth -= 1,
else => {},
}
_ = p.advance();
},
else => _ = p.advance(),
}
}
if (depth != 0) return false;
},
.identifier, .kw_this => _ = p.advance(),
// A keyword may name a parameter, except the reserved keywords that are a
// complete bare type by themselves. (`this` is accepted above — `(this) =>`
// is a function type in TS.)
else => |first| {
if (!first.isKeyword() or first == .kw_void or first == .kw_null or
first == .kw_true or first == .kw_false)
return false;
_ = p.advance();
},
}

return false;
// After the first parameter binding: a separator (`:` `?` `=` `,`) or `) =>`.
return switch (p.peek()) {
.colon, .question, .comma, .equal => true,
.r_paren => p.peekAt(1) == .arrow,
else => false,
};
}

/// Parse a simple parenthesized type: `(Type)`.
Expand Down
95 changes: 95 additions & 0 deletions tests/parser_test.zig
Original file line number Diff line number Diff line change
Expand Up @@ -926,3 +926,98 @@ test "lang:js_ts angle-bracket type assertion parsed as TS (not JSX) (#32)" {
defer tree.deinit(testing.allocator);
try expectNoErrors(&tree);
}

// ── #62: parenthesized type as an arrow return type ───────────────────────

fn hasNodeTag(tree: *const ast.Ast, tag: Node.Tag) bool {
const tags = tree.nodes.items(.tag);
var i: u32 = 0;
while (i < tree.nodes.len) : (i += 1) {
if (tags[i] == tag) return true;
}
return false;
}

test "arrow with a parenthesized return type parses, not an ErrorNode (#62)" {
// `(): (void) => {}` — the `: (` must read a parenthesized return TYPE, not a
// function type that swallows the arrow's own `=>`. Previously the whole arrow
// became an ErrorNode.
const cases = [_][]const u8{
"const f = (): (void) => {};",
"const f = (): (T | U) => x;", // union
"const f = (): (T & U) => x;", // intersection
"const f = (): (A extends B ? C : D) => x;", // conditional type
"const f = (): (readonly string[]) => x;",
"const f = (x): (void) => {};",
"const o = { key: (): (void) => { x(); } };",
"const f = (): (() => void) => g;", // parenthesized function-type return
};
for (cases) |src| {
var tree = try parseTs(src);
defer tree.deinit(testing.allocator);
try expectNoErrors(&tree);
try testing.expect(hasNodeTag(&tree, .arrow_fn));
try testing.expect(!hasNodeTag(&tree, .error_node));
}
// js_ts mode parses it the same way.
var jt = try parseJsTs("const f = (): (void) => {};");
defer jt.deinit(testing.allocator);
try expectNoErrors(&jt);
try testing.expect(hasNodeTag(&jt, .arrow_fn));
}

test "parenthesized return type is a TSParenthesizedType; function types still parse (#62)" {
{
var tree = try parseTs("const f = (): (void) => {};");
defer tree.deinit(testing.allocator);
try testing.expect(hasNodeTag(&tree, .ts_parenthesized_type));
}
// Genuine function types must still be recognized (the heuristic that decides
// `( … ) =>` is a function type vs a parenthesized type must not regress).
const fn_types = [_][]const u8{
"type A = (a) => void;",
"type B = (a, b) => void;",
"type C = ({x}) => void;",
"type D = (...r: any[]) => void;",
"type E = (a?) => void;",
"type F = (a = 1) => void;",
"function A(): (public B) => C {}", // parameter-property modifier
"type G = ({}?: { x: string }) => void;", // optional destructuring param
};
for (fn_types) |src| {
var tree = try parseTs(src);
defer tree.deinit(testing.allocator);
try expectNoErrors(&tree);
try testing.expect(hasNodeTag(&tree, .ts_function_type));
}
// A bare parenthesized type stays a parenthesized type.
var pt = try parseTs("type T = (void);");
defer pt.deinit(testing.allocator);
try expectNoErrors(&pt);
try testing.expect(hasNodeTag(&pt, .ts_parenthesized_type));
}

fn firstNodeOfTag(tree: *const ast.Ast, tag: Node.Tag) ?NodeIndex {
const tags = tree.nodes.items(.tag);
var i: u32 = 0;
while (i < tree.nodes.len) : (i += 1) {
if (tags[i] == tag) return NodeIndex.fromInt(i);
}
return null;
}

test "arrow's actual return_type node is the parenthesized type (#62)" {
// Pin the precise shape (not just `hasNodeTag` anywhere in the tree): the
// arrow's ArrowData.return_type is a TSTypeAnnotation wrapping the
// parenthesized type.
var tree = try parseTs("const f = (): (void) => {};");
defer tree.deinit(testing.allocator);
try expectNoErrors(&tree);
const datas = tree.nodes.items(.data);
const arrow = firstNodeOfTag(&tree, .arrow_fn) orelse return error.NoArrow;
const ad = tree.extraData(ast.ArrowData, @intFromEnum(datas[arrow.toInt()].lhs));
try testing.expect(ad.return_type != .none);
try expectNodeTag(&tree, ad.return_type, .ts_type_annotation);
// The annotation's inner node (data.lhs) is the parenthesized type.
try expectNodeTag(&tree, datas[ad.return_type.toInt()].lhs, .ts_parenthesized_type);
}
21 changes: 21 additions & 0 deletions tests/semantic_test.zig
Original file line number Diff line number Diff line change
Expand Up @@ -1518,3 +1518,24 @@ test "overloaded interface method creates independent scopes per overload (#30)"
}
try testing.expectEqual(@as(u32, 2), count);
}

test "conditional with a paren-expression alternate leaks no phantom scope (#62)" {
// Speculatively parsing the consequent's typed-arrow return type tries the
// alternate `(b = 1)` as a function type, emitting scope/declare events; the
// backtrack must discard them so no phantom function scope or duplicate `b`
// parameter leaks into the graph.
var r = try analyzeTsSource("function f(b){ return c ? (b) : (b = 1) && b; }");
defer r.deinit(testing.allocator);
// Exactly one function scope (f); the discarded speculation adds none.
try testing.expectEqual(@as(u32, 1), countScopesOfKind(&r, .function));
try testing.expectEqual(@as(u32, 0), countScopesOfKind(&r, .arrow_function));
// Only the real `b` parameter — no phantom duplicate.
var b_params: u32 = 0;
var i: u32 = 0;
while (i < r.symbols.count()) : (i += 1) {
const id = SymbolId.fromInt(i);
if (r.symbols.getBindingKind(id) == .parameter and
std.mem.eql(u8, r.symbols.getName(id), "b")) b_params += 1;
}
try testing.expectEqual(@as(u32, 1), b_params);
}
Loading