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
79 changes: 77 additions & 2 deletions src/code_path.zig
Original file line number Diff line number Diff line change
Expand Up @@ -347,6 +347,15 @@ const LoopContext = struct {
prev_break_target_is_switch: bool = false,
};

/// Break target for a labeled NON-loop statement (`A: { … break A; … }`). Loops
/// and switches have their own break handling; this covers the remaining case so
/// `break <label>` reaches the segment after the labeled statement (#57).
const LabelBreakContext = struct {
upper: ?*LabelBreakContext,
label: []const u8,
break_fork: ForkContext,
};

// ── CodePathBuilder ──────────────────────────────────────────────

pub const CodePathBuilder = struct {
Expand Down Expand Up @@ -407,7 +416,14 @@ pub const CodePathBuilder = struct {
switch_context: ?*SwitchContext,
try_context: ?*TryContext,
loop_context: ?*LoopContext,
label_break_context: ?*LabelBreakContext,
break_target_is_switch: bool,
/// Saved `try_context` per code-path nesting level. A nested function inside a
/// try body is its own code path with no enclosing try, so `enterCodePath`
/// clears `try_context` (pushing the old value here) and `exitCodePath` restores
/// it — keeping throwable expressions in nested functions from making the outer
/// catch reachable (#57). Arena-backed; freed with the arena.
cp_saved_try: std.ArrayList(?*TryContext),

// ── ChoiceContext slab ───────────────────────────────────────
// Pre-allocated pool of ChoiceContext structs — eliminates per-push
Expand Down Expand Up @@ -464,7 +480,9 @@ pub const CodePathBuilder = struct {
.switch_context = null,
.try_context = null,
.loop_context = null,
.label_break_context = null,
.break_target_is_switch = false,
.cp_saved_try = .empty,
.choice_slab = &.{},
.choice_slab_top = 0,
.seg_id_pool = undefined,
Expand Down Expand Up @@ -900,6 +918,11 @@ pub const CodePathBuilder = struct {
const upper = self.current_codepath;
self.current_codepath = cp_id;

// A new code path (function/arrow/static-block/field-init) does not inherit
// the enclosing try — its throwable expressions can't reach the outer catch.
try self.cp_saved_try.append(self.allocator, self.try_context);
self.try_context = null;

// Create initial segment
const initial_seg = try self.newRootSegment();

Expand Down Expand Up @@ -1024,6 +1047,8 @@ pub const CodePathBuilder = struct {
if (self.fork_context.upper) |upper_fc| {
self.fork_context = upper_fc;
}
// Restore the enclosing try context saved in enterCodePath.
if (self.cp_saved_try.pop()) |saved| self.try_context = saved;
}

// ── Segment event emission ───────────────────────────────
Expand Down Expand Up @@ -1690,20 +1715,57 @@ pub const CodePathBuilder = struct {
/// Labeled `break lbl` — walks the LoopContext chain and adds to the
/// matching loop's break_fork. Falls back to innermost if not found.
pub fn makeBreakLabeled(self: *CodePathBuilder, label: []const u8, node: NodeIndex) !void {
// A label is on exactly one statement: a loop (loop_context) or a non-loop
// statement (label_break_context). Route the break to whichever holds it.
var lc = self.loop_context;
while (lc) |ctx| : (lc = ctx.upper) {
if (ctx.label.len > 0 and std.mem.eql(u8, ctx.label, label)) {
try ctx.break_fork.add(self.fork_context.head(), self);
break;
try self.makeUnreachable(node);
return;
}
} else if (self.loop_context) |lc_inner| {
}
var lbc = self.label_break_context;
while (lbc) |ctx| : (lbc = ctx.upper) {
if (std.mem.eql(u8, ctx.label, label)) {
try ctx.break_fork.add(self.fork_context.head(), self);
try self.makeUnreachable(node);
return;
}
}
// Fallback (no matching label found — e.g. error-recovered input): innermost loop.
if (self.loop_context) |lc_inner| {
if (!self.break_target_is_switch) {
try lc_inner.break_fork.add(self.fork_context.head(), self);
}
}
try self.makeUnreachable(node);
}

pub fn pushLabelBreakContext(self: *CodePathBuilder, label: []const u8) !void {
const ctx = try self.allocator.create(LabelBreakContext);
ctx.* = .{
.upper = self.label_break_context,
.label = label,
.break_fork = newEmptyForkContext(self.allocator, self.fork_context, false),
};
self.label_break_context = ctx;
}

pub fn popLabelBreakContext(self: *CodePathBuilder, node: NodeIndex) !void {
const ctx = self.label_break_context orelse return;
self.label_break_context = ctx.upper;
// If anything broke to this label, the segment after the labeled statement is
// reachable from those break points (plus the body's normal exit). Merge them.
if (!ctx.break_fork.empty()) {
try ctx.break_fork.add(self.fork_context.head(), self);
try self.leaveFromCurrentSegment(node, .post);
const post_segs = try ctx.break_fork.makeNext(0, -1, self);
try self.fork_context.replaceHead(post_segs, self);
try self.forwardCurrentToHead(node, .post);
}
}

pub fn makeBreak(self: *CodePathBuilder, node: NodeIndex) !void {
if (!self.break_target_is_switch) {
if (self.loop_context) |lc| {
Expand All @@ -1727,6 +1789,19 @@ pub const CodePathBuilder = struct {
try self.makeUnreachable(node);
}

/// A potentially-throwing expression (call / member access / new / yield) was
/// evaluated directly in the current try body. Records that the catch clause is
/// reachable from the try entry — only the first one matters (mirrors ESLint's
/// makeFirstThrowablePathInTryBlock). The try_context is null inside nested
/// functions (saved/restored across code paths), so their throwables are ignored.
pub fn makeFirstThrowablePathInTryBlock(self: *CodePathBuilder, node: NodeIndex) !void {
_ = node;
const ctx = self.try_context orelse return;
if (ctx.position != .try_body or ctx.first_throwable_called) return;
ctx.first_throwable_called = true;
try ctx.thrown_fork.add(self.fork_context.head(), self);
}

pub fn makeThrow(self: *CodePathBuilder, node: NodeIndex) !void {
const cp_id = self.current_codepath;
if (cp_id == NONE_CP) return;
Expand Down
8 changes: 8 additions & 0 deletions src/event_resolver.zig
Original file line number Diff line number Diff line change
Expand Up @@ -792,10 +792,18 @@ fn resolveFullImpl(
if (e.aux == 1) { // loop label — extract text for upcoming loop_open
// Defensive: out-of-range node on error-recovered input → no label.
pending_label = if (e.node >= ast.nodes.len) "" else ast.nodeName(@enumFromInt(e.node));
} else if (do_cfg) { // non-loop label — break target for `break <label>`
const lbl = if (e.node >= ast.nodes.len) "" else ast.nodeName(@enumFromInt(e.node));
try cpb.pushLabelBreakContext(lbl);
}
},
.label_close => {
pending_label = ""; // consumed or no loop found — clear either way
if (e.aux == 0 and do_cfg) try cpb.popLabelBreakContext(@enumFromInt(e.node));
},
.throwable => {
const n: NodeIndex = @enumFromInt(e.node);
if (do_cfg) try cpb.makeFirstThrowablePathInTryBlock(n);
},
.nop => {},
}
Expand Down
39 changes: 36 additions & 3 deletions src/parser.zig
Original file line number Diff line number Diff line change
Expand Up @@ -415,6 +415,11 @@ pub const Parser = struct {
/// True when parsing inside a TypeScript namespace/module body (even non-ambient).
/// Exports are valid in namespace bodies regardless of in_block.
in_ts_namespace: bool = false,
/// True when parsing statements directly inside a `try` block body. Used to emit
/// `throwable` events for call/member/new/yield expressions so the CFG can make
/// the `catch` clause reachable. Stays set inside nested functions within the try
/// (the CFG scopes try_context per code path, so those events are ignored) (#57).
in_try_body: bool = false,
/// True when parsing statements directly inside a switch case/default clause
/// (not inside a nested block within the clause). Used to detect TS1547/TS1548.
in_case_clause: bool = false,
Expand Down Expand Up @@ -975,9 +980,33 @@ pub const Parser = struct {
self.node_data_ptr[result] = node.data;
self.nodes.len += 1;
self.node_end_toks[result] = if (self.tok_i > 0) @intCast(self.tok_i - 1) else 0;
// Emit a `throwable` event for call/member/new/yield expressions evaluated in
// a try body, so the CFG makes the catch reachable (#57). `in_try_body` is
// almost always false — a single predictable bool read on the hot path.
if (self.in_try_body) {
@branchHint(.unlikely);
try self.maybeEmitThrowable(node.tag, NodeIndex.fromInt(result));
}
return NodeIndex.fromInt(result);
}

/// Emit a `throwable` event if `tag` is a potentially-throwing expression
/// (CallExpression / MemberExpression / NewExpression / YieldExpression, and
/// their optional-chaining variants). Mirrors ESLint's makeFirstThrowablePath
/// triggers. Only called when `in_try_body` and event emission is on.
inline fn maybeEmitThrowable(self: *Parser, tag: Node.Tag, node: NodeIndex) !void {
if (!self.emit_scope_events) return;
switch (tag) {
.call_expr, .optional_call_expr, .new_expr,
.member_expr, .optional_member_expr,
.computed_member_expr, .optional_computed_member_expr,
.yield_expr, .yield_delegate => {
try self.evPush(.{ .kind = .throwable, .aux = 0, .node = @intFromEnum(node) });
},
else => {},
}
}

// ── Event cursor helpers ───────────────────────────────────────

/// Write one event via hoisted cursor. Grows the EventStream on overflow
Expand Down Expand Up @@ -1568,11 +1597,11 @@ pub const Parser = struct {
return idx;
}

pub inline fn emitLabelClose(self: *Parser, node: NodeIndex) !void {
pub inline fn emitLabelClose(self: *Parser, is_loop: bool, node: NodeIndex) !void {
if (!self.emit_scope_events) return;
try self.evPush(.{
.kind = .label_close,
.aux = 0,
.aux = if (is_loop) 1 else 0,
.node = @intFromEnum(node),
});
}
Expand Down Expand Up @@ -4096,7 +4125,7 @@ pub const Parser = struct {
.rhs = label_node,
},
});
try self.emitLabelClose(node);
try self.emitLabelClose(is_loop_label, node);
return node;
}

Expand All @@ -4108,7 +4137,11 @@ pub const Parser = struct {
// so we pre-count: a finalizer flag patched in after parsing.
const try_ev = try self.emitTryOpen(false, .none);
try self.emitBranchOpen(.none); // keep branch compatibility for node_reachable
const prev_in_try_body = self.in_try_body;
self.in_try_body = true;
errdefer self.in_try_body = prev_in_try_body; // restore if the body parse errors
const block = try self.parseBlockStatement();
self.in_try_body = prev_in_try_body;
try self.emitTryBodyEnd(.none);

var catch_node: NodeIndex = .none;
Expand Down
4 changes: 4 additions & 0 deletions src/scope_events.zig
Original file line number Diff line number Diff line change
Expand Up @@ -86,6 +86,10 @@ pub const EventKind = enum(u8) {
/// A labeled statement begins. node: labeled_stmt. aux: 0=non-loop, 1=loop.
label_open,
label_close,
/// A potentially-throwing expression (call / member access / new / yield)
/// evaluated directly in a `try` block body. Makes the `catch` clause
/// reachable from the try entry. node: the expression node. aux unused.
throwable,
/// `if (cond) consequent [else alternate]` — CodePath-specific events.
/// node: if_stmt or if_else_stmt. aux: 0=no-alternate, 1=has-alternate.
if_open,
Expand Down
66 changes: 66 additions & 0 deletions tests/semantic_test.zig
Original file line number Diff line number Diff line change
Expand Up @@ -1565,3 +1565,69 @@ test "import-equals binding does not collide with a same-named reference (#64)"
const imp = findSymbolByKind(&r, "C", .import_binding) orelse return error.ImportNotFound;
try testing.expect(r.symbols.getRefRange(imp).len() >= 1); // `extends C`
}

/// Reachability (1/0) of the segment containing the first reference named `name`,
/// read from the serialized CFG `seg_reachable` table the JS runner consumes.
/// 99 = not found / no CFG. Used to pin `no-unreachable` behaviour (#57).
fn cfgSegReachable(src: []const u8, name: []const u8) !u8 {
const allocator = testing.allocator;
var _lr = try Lexer.tokenizeWithOptions(allocator, src, .js, false);
defer _lr.deinit(allocator);
var tree = try Parser.parseWithOptions(allocator, src, _lr.tokens.slice(), .{ .emit_events = true });
defer tree.deinit(allocator);
var za = semantic.ZeroingAllocator.init(allocator);
const za_alloc = za.allocator();
var r = try semantic.SemanticAnalyzer.analyzeWithOptions(za_alloc, &tree, .{ .need_cfg = true });
defer r.deinit(za_alloc);
const cpr = r.code_path_result orelse return 99;
var ri: u32 = 0;
while (ri < r.references.count()) : (ri += 1) {
const node = r.references.getNode(ReferenceId.fromInt(ri));
if (std.mem.eql(u8, tree.nodeName(node), name)) {
const seg = r.references.list.items(.seg_id)[ri];
if (seg < cpr.seg_reachable.len) return cpr.seg_reachable[seg];
}
}
return 99;
}

test "catch block is reachable when the try body has a throwable expression (#57)" {
// The try body can throw before its `return`, so the catch is reachable. The
// throwable expression is a call / yield / member write respectively.
try testing.expectEqual(@as(u8, 1), try cfgSegReachable(
"function f(){ try { bar(); return; } catch (err) { return err; } }", "err"));
try testing.expectEqual(@as(u8, 1), try cfgSegReachable(
"function* f(){ try { yield 1; return; } catch (err) { return err; } }", "err"));
try testing.expectEqual(@as(u8, 1), try cfgSegReachable(
"function f(){ try { a.b.c = 1; return; } catch (err) { return err; } }", "err"));
// Member READ and `new` are throwable too (distinct node tags from the call/write above).
try testing.expectEqual(@as(u8, 1), try cfgSegReachable(
"function f(){ try { a.b.c; return; } catch (err) { return err; } }", "err"));
try testing.expectEqual(@as(u8, 1), try cfgSegReachable(
"function f(){ try { new Foo(); return; } catch (err) { return err; } }", "err"));
}

test "catch stays unreachable when the try body cannot throw (#57)" {
// No throwable expression + the try body always exits → catch is dead (ESLint).
try testing.expectEqual(@as(u8, 0), try cfgSegReachable(
"function f(){ try { return; } catch (err) { return err; } }", "err"));
// A throwable inside a NESTED function does not make the outer catch reachable.
try testing.expectEqual(@as(u8, 0), try cfgSegReachable(
"function f(){ try { (function(){ q(); }); return; } catch (err) { return err; } }", "err"));
}

test "break to a labeled block makes the following statement reachable (#57)" {
// `break A` transfers to AFTER the labeled statement, so `foo()` is reachable.
try testing.expectEqual(@as(u8, 1), try cfgSegReachable("A: { break A; } foo();", "foo"));
// The break is the ONLY way to reach foo (the fall-through returns), so the
// post-label segment is reachable solely via the break edge — discriminates the fix.
try testing.expectEqual(@as(u8, 1), try cfgSegReachable("function f(){ A: { if (x) break A; return; } foo(); }", "foo"));
// Break to an OUTER label, from a nested block, and to an INNER label.
try testing.expectEqual(@as(u8, 1), try cfgSegReachable("A: B: { break A; } foo();", "foo"));
try testing.expectEqual(@as(u8, 1), try cfgSegReachable("A: { { break A; } } foo();", "foo"));
try testing.expectEqual(@as(u8, 1), try cfgSegReachable("A: B: { break B; } foo();", "foo"));
// Labeled loops (the pre-existing path) still work.
try testing.expectEqual(@as(u8, 1), try cfgSegReachable("A: for(;;) { break A; } q();", "q"));
// No break, body always returns → the following statement is unreachable.
try testing.expectEqual(@as(u8, 0), try cfgSegReachable("function f(){ A: { return; } baz(); }", "baz"));
}
Loading