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
24 changes: 24 additions & 0 deletions internal/frontend/ast/stmt.go
Original file line number Diff line number Diff line change
Expand Up @@ -83,18 +83,42 @@ func (s *IfStmt) loc() *source.Location { return s.Location }
type ForStmt struct {
NodeIDHolder
Documented
Index *Ident
Value *Ident
Iterable Expr
Cond Expr
Body *BlockStmt
Location *source.Location
}

func (*ForStmt) stmtNode() {}
func (s *ForStmt) forEachChild(visit func(Node)) {
visit(s.Index)
visit(s.Value)
visit(s.Iterable)
visit(s.Cond)
visit(s.Body)
}
func (s *ForStmt) loc() *source.Location { return s.Location }

type BreakStmt struct {
NodeIDHolder
Location *source.Location
}

func (*BreakStmt) stmtNode() {}
func (s *BreakStmt) forEachChild(func(Node)) {}
func (s *BreakStmt) loc() *source.Location { return s.Location }

type ContinueStmt struct {
NodeIDHolder
Location *source.Location
}

func (*ContinueStmt) stmtNode() {}
func (s *ContinueStmt) forEachChild(func(Node)) {}
func (s *ContinueStmt) loc() *source.Location { return s.Location }

type MatchPatternField struct {
Name *Ident
Binding *Ident
Expand Down
192 changes: 192 additions & 0 deletions internal/frontend/parser/parse_for_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,192 @@
package parser

import (
"strings"
"testing"

"compiler/internal/frontend/ast"
)

func parseForBody(t *testing.T, src string) *ast.ForStmt {
t.Helper()
mod, diag := parseTestModule(src)
if diag.HasErrors() {
t.Fatalf("unexpected diagnostics: %s", diag.EmitAllToString())
}
if len(mod.Stmts) != 1 {
t.Fatalf("module stmts = %d, want 1", len(mod.Stmts))
}
fn, ok := mod.Stmts[0].(*ast.FnDecl)
if !ok || fn.Body == nil || len(fn.Body.Stmts) == 0 {
t.Fatalf("expected function with statements, got %#v", mod.Stmts)
}
forStmt, ok := fn.Body.Stmts[0].(*ast.ForStmt)
if !ok {
t.Fatalf("expected for stmt, got %#v", fn.Body.Stmts[0])
}
return forStmt
}

func TestParseForConditionForm(t *testing.T) {
src := `fn main() -> i32 {
for x < 10 {
return 1;
}
return 0;
}`
forStmt := parseForBody(t, src)
if forStmt.Value != nil || forStmt.Iterable != nil {
t.Fatalf("expected condition form, got value=%v iterable=%v", forStmt.Value, forStmt.Iterable)
}
if forStmt.Cond == nil {
t.Fatal("expected condition")
}
}

func TestParseForInSingleBinding(t *testing.T) {
src := `fn main() -> i32 {
for i in 0..10 {
return 1;
}
return 0;
}`
forStmt := parseForBody(t, src)
if forStmt.Cond != nil {
t.Fatalf("expected nil condition, got %#v", forStmt.Cond)
}
if forStmt.Value == nil || forStmt.Index != nil {
t.Fatalf("expected single value binding, got index=%v value=%v", forStmt.Index, forStmt.Value)
}
if forStmt.Value.Name != "i" {
t.Fatalf("binding value = %q, want i", forStmt.Value.Name)
}
if _, ok := forStmt.Iterable.(*ast.RangeExpr); !ok {
t.Fatalf("expected range iterable, got %#v", forStmt.Iterable)
}
}

func TestParseForInIndexValueBinding(t *testing.T) {
src := `fn main() -> i32 {
for index, value in 0..10 {
return 1;
}
return 0;
}`
forStmt := parseForBody(t, src)
if forStmt.Index == nil || forStmt.Value == nil {
t.Fatalf("expected index and value bindings, got index=%v value=%v", forStmt.Index, forStmt.Value)
}
if forStmt.Index.Name != "index" || forStmt.Value.Name != "value" {
t.Fatalf("binding names = %q, %q", forStmt.Index.Name, forStmt.Value.Name)
}
}

func TestParseBreakContinue(t *testing.T) {
src := `fn main() -> i32 {
for x < 10 {
break;
continue;
}
return 0;
}`
mod, diag := parseTestModule(src)
if diag.HasErrors() {
t.Fatalf("unexpected diagnostics: %s", diag.EmitAllToString())
}
fn := mod.Stmts[0].(*ast.FnDecl)
forStmt := fn.Body.Stmts[0].(*ast.ForStmt)
if len(forStmt.Body.Stmts) != 2 {
t.Fatalf("body stmts = %d, want 2", len(forStmt.Body.Stmts))
}
if _, ok := forStmt.Body.Stmts[0].(*ast.BreakStmt); !ok {
t.Fatalf("expected break stmt, got %#v", forStmt.Body.Stmts[0])
}
if _, ok := forStmt.Body.Stmts[1].(*ast.ContinueStmt); !ok {
t.Fatalf("expected continue stmt, got %#v", forStmt.Body.Stmts[1])
}
}

func TestParseForInInvalidBindingRegistersRecoveryNode(t *testing.T) {
src := `fn main() -> i32 {
for 1 in 0..2 {}
return 0;
}`
mod, diag := parseTestModule(src)
if !diag.HasErrors() {
t.Fatal("expected diagnostic for invalid loop binding")
}
fn, ok := mod.Stmts[0].(*ast.FnDecl)
if !ok || fn.Body == nil || len(fn.Body.Stmts) == 0 {
t.Fatalf("expected function with loop, got %#v", mod.Stmts)
}
loop, ok := fn.Body.Stmts[0].(*ast.ForStmt)
if !ok || loop.Value == nil {
t.Fatalf("expected recovered for-in binding, got %#v", fn.Body.Stmts[0])
}
if loop.Value.ID() == 0 {
t.Fatal("recovery binding has unregistered node ID")
}
}

func TestParseForCommaRequiresIn(t *testing.T) {
src := `fn main() -> i32 {
for i, v {
return 1;
}
return 0;
}`
_, diag := parseTestModule(src)
if !diag.HasErrors() {
t.Fatal("expected diagnostic for comma without 'in'")
}
}

func TestParseMalformedForInHeaderPreservesLoopShape(t *testing.T) {
for _, test := range []struct {
name string
header string
}{
{name: "missing first binding", header: ", value in values"},
{name: "missing second binding", header: "index, in values"},
{name: "extra binding", header: "index, value, extra in values"},
{name: "missing iterable", header: "value in"},
{name: "malformed iterable", header: "value in +"},
} {
t.Run(test.name, func(t *testing.T) {
mod, diag := parseTestModule("fn main() { for " + test.header + " {} return; }")
if !diag.HasErrors() {
t.Fatal("expected malformed-header diagnostic")
}
if strings.Contains(diag.EmitAllToString(), "missing for body") {
t.Fatalf("unexpected body-recovery cascade:\n%s", diag.EmitAllToString())
}
fn := mod.Stmts[0].(*ast.FnDecl)
if len(fn.Body.Stmts) != 2 {
t.Fatalf("function statements = %d, want recovered loop and return", len(fn.Body.Stmts))
}
loop, ok := fn.Body.Stmts[0].(*ast.ForStmt)
if !ok || loop.Cond != nil || loop.Iterable == nil || loop.Body == nil {
t.Fatalf("malformed header lost for-in shape: %#v", fn.Body.Stmts[0])
}
if loop.Value == nil || loop.Value.ID() == 0 {
t.Fatalf("recovery value binding = %#v, want registered identifier", loop.Value)
}
if _, ok := fn.Body.Stmts[1].(*ast.ReturnStmt); !ok {
t.Fatalf("following statement = %#v, want return outside loop", fn.Body.Stmts[1])
}
})
}
}

func TestParseRejectsLabeledBreak(t *testing.T) {
src := `fn main() -> i32 {
for x < 10 {
break outer;
}
return 0;
}`
_, diag := parseTestModule(src)
if !diag.HasErrors() {
t.Fatal("expected diagnostic for labeled break")
}
}
76 changes: 73 additions & 3 deletions internal/frontend/parser/parse_stmt.go
Original file line number Diff line number Diff line change
Expand Up @@ -60,6 +60,10 @@ func (p *Parser) parseStmt(isModuleLevel bool) ast.Stmt {
stmt = p.parseIfStmt()
case token.FOR:
stmt = p.parseForStmt()
case token.BREAK:
stmt = p.parseLoopJumpStmt(token.BREAK)
case token.CONTINUE:
stmt = p.parseLoopJumpStmt(token.CONTINUE)
case token.MATCH:
stmt = p.parseMatchStmt()
case token.RETURN:
Expand Down Expand Up @@ -180,21 +184,53 @@ func (p *Parser) parseForStmt() ast.Stmt {
if start == nil {
return nil
}
var cond ast.Expr
var index, value *ast.Ident
var iterable, cond ast.Expr
if !p.at(token.LBRACE) {
cond = p.parseExprWithControlHeader(precLowest, true)
head := p.parseExprWithControlHeader(precLowest, true)
switch {
case head != nil && p.at(token.IN):
p.advance()
iterable = p.parseIndexOperand()
value = p.forInBindingName(head)
case head != nil && p.at(token.COMMA):
// `for i, v in expr` — comma commits to the two-binding form even
// when recovery must preserve an invalid header for later phases.
p.advance()
index = p.forInBindingName(head)
value = p.parseIdent()
if value == nil {
current := p.current()
value = reg(p, &ast.Ident{Name: "", Location: source.NewLocation(p.filePath, current.Start, current.End)})
}
if p.match(token.IN) {
iterable = p.parseIndexOperand()
} else {
p.consume(token.IN, "expected 'in' after loop variables")
current := p.current()
iterable = reg(p, &ast.BadExpr{Location: source.NewLocation(p.filePath, current.Start, current.End)})
p.synchronize(token.LBRACE)
}
default:
cond = head
}
}
var body *ast.BlockStmt
if p.at(token.LBRACE) {
body = p.parseBlock()
}
if body == nil {
prev := p.lastNonNilToken(*start)
if cond != nil {
if iterable != nil {
prev.End = ast.EndOf(iterable)
} else if cond != nil {
prev.End = ast.EndOf(cond)
}
p.diag.Add(diagnostics.NewError("missing for body").WithCode(diagnostics.ErrExpectedToken).WithPrimaryLabel(source.NewLocation(p.filePath, prev.End, prev.End), "expected '{' here"))
return reg(p, &ast.ForStmt{
Index: index,
Value: value,
Iterable: iterable,
Cond: cond,
Location: source.NewLocation(p.filePath, start.Start, prev.End),
})
Expand All @@ -204,12 +240,46 @@ func (p *Parser) parseForStmt() ast.Stmt {
endTok.End = *ast.LocOf(body).End
}
return reg(p, &ast.ForStmt{
Index: index,
Value: value,
Iterable: iterable,
Cond: cond,
Body: body,
Location: source.NewLocation(p.filePath, start.Start, endTok.End),
})
}

// forInBindingName converts the leading expression of a for-in header into a
// loop binding name. Non-identifier heads get a diagnostic and a registered
// recovery binding so later phase maps retain unique node IDs.
func (p *Parser) forInBindingName(head ast.Expr) *ast.Ident {
if ident, ok := head.(*ast.Ident); ok {
return ident
}
p.diag.Add(diagnostics.NewError("invalid loop variable").WithCode(diagnostics.ErrInvalidExpression).WithPrimaryLabel(ast.LocOf(head), "expected an identifier before 'in'"))
return reg(p, &ast.Ident{Name: "", Location: ast.LocOf(head)})
}

// parseLoopJumpStmt parses `break;` / `continue;`. Loop labels are parsed and
// rejected so the diagnostic points at the label instead of a generic syntax
// error; labeled jumps are planned for a later release.
func (p *Parser) parseLoopJumpStmt(kind token.Kind) ast.Stmt {
start := p.consume(kind, "expected "+string(kind))
if start == nil {
return nil
}
if p.at(token.IDENT) {
label := p.advance()
p.diag.Add(diagnostics.NewError("labeled "+string(kind)+" is not supported yet").WithCode(diagnostics.ErrInvalidStatement).WithPrimaryLabel(source.NewLocation(p.filePath, label.Start, label.End), "loop labels are planned for a later release"))
}
p.consume(token.SEMICOLON, "")
loc := source.NewLocation(p.filePath, start.Start, start.End)
if kind == token.BREAK {
return reg(p, &ast.BreakStmt{Location: loc})
}
return reg(p, &ast.ContinueStmt{Location: loc})
}

func (p *Parser) parseMatchStmt() ast.Stmt {
start := p.consume(token.MATCH, "expected match")
if start == nil {
Expand Down
2 changes: 2 additions & 0 deletions internal/frontend/token/keywords.go
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,7 @@ var keywords = map[string]Kind{
"rawptr": RAWPTR,
"as": AS,
"is": IS,
"in": IN,
"with": WITH,
"mut": MUT,
"atomic": ATOMIC,
Expand Down Expand Up @@ -68,6 +69,7 @@ var keywordDocs = map[Kind]string{
RAWPTR: "Name an opaque unsafe non-owning pointer.",
AS: "Cast an expression to a target type.",
IS: "Check whether a value conforms to a target type.",
IN: "Iterate over an iterable value in a for loop.",
WITH: "Attach a payload value to an enum variant.",
MUT: "Mark a binding or reference as mutable.",
ATOMIC: "Declare or name atomic storage.",
Expand Down
1 change: 1 addition & 0 deletions internal/frontend/token/kinds.go
Original file line number Diff line number Diff line change
Expand Up @@ -92,6 +92,7 @@ const (
RAWPTR Kind = "rawptr"
AS Kind = "as"
IS Kind = "is"
IN Kind = "in"
WITH Kind = "with"
MUT Kind = "mut"
ATOMIC Kind = "atomic"
Expand Down
Loading