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
38 changes: 35 additions & 3 deletions src/parser.zig
Original file line number Diff line number Diff line change
Expand Up @@ -2344,6 +2344,14 @@ pub const Parser = struct {
if (!self.hasNewLineBetween(self.tokIdx(), @intCast(self.tok_i + 1))) {
return self.parseVariableDeclaration();
}
// There is no `[no LineTerminator here]` restriction between `let`
// and its BindingList: `let\n x`, `let\n [a] = …`, `let\n \u{…}`
// continue the LexicalDeclaration, so ASI must NOT fire — the
// ExpressionStatement lookahead forbids `let [`, and `let <id>` has no
// valid expression parse either (#59).
if (next == .identifier or next == .escaped_keyword or next == .l_bracket) {
return self.parseVariableDeclaration();
}
// With newline: `let\n{...} =` is still a destructuring declaration
// because `{...} = expr` has no valid parse as block + assignment.
if (next == .l_brace and self.looksLikeLetDestructuring()) {
Expand Down Expand Up @@ -2622,7 +2630,12 @@ pub const Parser = struct {
if (next != .l_bracket) {
const could_be_binding = (next == .identifier or next == .l_brace or next.isKeyword());
if (!could_be_binding or self.hasNewLineBetween(self.tokIdx(), @intCast(self.tok_i + 1))) {
return self.parseStatement();
// `let` here is an identifier expression, not a declaration: a
// LexicalDeclaration is forbidden in single-statement context, so
// ASI fires after `let` (`let\n x` → body `let`, then `x` follows).
// Parse the expression directly — NOT via parseStatement, which now
// treats `let\n <id>` as a declaration in statement-list context (#59).
return self.parseExprOrLabeledStatement();
}
}
}
Expand Down Expand Up @@ -2709,7 +2722,12 @@ pub const Parser = struct {
if (next != .l_bracket) {
const could_be_binding = (next == .identifier or next == .l_brace or next.isKeyword());
if (!could_be_binding or self.hasNewLineBetween(self.tokIdx(), @intCast(self.tok_i + 1))) {
return self.parseStatement();
// `let` here is an identifier expression, not a declaration: a
// LexicalDeclaration is forbidden in single-statement context, so
// ASI fires after `let` (`let\n x` → body `let`, then `x` follows).
// Parse the expression directly — NOT via parseStatement, which now
// treats `let\n <id>` as a declaration in statement-list context (#59).
return self.parseExprOrLabeledStatement();
}
}
}
Expand Down Expand Up @@ -4115,7 +4133,21 @@ pub const Parser = struct {
// We pass label_node (property_ident) — event_resolver reads label text from it.
_ = try self.emitLabelOpen(is_loop_label, label_node);

const stmt = try self.parseStatement();
// `lbl: let\n x` — a LexicalDeclaration is not a valid LabelledItem, so ASI
// fires after `let` (the labeled body is just the `let` identifier
// expression). Parse it directly, not via parseStatement, which now treats
// `let\n <id>` as a declaration in statement-list context (#59).
const stmt = blk: {
if (!self.in_strict and self.peek() == .kw_let) {
const ln = self.peekAt(1);
if ((ln == .identifier or ln == .escaped_keyword or ln == .l_bracket) and
self.hasNewLineBetween(self.tokIdx(), @intCast(self.tok_i + 1)))
{
break :blk try self.parseExprOrLabeledStatement();
}
}
break :blk try self.parseStatement();
};

const node = try self.addNode(.{
.tag = .labeled_stmt,
Expand Down
47 changes: 47 additions & 0 deletions tests/parser_test.zig
Original file line number Diff line number Diff line change
Expand Up @@ -1043,3 +1043,50 @@ test "import-equals binding identifier is created and anchored to the declaratio
}
try testing.expectEqual(@as(u32, 1), binding_children);
}

test "let with a binding list on the next line is one declaration, not ASI (#59)" {
// There is no `[no LineTerminator here]` between `let` and its BindingList, so
// `let\n x` continues the LexicalDeclaration — it must NOT be `let;` + expression.
const cases = [_][]const u8{
"let\n x = 1",
"let\n x = {},\n y = {}", // multiple declarators
"let\n [a] = b", // array destructuring
"let\n {a} = b", // object destructuring
};
for (cases) |src| {
var tree = try parseSource(src);
defer tree.deinit(testing.allocator);
try expectNoErrors(&tree);
try testing.expect(firstNodeOfTag(&tree, .let_decl) != null);
try testing.expect(firstNodeOfTag(&tree, .declarator) != null);
}
}

test "let followed by an operator is still an identifier expression (#59)" {
// `let\n instanceof x` is the expression `let instanceof x` (the operator
// continues the expression); ASI does not make a declaration here.
var tree = try parseSource("let\n instanceof x");
defer tree.deinit(testing.allocator);
try expectNoErrors(&tree);
try testing.expect(firstNodeOfTag(&tree, .let_decl) == null);
}

test "let with newline binding stays an expression in single-statement contexts (#59)" {
// A LexicalDeclaration is forbidden as a single-statement body or labeled-item,
// so ASI fires after `let` there — `let\n x` is the `let` identifier expression,
// NOT a declaration (guards the statement-list fix from leaking into these
// delegated contexts).
const cases = [_][]const u8{
"if (a) let\n x = 1",
"while (a) let\n x = 1",
"for (;;) let\n x = 1",
"lbl: let\n x = 1",
"A: B: let\n x = 1", // nested labels
};
for (cases) |src| {
var tree = try parseSource(src);
defer tree.deinit(testing.allocator);
try expectNoErrors(&tree);
try testing.expect(firstNodeOfTag(&tree, .let_decl) == null);
}
}
15 changes: 15 additions & 0 deletions tests/semantic_test.zig
Original file line number Diff line number Diff line change
Expand Up @@ -1631,3 +1631,18 @@ test "break to a labeled block makes the following statement reachable (#57)" {
// No break, body always returns → the following statement is unreachable.
try testing.expectEqual(@as(u8, 0), try cfgSegReachable("function f(){ A: { return; } baz(); }", "baz"));
}

test "let with binding on next line declares a let binding, not a global (#59)" {
// `let\n x = 1` must declare `x` as a `let` (previously ASI made it `let;` plus
// an assignment to an undeclared global `x`).
var r = try analyzeSource("let\n x = 1;\n x;");
defer r.deinit(testing.allocator);
try expectSymbol(&r, "x", .let, .global);
const x = findSymbol(&r, "x") orelse return error.SymbolNotFound;
// 2 refs: the declarator init write and the `x;` read — both resolve to the binding.
try testing.expectEqual(@as(u32, 2), r.symbols.getRefRange(x).len());
// The array-destructuring form (the new `[`-branch) binds its names too.
var r2 = try analyzeSource("let\n [a] = b;\n a;");
defer r2.deinit(testing.allocator);
try expectSymbol(&r2, "a", .let, .global);
}
Loading