feat: execute Bro-compatible integer range loops

This commit is contained in:
2026-10-10 01:47:30 +02:00
parent d51ca57c9e
commit cf39481bfc
16 changed files with 1416 additions and 16 deletions
+127 -1
View File
@@ -185,6 +185,7 @@ parse_statement proc(state @mut State, allow_else bool) ast.NodeId ! ParseError
match current(state).kind {
.if: return try parse_if(state)
.while: return try parse_while(state, allow_else)
.for: return try parse_for(state, allow_else)
.return: return try parse_return(state, allow_else)
.break, .continue: return try parse_loop_control(state, allow_else)
.ident, .int, .float, .string, .true, .false, .minus, .bang, .open_paren: {
@@ -302,6 +303,92 @@ parse_while proc(state @mut State, allow_else bool) ast.NodeId ! ParseError {
)
}
@hide:file
CaptureList :: struct {
item ?ast.NodeId = null
index ?ast.NodeId = null
pointer bool = false
indexed bool = false
}
@hide:file
parse_capture_name proc(state @mut State) ?ast.NodeId ! ParseError {
name :: try consume(state, .ident)
capture ?ast.NodeId := null
if (
name.span.end != name.span.start + 1
or state.builder.source[usize(name.span.start)] != '_'
) capture = try ast.add_identifier(&state.builder, name.span)
return capture
}
@hide:file
parse_captures proc(state @mut State) CaptureList ! ParseError {
_ = try consume(state, .pipe)
captures := CaptureList{}
if next_is(state, .at) {
captures.pointer = true
state.next_token += 1
}
captures.item = try parse_capture_name(state)
if next_is(state, .comma) {
captures.indexed = true
state.next_token += 1
captures.index = try parse_capture_name(state)
}
_ = try consume(state, .pipe)
return captures
}
@hide:file
parse_for proc(state @mut State, allow_else bool) ast.NodeId ! ParseError {
keyword :: try consume(state, .for)
skip_newlines(state)
iterable_start :: state.next_token
iterable := try parse_expr(state)
grouped :: parenthesized_guard(state, iterable_start)
skip_newlines(state)
captures :: parse_captures(state) catch |err| {
match err {
.unexpected_token: {}
else: return err
}
diagnostic :: try diagnostics.add(state.diagnostics, .unexpected_token, current(state).span)
iterable = try ast.add_invalid_expr(
&state.builder, ast.builder_span(&state.builder, iterable), diagnostic,
)
while (
!next_is(state, .open_curly) and !next_is(state, .close_curly)
and !next_is(state, .newline) and !next_is(state, .eof)
and !next_is(state, .else)
) state.next_token += 1
yield CaptureList{}
}
after_captures :: state.next_token
skip_newlines(state)
if (
!next_is(state, .open_curly) and !grouped
and ast.builder_expr_kind(&state.builder, iterable) != .call
and ast.builder_expr_kind(&state.builder, iterable) != .invalid
) {
diagnostic :: try diagnostics.add(state.diagnostics, .unexpected_token, current(state).span)
iterable = try ast.add_invalid_expr(
&state.builder, ast.builder_span(&state.builder, iterable), diagnostic,
)
}
# a malformed capture list must not swallow the next line's sibling.
if (
ast.builder_expr_kind(&state.builder, iterable) == .invalid
and !next_is(state, .open_curly)
) state.next_token = after_captures
body :: try parse_arm(state, allow_else)
return try ast.add_for_stmt(
&state.builder,
ast.Span{ start = keyword.span.start, end = ast.builder_span(&state.builder, body).end },
iterable, captures.item, captures.index, captures.pointer, captures.indexed, body,
)
}
@hide:file
parse_guard proc(state @mut State) ast.NodeId ! ParseError {
condition_start :: state.next_token
@@ -320,6 +407,7 @@ parse_guard proc(state @mut State) ast.NodeId ! ParseError {
and !next_is(state, .newline) and !next_is(state, .eof)
and !next_is(state, .else) and !next_is(state, .return)
and !next_is(state, .if) and !next_is(state, .while)
and !next_is(state, .for)
and !next_is(state, .break) and !next_is(state, .continue)
) {
if (next_is(state, .ident) and next_n_is(state, 1, .equal)) break
@@ -435,6 +523,7 @@ parse_expr proc(state @mut State) ast.NodeId ! ParseError {
parse_expr_bp proc(state @mut State, min_bp BindingPowerScalar) ast.NodeId ! ParseError {
skip_operand_newlines(state)
token := current(state)
expression_start :: state.next_token
left := if (prefix_binding_power(token.kind)) |right_bp| {
state.next_token += 1
operand :: try parse_expr_bp(state, right_bp)
@@ -449,12 +538,32 @@ parse_expr_bp proc(state @mut State, min_bp BindingPowerScalar) ast.NodeId ! Par
} else {
yield try parse_primary(state)
}
left_grouped := parenthesized_guard(state, expression_start)
while true {
skip_continuation_newlines(state)
token = current(state)
if (token.kind == .open_paren) {
left = try parse_call(state, left)
left_grouped = false
continue
}
if (token.kind == .range or token.kind == .range_inclusive) {
if (min_bp > BindingPowerScalar(0)) break
left = try validate_range_bound(state, left, left_grouped)
state.next_token += 1
right_start :: state.next_token
right := try parse_expr_bp(state, BindingPowerScalar(1))
right = try validate_range_bound(state, right, parenthesized_guard(state, right_start))
left = try ast.add_range(
&state.builder,
ast.Span{
start = state.tokens[expression_start].span.start,
end = state.tokens[state.next_token - 1].span.end,
},
left, right, token.kind == .range_inclusive,
)
left_grouped = false
continue
}
if (infix_binding_power(token.kind)) |power| {
@@ -470,6 +579,7 @@ parse_expr_bp proc(state @mut State, min_bp BindingPowerScalar) ast.NodeId ! Par
left,
right,
)
left_grouped = false
continue
}
break
@@ -477,6 +587,21 @@ parse_expr_bp proc(state @mut State, min_bp BindingPowerScalar) ast.NodeId ! Par
return left
}
@hide:file
validate_range_bound proc(
state @mut State, bound ast.NodeId, grouped bool,
) ast.NodeId ! (mem.AllocError | diagnostics.CapacityError) {
if (grouped) return bound
match ast.builder_expr_kind(&state.builder, bound) {
.unary, .binary, .range: {
span :: ast.builder_span(&state.builder, bound)
diagnostic :: try diagnostics.add(state.diagnostics, .unexpected_token, span)
return try ast.add_invalid_expr(&state.builder, span, diagnostic)
}
else: return bound
}
}
@hide:file
parse_call proc(state @mut State, callee ast.NodeId) ast.NodeId ! ParseError {
_ = try consume(state, .open_paren)
@@ -566,7 +691,7 @@ parse_primary proc(state @mut State) ast.NodeId ! ParseError {
@hide:file
is_expression_boundary proc(kind lexer.TokenKind) bool {
return match kind {
.close_paren, .newline, .close_curly, .open_curly, .else, .return, .if, .while, .eof: true
.close_paren, .newline, .close_curly, .open_curly, .else, .return, .if, .while, .for, .eof: true
else: false
}
}
@@ -586,6 +711,7 @@ is_expression_recovery_boundary proc(kind lexer.TokenKind) bool {
kind == .comma
or kind == .and
or kind == .or
or kind == .pipe
or is_expression_boundary(kind)
)
}
+117
View File
@@ -1,5 +1,6 @@
import "@std/mem"
import "@std/testing"
import "@std/arraylist"
import "@source/diagnostics"
import "@source/strpool"
@@ -1083,3 +1084,119 @@ preserves_control_bodies_after_malformed_guards test {
}
try testing.expect_equal(ast.StmtKind.declaration, ast.stmt_kind(&tree, ast.block_statement(&tree, body, 3)))
}
parses_range_endpoints_and_capture_shapes test {
strpool.strings = strpool.init(mem.c_allocator)
defer strpool.deinit(&strpool.strings)
diagnostic_store := diagnostics.init(mem.c_allocator)
defer diagnostics.deinit(&diagnostic_store)
source ::
`stored :: (1 + 2)..=(limit() * 2)
`run proc() void {
` for (-2)..1 |item| { item }
` for (0..10) |_| continue
` for 0..10 |@item, _| {}
` for 0..10 |item, index| { index }
` for
` 0..10
` |item|
` { item }
`}
scan_state := lexer.init(mem.c_allocator)
defer lexer.deinit(&scan_state)
try lexer.scan(&scan_state, source, &diagnostic_store)
tree := try parse(mem.c_allocator, scan_state.tokens.items, source, &diagnostic_store)
defer ast.deinit(&tree)
try testing.expect_equal(0, diagnostics.count(&diagnostic_store))
stored :: ast.statement_decl(&tree, ast.package_decl(&tree, 0)).initializer
interval :: ast.range_expr(&tree, stored)
try testing.expect(interval.inclusive)
try testing.expect_equal(ast.ExprKind.binary, ast.expr_kind(&tree, interval.start))
try testing.expect_equal(ast.ExprKind.binary, ast.expr_kind(&tree, interval.end))
body :: ast.function_decl(&tree, ast.package_decl(&tree, 1)).body
try testing.expect_equal(5, ast.block_statement_count(&tree, body))
first :: ast.for_stmt(&tree, ast.block_statement(&tree, body, 0))
if (first.capture) |capture| {
try testing.expect_equal(ast.ExprKind.identifier, ast.expr_kind(&tree, capture))
try testing.expect_equal("item", ast.source_text(&tree, ast.node_span(&tree, capture)))
} else return .expectation_failed
second :: ast.for_stmt(&tree, ast.block_statement(&tree, body, 1))
try testing.expect(second.capture == null)
try testing.expect_equal(ast.StmtKind.continue_stmt, ast.stmt_kind(
&tree, ast.block_statement(&tree, second.body, 0),
))
third :: ast.for_stmt(&tree, ast.block_statement(&tree, body, 2))
try testing.expect(third.pointer_capture and third.index_capture and third.index == null)
fourth :: ast.for_stmt(&tree, ast.block_statement(&tree, body, 3))
try testing.expect(fourth.index_capture and fourth.index != null)
}
rejects_bro_invalid_range_headers_without_losing_siblings test {
strpool.strings = strpool.init(mem.c_allocator)
defer strpool.deinit(&strpool.strings)
cases [13][]u8 :: [
"for -2..1 |i| {}",
"for 0..limit+1 |i| {}",
"for (0..limit+1) |i| {}",
"for 0..10 |i| continue",
"for (0)..10 |i| continue",
"for 0..10 {}",
"for 0..10 || {}",
"for 0..10 |i int| {}",
"for 0..10 |i,| {}",
"for 0..10 |i, j, k| {}",
"for 0..10 |i\n| {}",
"for ..10 |i| {}",
"for 0.. |i| {}",
]
for (cases) |header| {
diagnostic_store := diagnostics.init(mem.c_allocator)
defer diagnostics.deinit(&diagnostic_store)
source arraylist.ArrayList(u8) := arraylist.init(mem.c_allocator)
defer arraylist.deinit(&source)
for ("run proc() void {\n") |byte| try arraylist.append(&source, byte)
for (header) |byte| try arraylist.append(&source, byte)
for ("\nkept :: 7\n}\nafter :: 9\n") |byte| try arraylist.append(&source, byte)
scan_state := lexer.init(mem.c_allocator)
defer lexer.deinit(&scan_state)
try lexer.scan(&scan_state, source.items, &diagnostic_store)
tree := try parse(mem.c_allocator, scan_state.tokens.items, source.items, &diagnostic_store)
defer ast.deinit(&tree)
try testing.expect(diagnostics.count(&diagnostic_store) > 0)
try testing.expect_equal(2, ast.package_decl_count(&tree))
try testing.expect_equal("after", ast.source_text(
&tree, ast.statement_decl(&tree, ast.package_decl(&tree, 1)).name,
))
body :: ast.function_decl(&tree, ast.package_decl(&tree, 0)).body
last :: ast.block_statement(&tree, body, ast.block_statement_count(&tree, body) - 1)
try testing.expect_equal(ast.StmtKind.declaration, ast.stmt_kind(&tree, last))
try testing.expect_equal("kept", ast.source_text(&tree, ast.statement_decl(&tree, last).name))
}
}
range_loop_preserves_nearest_else_and_canonical_body test {
strpool.strings = strpool.init(mem.c_allocator)
defer strpool.deinit(&strpool.strings)
diagnostic_store := diagnostics.init(mem.c_allocator)
defer diagnostics.deinit(&diagnostic_store)
source ::
`run proc() void {
` if (true) for (0..2) |i| if (false) break else continue
` for values() |item| return
`}
scan_state := lexer.init(mem.c_allocator)
defer lexer.deinit(&scan_state)
try lexer.scan(&scan_state, source, &diagnostic_store)
tree := try parse(mem.c_allocator, scan_state.tokens.items, source, &diagnostic_store)
defer ast.deinit(&tree)
try testing.expect_equal(0, diagnostics.count(&diagnostic_store))
body :: ast.function_decl(&tree, ast.package_decl(&tree, 0)).body
outer :: ast.if_stmt(&tree, ast.block_statement(&tree, body, 0))
try testing.expect(outer.else_block == null)
loop :: ast.for_stmt(&tree, ast.block_statement(&tree, outer.then_block, 0))
nested :: ast.if_stmt(&tree, ast.block_statement(&tree, loop.body, 0))
try testing.expect(nested.else_block != null)
call_loop :: ast.for_stmt(&tree, ast.block_statement(&tree, body, 1))
try testing.expect_equal(ast.ExprKind.call, ast.expr_kind(&tree, call_loop.iterable))
try testing.expect_equal(1, ast.block_statement_count(&tree, call_loop.body))
}