From 2302c08a897a8eea5c714992d70cde8d0be8c1c7 Mon Sep 17 00:00:00 2001 From: itsfuad Date: Tue, 1 Sep 2026 00:12:09 +0600 Subject: [PATCH] Implement and harden for loops Add range and sequence iteration, break/continue CFG topology, structured HIR/MIR lowering, ownership-safe cleanup, and target-sized sequence indexes. Add focused parser, semantic, CFG, ownership, HIR, MIR, backend, and source fixture coverage. Helpers centralize generated loop artifacts and loan-lifetime invariants rather than forwarding existing behavior. Validated with go test -count=1 ./..., a fresh compiler bundle, the full uncached x_test suite, and 386/amd64 LLVM checks through clang. --- internal/frontend/ast/stmt.go | 24 ++ internal/frontend/parser/parse_for_test.go | 192 +++++++++ internal/frontend/parser/parse_stmt.go | 76 +++- internal/frontend/token/keywords.go | 2 + internal/frontend/token/kinds.go | 1 + internal/ir/cfg/build.go | 128 ++++-- internal/ir/cfg/cfg_test.go | 287 ++++++++++++- internal/ir/cfg/model.go | 3 + internal/ir/hir/fold/fold.go | 10 +- internal/ir/hir/fold/fold_test.go | 35 ++ internal/ir/hir/lower/module_lower.go | 132 +++++- internal/ir/hir/lower/module_lower_test.go | 95 +++- internal/ir/hir/model.go | 26 +- internal/ir/hir/model_test.go | 31 ++ internal/ir/mir/module_lower.go | 30 +- internal/ir/mir/module_lower_test.go | 173 ++++++-- internal/pipeline/pipeline.go | 5 +- internal/pipeline/pipeline_test.go | 222 +++++++++- internal/project/modules.go | 35 ++ .../definiteinit/initialization_test.go | 30 +- internal/semantics/ownership/ownership.go | 145 +++++-- .../semantics/ownership/ownership_test.go | 384 ++++++++++++++++- internal/semantics/ownership/reference.go | 17 +- internal/semantics/ownershipresult/result.go | 5 +- internal/semantics/resolver/resolver.go | 29 +- internal/semantics/typechecker/check_expr.go | 17 +- internal/semantics/typechecker/check_stmt.go | 184 ++++++++ internal/semantics/typechecker/flow_test.go | 5 +- internal/semantics/typechecker/for_in_test.go | 404 ++++++++++++++++++ internal/semantics/typechecker/typechecker.go | 1 + specs/002-for-loop/data-model.md | 111 +++++ specs/002-for-loop/plan.md | 91 ++++ specs/002-for-loop/quickstart.md | 72 ++++ specs/002-for-loop/research.md | 83 ++++ specs/002-for-loop/spec.md | 71 +++ x_test/for_array_loop/peeper.toml | 7 + x_test/for_array_loop/src/main.peep | 46 ++ x_test/for_break_continue/peeper.toml | 7 + x_test/for_break_continue/src/main.peep | 71 +++ x_test/for_nested_loops/peeper.toml | 7 + x_test/for_nested_loops/src/main.peep | 16 + x_test/for_range_loop/peeper.toml | 7 + x_test/for_range_loop/src/main.peep | 18 + .../negative_break_outside_loop/peeper.toml | 7 + .../negative_break_outside_loop/src/main.peep | 3 + .../peeper.toml | 7 + .../src/main.peep | 3 + .../negative_for_inclusive_range/peeper.toml | 7 + .../src/main.peep | 3 + .../peeper.toml | 7 + .../src/main.peep | 14 + .../negative_for_malformed_header/peeper.toml | 7 + .../src/main.peep | 4 + x_test/negative_for_non_iterable/peeper.toml | 7 + .../negative_for_non_iterable/src/main.peep | 3 + .../peeper.toml | 7 + .../src/main.peep | 12 + x_test/negative_for_string/peeper.toml | 7 + x_test/negative_for_string/src/main.peep | 3 + .../negative_for_unbounded_range/peeper.toml | 7 + .../src/main.peep | 3 + .../negative_for_zero_entry_move/peeper.toml | 7 + .../src/main.peep | 18 + 63 files changed, 3304 insertions(+), 167 deletions(-) create mode 100644 internal/frontend/parser/parse_for_test.go create mode 100644 internal/semantics/typechecker/for_in_test.go create mode 100644 specs/002-for-loop/data-model.md create mode 100644 specs/002-for-loop/plan.md create mode 100644 specs/002-for-loop/quickstart.md create mode 100644 specs/002-for-loop/research.md create mode 100644 specs/002-for-loop/spec.md create mode 100644 x_test/for_array_loop/peeper.toml create mode 100644 x_test/for_array_loop/src/main.peep create mode 100644 x_test/for_break_continue/peeper.toml create mode 100644 x_test/for_break_continue/src/main.peep create mode 100644 x_test/for_nested_loops/peeper.toml create mode 100644 x_test/for_nested_loops/src/main.peep create mode 100644 x_test/for_range_loop/peeper.toml create mode 100644 x_test/for_range_loop/src/main.peep create mode 100644 x_test/negative_break_outside_loop/peeper.toml create mode 100644 x_test/negative_break_outside_loop/src/main.peep create mode 100644 x_test/negative_continue_outside_loop/peeper.toml create mode 100644 x_test/negative_continue_outside_loop/src/main.peep create mode 100644 x_test/negative_for_inclusive_range/peeper.toml create mode 100644 x_test/negative_for_inclusive_range/src/main.peep create mode 100644 x_test/negative_for_iterable_ownership/peeper.toml create mode 100644 x_test/negative_for_iterable_ownership/src/main.peep create mode 100644 x_test/negative_for_malformed_header/peeper.toml create mode 100644 x_test/negative_for_malformed_header/src/main.peep create mode 100644 x_test/negative_for_non_iterable/peeper.toml create mode 100644 x_test/negative_for_non_iterable/src/main.peep create mode 100644 x_test/negative_for_sequence_requirements/peeper.toml create mode 100644 x_test/negative_for_sequence_requirements/src/main.peep create mode 100644 x_test/negative_for_string/peeper.toml create mode 100644 x_test/negative_for_string/src/main.peep create mode 100644 x_test/negative_for_unbounded_range/peeper.toml create mode 100644 x_test/negative_for_unbounded_range/src/main.peep create mode 100644 x_test/negative_for_zero_entry_move/peeper.toml create mode 100644 x_test/negative_for_zero_entry_move/src/main.peep diff --git a/internal/frontend/ast/stmt.go b/internal/frontend/ast/stmt.go index 3da66826..6bb16768 100644 --- a/internal/frontend/ast/stmt.go +++ b/internal/frontend/ast/stmt.go @@ -83,6 +83,9 @@ 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 @@ -90,11 +93,32 @@ type ForStmt struct { 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 diff --git a/internal/frontend/parser/parse_for_test.go b/internal/frontend/parser/parse_for_test.go new file mode 100644 index 00000000..89c5f715 --- /dev/null +++ b/internal/frontend/parser/parse_for_test.go @@ -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") + } +} diff --git a/internal/frontend/parser/parse_stmt.go b/internal/frontend/parser/parse_stmt.go index 2c9a1eac..4298487e 100644 --- a/internal/frontend/parser/parse_stmt.go +++ b/internal/frontend/parser/parse_stmt.go @@ -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: @@ -180,9 +184,36 @@ 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) { @@ -190,11 +221,16 @@ func (p *Parser) parseForStmt() ast.Stmt { } 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), }) @@ -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 { diff --git a/internal/frontend/token/keywords.go b/internal/frontend/token/keywords.go index 168be513..dbbda0dc 100644 --- a/internal/frontend/token/keywords.go +++ b/internal/frontend/token/keywords.go @@ -27,6 +27,7 @@ var keywords = map[string]Kind{ "rawptr": RAWPTR, "as": AS, "is": IS, + "in": IN, "with": WITH, "mut": MUT, "atomic": ATOMIC, @@ -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.", diff --git a/internal/frontend/token/kinds.go b/internal/frontend/token/kinds.go index 97d854f4..6cab9129 100644 --- a/internal/frontend/token/kinds.go +++ b/internal/frontend/token/kinds.go @@ -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" diff --git a/internal/ir/cfg/build.go b/internal/ir/cfg/build.go index d993b6b9..6fe39d67 100644 --- a/internal/ir/cfg/build.go +++ b/internal/ir/cfg/build.go @@ -9,16 +9,34 @@ import ( ) type builder struct { - fn *Graph - matchCases MatchCaseQuery - nextID int + fn *Graph + queries BuildQueries + nextID int + scopes []*ast.BlockStmt + loops []loopContext +} + +type loopContext struct { + continueTarget *Block + breakTarget *Block + scopeDepth int } // MatchCaseQuery supplies typechecker-resolved case indexes in source arm order. type MatchCaseQuery func(ast.NodeID) ([]int, bool) +// LoopEntryQuery supplies typechecker proof that a loop executes its body before +// its first condition check. +type LoopEntryQuery func(ast.NodeID) bool + +// BuildQueries are semantic facts required to construct truthful CFG topology. +type BuildQueries struct { + MatchCases MatchCaseQuery + LoopGuaranteedEntry LoopEntryQuery +} + // BuildModule creates immutable control-flow topology from typed source syntax. -func BuildModule(source *ast.Module, matchCases MatchCaseQuery) *Module { +func BuildModule(source *ast.Module, queries BuildQueries) *Module { if source == nil { return nil } @@ -31,7 +49,7 @@ func BuildModule(source *ast.Module, matchCases MatchCaseQuery) *Module { if !ok || fn == nil || fn.Body == nil { return true } - graph := buildFunction(fn, matchCases) + graph := buildFunction(fn, queries) finalizeGraph(graph) if module.byNodeID[graph.NodeID] != nil { panic(fmt.Sprintf("CFG construction: duplicate function NodeID %d", graph.NodeID)) @@ -43,7 +61,7 @@ func BuildModule(source *ast.Module, matchCases MatchCaseQuery) *Module { return module } -func buildFunction(source *ast.FnDecl, matchCases MatchCaseQuery) *Graph { +func buildFunction(source *ast.FnDecl, queries BuildQueries) *Graph { name := "" if source.Name != nil { name = source.Name.Name @@ -56,7 +74,7 @@ func buildFunction(source *ast.FnDecl, matchCases MatchCaseQuery) *Graph { ReturnsValue: source.ReturnType != nil, Blocks: make([]*Block, 0), } - b := &builder{fn: fn, matchCases: matchCases} + b := &builder{fn: fn, queries: queries} fn.Entry = b.newBlock(BlockNormal, ast.LocOf(source.Body)) fn.Exit = b.newBlock(BlockNormal, ast.LocOf(source)) next := b.buildBlock(source.Body, fn.Entry) @@ -77,6 +95,9 @@ func (b *builder) buildBlock(block *ast.BlockStmt, current *Block) *Block { if b == nil || block == nil { return current } + b.scopes = append(b.scopes, block) + defer func() { b.scopes = b.scopes[:len(b.scopes)-1] }() + next := current scopeID := ir.NodeID(block.ID()) for _, stmt := range block.Stmts { @@ -115,6 +136,19 @@ func (b *builder) buildStmt(stmt ast.Stmt, current *Block, scopeID ir.NodeID) *B current.Sites = append(current.Sites, statementSite(node, scopeID)) current.Terminator = &Return{NodeID: ir.NodeID(node.ID())} return nil + case *ast.BreakStmt, *ast.ContinueStmt: + current.Sites = append(current.Sites, statementSite(node, scopeID)) + if len(b.loops) == 0 { + return current + } + loop := b.loops[len(b.loops)-1] + b.appendLoopScopeExits(current, loop.scopeDepth) + if _, ok := node.(*ast.BreakStmt); ok { + current.Terminator = &Jump{Target: loop.breakTarget} + } else { + current.Terminator = &Jump{Target: loop.continueTarget} + } + return nil case *ast.IfStmt: thenBlock := b.newBlock(BlockThen, ast.LocOf(node)) elseBlock := b.newBlock(BlockElse, ast.LocOf(node)) @@ -149,37 +183,60 @@ func (b *builder) buildStmt(stmt ast.Stmt, current *Block, scopeID ir.NodeID) *B } return join case *ast.ForStmt: - if node.Cond == nil { - bodyBlock := b.newBlock(BlockLoopBody, ast.LocOf(node)) - current.Terminator = &Jump{Target: bodyBlock} - bodyEnd := b.buildBlock(node.Body, bodyBlock) - if bodyEnd != nil && bodyEnd.Terminator == nil { - bodyEnd.Terminator = &Jump{Target: bodyBlock} - } - return nil - } - header := b.newBlock(BlockLoop, ast.LocOf(node)) + loopID := ir.NodeID(node.ID()) + init := b.newBlock(BlockLoopInit, ast.LocOf(node)) bodyBlock := b.newBlock(BlockLoopBody, ast.LocOf(node)) + latch := b.newBlock(BlockLoopLatch, ast.LocOf(node)) exit := b.newBlock(BlockNormal, ast.LocOf(node)) - current.Terminator = &Jump{Target: header} - header.Terminator = &Branch{ - NodeID: ir.NodeID(node.ID()), - ConditionID: ir.NodeID(node.Cond.ID()), - ScopeID: scopeID, - Location: ast.LocOf(node), - TrueTarget: bodyBlock, - FalseTarget: exit, + init.NodeID = loopID + bodyBlock.NodeID = loopID + latch.NodeID = loopID + exit.NodeID = loopID + current.Terminator = &Jump{Target: init} + + latchTarget := bodyBlock + if node.Cond != nil || node.Iterable != nil { + header := b.newBlock(BlockLoop, ast.LocOf(node)) + header.NodeID = loopID + conditionID := ir.NodeID(0) + if node.Cond != nil { + conditionID = ir.NodeID(node.Cond.ID()) + } + initTarget := header + if b.queries.LoopGuaranteedEntry != nil && b.queries.LoopGuaranteedEntry(node.ID()) { + initTarget = bodyBlock + } + init.Terminator = &Jump{Target: initTarget} + header.Terminator = &Branch{ + NodeID: loopID, + ConditionID: conditionID, + ScopeID: scopeID, + Location: ast.LocOf(node), + TrueTarget: bodyBlock, + FalseTarget: exit, + } + latchTarget = header + } else { + init.Terminator = &Jump{Target: bodyBlock} } + + b.loops = append(b.loops, loopContext{ + continueTarget: latch, + breakTarget: exit, + scopeDepth: len(b.scopes), + }) bodyEnd := b.buildBlock(node.Body, bodyBlock) + b.loops = b.loops[:len(b.loops)-1] if bodyEnd != nil && bodyEnd.Terminator == nil { - bodyEnd.Terminator = &Jump{Target: header} + bodyEnd.Terminator = &Jump{Target: latch} } + latch.Terminator = &Jump{Target: latchTarget} return exit case *ast.MatchStmt: var cases []int found := false - if b.matchCases != nil { - cases, found = b.matchCases(node.ID()) + if b.queries.MatchCases != nil { + cases, found = b.queries.MatchCases(node.ID()) } if !found || len(cases) != len(node.Arms) { // Invalid source may not have complete semantic evidence. Preserve one @@ -218,6 +275,21 @@ func (b *builder) buildStmt(stmt ast.Stmt, current *Block, scopeID ir.NodeID) *B } } +func (b *builder) appendLoopScopeExits(current *Block, scopeDepth int) { + for index := len(b.scopes) - 1; index >= scopeDepth; index-- { + scope := b.scopes[index] + if scope.ID() == 0 { + continue + } + current.Sites = append(current.Sites, &Site{ + Kind: SiteScopeExit, + NodeID: ir.NodeID(scope.ID()), + ScopeID: ir.NodeID(scope.ID()), + Location: ast.LocOf(scope), + }) + } +} + func statementSite(node ast.Node, scopeID ir.NodeID) *Site { return &Site{ Kind: SiteStatement, diff --git a/internal/ir/cfg/cfg_test.go b/internal/ir/cfg/cfg_test.go index 866fdc12..79620db1 100644 --- a/internal/ir/cfg/cfg_test.go +++ b/internal/ir/cfg/cfg_test.go @@ -24,7 +24,7 @@ func testModule(body *ast.BlockStmt, returnType ast.TypeExpr) *ast.Module { func TestModuleIndexesFunctionBySourceIdentity(t *testing.T) { body := &ast.BlockStmt{NodeIDHolder: ast.NodeIDHolder{NodeID: 10}} - module := BuildModule(testModule(body, nil), nil) + module := BuildModule(testModule(body, nil), BuildQueries{}) if len(module.Functions) != 1 || module.Function(ir.NodeID(1)) != module.Functions[0] { t.Fatalf("CFG function index = %#v, want source NodeID lookup", module) } @@ -36,7 +36,7 @@ func TestModuleIndexesFunctionBySourceIdentity(t *testing.T) { func TestBuildModulePreservesLexicalScopeExits(t *testing.T) { nested := &ast.BlockStmt{NodeIDHolder: ast.NodeIDHolder{NodeID: 20}} body := &ast.BlockStmt{NodeIDHolder: ast.NodeIDHolder{NodeID: 10}, Stmts: []ast.Stmt{nested}} - graph := BuildModule(testModule(body, nil), nil).Functions[0] + graph := BuildModule(testModule(body, nil), BuildQueries{}).Functions[0] if graph.Entry == nil || len(graph.Entry.Sites) != 1 || graph.Entry.Sites[0].Kind != SiteScopeExit || graph.Entry.Sites[0].NodeID != 20 { t.Fatalf("entry sites = %#v, want nested scope exit", graph.Entry.Sites) } @@ -59,7 +59,7 @@ func TestBuildModulePreservesTerminatorSourceIdentity(t *testing.T) { } ret := &ast.ReturnStmt{NodeIDHolder: ast.NodeIDHolder{NodeID: 40}, Location: location} body := &ast.BlockStmt{NodeIDHolder: ast.NodeIDHolder{NodeID: 10}, Stmts: []ast.Stmt{branch, ret}} - graph := BuildModule(testModule(body, nil), nil).Functions[0] + graph := BuildModule(testModule(body, nil), BuildQueries{}).Functions[0] branchTerm, ok := graph.Entry.Terminator.(*Branch) if !ok || branchTerm.NodeID != 30 || branchTerm.ConditionID != 31 { t.Fatalf("branch = %#v, want source nodes 30 and 31", graph.Entry.Terminator) @@ -83,7 +83,7 @@ func TestBuildModuleCreatesCanonicalSiteAdjacency(t *testing.T) { Else: &ast.BlockStmt{NodeIDHolder: ast.NodeIDHolder{NodeID: 33}}, } body := &ast.BlockStmt{NodeIDHolder: ast.NodeIDHolder{NodeID: 10}, Stmts: []ast.Stmt{branch}} - graph := BuildModule(testModule(body, nil), nil).Functions[0] + graph := BuildModule(testModule(body, nil), BuildQueries{}).Functions[0] if len(graph.Entry.Sites) != 1 { t.Fatalf("entry sites = %#v, want one branch site", graph.Entry.Sites) } @@ -137,12 +137,12 @@ func TestBuildModuleCreatesSemanticVariantSwitchAndSharedJoin(t *testing.T) { } after := &ast.ExprStmt{NodeIDHolder: ast.NodeIDHolder{NodeID: 40}, Expr: &ast.NumberLit{NodeIDHolder: ast.NodeIDHolder{NodeID: 41}, Value: "1"}} body := &ast.BlockStmt{NodeIDHolder: ast.NodeIDHolder{NodeID: 10}, Stmts: []ast.Stmt{match, after}} - graph := BuildModule(testModule(body, nil), func(matchID ast.NodeID) ([]int, bool) { + graph := BuildModule(testModule(body, nil), BuildQueries{MatchCases: func(matchID ast.NodeID) ([]int, bool) { if matchID != 30 { t.Fatalf("match evidence query = %d, want 30", matchID) } return []int{1, 0}, true - }).Functions[0] + }}).Functions[0] switchTerm, ok := graph.Entry.Terminator.(*SwitchVariant) if !ok || switchTerm.NodeID != 30 || len(switchTerm.Targets) != 2 { t.Fatalf("match terminator = %#v", graph.Entry.Terminator) @@ -171,7 +171,7 @@ func TestBuildModulePreservesDisconnectedStatementsAfterReturn(t *testing.T) { &ast.ReturnStmt{NodeIDHolder: ast.NodeIDHolder{NodeID: 40}, Location: location}, &ast.ExprStmt{NodeIDHolder: ast.NodeIDHolder{NodeID: 41}, Expr: &ast.NumberLit{Value: "1"}, Location: location}, }} - module := BuildModule(testModule(body, nil), nil) + module := BuildModule(testModule(body, nil), BuildQueries{}) graph := module.Functions[0] found := false for _, block := range graph.Blocks { @@ -191,9 +191,256 @@ func TestBuildModulePreservesDisconnectedStatementsAfterReturn(t *testing.T) { } } +func TestBuildModuleCreatesForInLoopBlocksWithSynthesizedCondition(t *testing.T) { + loop := &ast.ForStmt{ + NodeIDHolder: ast.NodeIDHolder{NodeID: 30}, + Iterable: &ast.Ident{NodeIDHolder: ast.NodeIDHolder{NodeID: 31}, Name: "items"}, + Body: &ast.BlockStmt{NodeIDHolder: ast.NodeIDHolder{NodeID: 32}}, + } + body := &ast.BlockStmt{NodeIDHolder: ast.NodeIDHolder{NodeID: 10}, Stmts: []ast.Stmt{loop}} + graph := BuildModule(testModule(body, nil), BuildQueries{}).Functions[0] + init := loopBlock(t, graph, 30, BlockLoopInit) + header := loopBlock(t, graph, 30, BlockLoop) + loopBody := loopBlock(t, graph, 30, BlockLoopBody) + latch := loopBlock(t, graph, 30, BlockLoopLatch) + exit := loopBlock(t, graph, 30, BlockNormal) + + entryJump, ok := graph.Entry.Terminator.(*Jump) + if !ok || entryJump.Target != init { + t.Fatalf("entry terminator = %#v, want loop init", graph.Entry.Terminator) + } + initJump, ok := init.Terminator.(*Jump) + if !ok || initJump.Target != header { + t.Fatalf("init terminator = %#v, want loop header", init.Terminator) + } + branch, ok := header.Terminator.(*Branch) + if !ok || branch.NodeID != 30 || branch.ConditionID != 0 || branch.TrueTarget != loopBody || branch.FalseTarget != exit { + t.Fatalf("for-in header = %#v, want synthesized branch", header.Terminator) + } + latchJump, ok := latch.Terminator.(*Jump) + if !ok || latchJump.Target != header { + t.Fatalf("latch terminator = %#v, want loop header", latch.Terminator) + } +} + +func TestBuildModuleUsesGuaranteedLoopEntryEvidence(t *testing.T) { + loop := &ast.ForStmt{ + NodeIDHolder: ast.NodeIDHolder{NodeID: 30}, + Iterable: &ast.Ident{NodeIDHolder: ast.NodeIDHolder{NodeID: 31}, Name: "items"}, + Body: &ast.BlockStmt{NodeIDHolder: ast.NodeIDHolder{NodeID: 32}}, + } + body := &ast.BlockStmt{NodeIDHolder: ast.NodeIDHolder{NodeID: 10}, Stmts: []ast.Stmt{loop}} + for _, test := range []struct { + name string + guaranteed bool + wantOrigin BlockOrigin + }{ + {name: "maybe empty", wantOrigin: BlockLoop}, + {name: "guaranteed entry", guaranteed: true, wantOrigin: BlockLoopBody}, + } { + t.Run(test.name, func(t *testing.T) { + graph := BuildModule(testModule(body, nil), BuildQueries{ + LoopGuaranteedEntry: func(loopID ast.NodeID) bool { + if loopID != 30 { + t.Fatalf("loop evidence query = %d, want 30", loopID) + } + return test.guaranteed + }, + }).Functions[0] + init := loopBlock(t, graph, 30, BlockLoopInit) + header := loopBlock(t, graph, 30, BlockLoop) + loopBody := loopBlock(t, graph, 30, BlockLoopBody) + latch := loopBlock(t, graph, 30, BlockLoopLatch) + initJump, ok := init.Terminator.(*Jump) + if !ok || initJump.Target.Origin != test.wantOrigin { + t.Fatalf("init terminator = %#v, want target origin %d", init.Terminator, test.wantOrigin) + } + if test.guaranteed && initJump.Target != loopBody { + t.Fatalf("init target = %#v, want loop body", initJump.Target) + } + latchJump, ok := latch.Terminator.(*Jump) + if !ok || latchJump.Target != header { + t.Fatalf("latch terminator = %#v, want loop header", latch.Terminator) + } + }) + } +} + +func TestBuildModuleContinueTargetsLatchAndPreservesUnreachableBody(t *testing.T) { + loop := &ast.ForStmt{ + NodeIDHolder: ast.NodeIDHolder{NodeID: 30}, + Cond: &ast.BoolLit{NodeIDHolder: ast.NodeIDHolder{NodeID: 31}, Value: true}, + Body: &ast.BlockStmt{NodeIDHolder: ast.NodeIDHolder{NodeID: 32}, Stmts: []ast.Stmt{ + &ast.ContinueStmt{NodeIDHolder: ast.NodeIDHolder{NodeID: 40}}, + &ast.ExprStmt{NodeIDHolder: ast.NodeIDHolder{NodeID: 41}, Expr: &ast.NumberLit{Value: "1"}}, + }}, + } + body := &ast.BlockStmt{NodeIDHolder: ast.NodeIDHolder{NodeID: 10}, Stmts: []ast.Stmt{loop}} + graph := BuildModule(testModule(body, nil), BuildQueries{}).Functions[0] + latch := loopBlock(t, graph, 30, BlockLoopLatch) + header := loopBlock(t, graph, 30, BlockLoop) + foundContinue := false + foundUnreachable := false + for _, block := range graph.Blocks { + for _, site := range block.Sites { + switch site.NodeID { + case 40: + jump, ok := block.Terminator.(*Jump) + if !ok || jump.Target != latch { + t.Fatalf("continue terminator = %#v, want latch", block.Terminator) + } + foundContinue = true + case 41: + foundUnreachable = !block.Reachable + } + } + } + latchJump, ok := latch.Terminator.(*Jump) + if !ok || latchJump.Target != header { + t.Fatalf("latch terminator = %#v, want condition header", latch.Terminator) + } + if !foundContinue || !foundUnreachable { + t.Fatalf("continue=%v unreachable-following-statement=%v", foundContinue, foundUnreachable) + } +} + +func TestBuildModuleNestedLoopJumpsUseInnermostTargets(t *testing.T) { + inner := &ast.ForStmt{ + NodeIDHolder: ast.NodeIDHolder{NodeID: 40}, + Cond: &ast.BoolLit{NodeIDHolder: ast.NodeIDHolder{NodeID: 41}, Value: true}, + Body: &ast.BlockStmt{NodeIDHolder: ast.NodeIDHolder{NodeID: 42}, Stmts: []ast.Stmt{ + &ast.IfStmt{ + NodeIDHolder: ast.NodeIDHolder{NodeID: 43}, + Cond: &ast.BoolLit{NodeIDHolder: ast.NodeIDHolder{NodeID: 44}, Value: true}, + Then: &ast.BlockStmt{NodeIDHolder: ast.NodeIDHolder{NodeID: 45}, Stmts: []ast.Stmt{ + &ast.ContinueStmt{NodeIDHolder: ast.NodeIDHolder{NodeID: 52}}, + }}, + }, + &ast.BreakStmt{NodeIDHolder: ast.NodeIDHolder{NodeID: 50}}, + }}, + } + outer := &ast.ForStmt{ + NodeIDHolder: ast.NodeIDHolder{NodeID: 30}, + Cond: &ast.BoolLit{NodeIDHolder: ast.NodeIDHolder{NodeID: 31}, Value: true}, + Body: &ast.BlockStmt{NodeIDHolder: ast.NodeIDHolder{NodeID: 32}, Stmts: []ast.Stmt{ + inner, + &ast.ContinueStmt{NodeIDHolder: ast.NodeIDHolder{NodeID: 51}}, + }}, + } + body := &ast.BlockStmt{NodeIDHolder: ast.NodeIDHolder{NodeID: 10}, Stmts: []ast.Stmt{outer}} + graph := BuildModule(testModule(body, nil), BuildQueries{}).Functions[0] + innerExit := loopBlock(t, graph, 40, BlockNormal) + innerLatch := loopBlock(t, graph, 40, BlockLoopLatch) + outerLatch := loopBlock(t, graph, 30, BlockLoopLatch) + foundBreak := false + foundInnerContinue := false + foundOuterContinue := false + for _, block := range graph.Blocks { + for _, site := range block.Sites { + jump, ok := block.Terminator.(*Jump) + if !ok { + continue + } + if site.NodeID == 50 { + foundBreak = jump.Target == innerExit + } + if site.NodeID == 51 { + foundOuterContinue = jump.Target == outerLatch + } + if site.NodeID == 52 { + foundInnerContinue = jump.Target == innerLatch + } + } + } + if !foundBreak || !foundInnerContinue || !foundOuterContinue { + t.Fatalf("nested targets: inner break=%v inner continue=%v outer continue=%v", foundBreak, foundInnerContinue, foundOuterContinue) + } +} + +func TestBuildModuleLoopJumpExitsOnlyLoopScopesInnermostFirst(t *testing.T) { + nested := &ast.BlockStmt{NodeIDHolder: ast.NodeIDHolder{NodeID: 33}, Stmts: []ast.Stmt{ + &ast.BreakStmt{NodeIDHolder: ast.NodeIDHolder{NodeID: 40}}, + }} + loop := &ast.ForStmt{ + NodeIDHolder: ast.NodeIDHolder{NodeID: 30}, + Cond: &ast.BoolLit{NodeIDHolder: ast.NodeIDHolder{NodeID: 31}, Value: true}, + Body: &ast.BlockStmt{NodeIDHolder: ast.NodeIDHolder{NodeID: 32}, Stmts: []ast.Stmt{nested}}, + } + body := &ast.BlockStmt{NodeIDHolder: ast.NodeIDHolder{NodeID: 10}, Stmts: []ast.Stmt{loop}} + graph := BuildModule(testModule(body, nil), BuildQueries{}).Functions[0] + for _, block := range graph.Blocks { + if len(block.Sites) < 3 || block.Sites[0].NodeID != 40 { + continue + } + if block.Sites[1].Kind != SiteScopeExit || block.Sites[1].NodeID != 33 || + block.Sites[2].Kind != SiteScopeExit || block.Sites[2].NodeID != 32 { + t.Fatalf("break sites = %#v, want scope exits [33, 32]", block.Sites) + } + for _, site := range block.Sites[1:] { + if site.Kind == SiteScopeExit && site.NodeID == 10 { + t.Fatalf("break sites = %#v, must retain enclosing scope 10", block.Sites) + } + } + return + } + t.Fatal("break scope-exit sites missing") +} + +func TestBuildModuleRecoversLoopJumpsOutsideLoop(t *testing.T) { + body := &ast.BlockStmt{NodeIDHolder: ast.NodeIDHolder{NodeID: 10}, Stmts: []ast.Stmt{ + &ast.BreakStmt{NodeIDHolder: ast.NodeIDHolder{NodeID: 20}}, + &ast.ExprStmt{NodeIDHolder: ast.NodeIDHolder{NodeID: 21}, Expr: &ast.NumberLit{Value: "1"}}, + &ast.ContinueStmt{NodeIDHolder: ast.NodeIDHolder{NodeID: 22}}, + &ast.ExprStmt{NodeIDHolder: ast.NodeIDHolder{NodeID: 23}, Expr: &ast.NumberLit{Value: "2"}}, + }} + graph := BuildModule(testModule(body, nil), BuildQueries{}).Functions[0] + want := []ir.NodeID{20, 21, 22, 23} + if len(graph.Entry.Sites) < len(want) { + t.Fatalf("entry sites = %#v, want recovered statements", graph.Entry.Sites) + } + for index, nodeID := range want { + if graph.Entry.Sites[index].Kind != SiteStatement || graph.Entry.Sites[index].NodeID != nodeID { + t.Fatalf("entry site %d = %#v, want statement %d", index, graph.Entry.Sites[index], nodeID) + } + } +} + +func TestBuildModuleInfiniteLoopBreakMakesExitReachable(t *testing.T) { + loop := &ast.ForStmt{ + NodeIDHolder: ast.NodeIDHolder{NodeID: 30}, + Body: &ast.BlockStmt{NodeIDHolder: ast.NodeIDHolder{NodeID: 32}, Stmts: []ast.Stmt{ + &ast.BreakStmt{NodeIDHolder: ast.NodeIDHolder{NodeID: 40}}, + }}, + } + after := &ast.ExprStmt{NodeIDHolder: ast.NodeIDHolder{NodeID: 50}, Expr: &ast.NumberLit{Value: "1"}} + body := &ast.BlockStmt{NodeIDHolder: ast.NodeIDHolder{NodeID: 10}, Stmts: []ast.Stmt{loop, after}} + graph := BuildModule(testModule(body, nil), BuildQueries{}).Functions[0] + init := loopBlock(t, graph, 30, BlockLoopInit) + loopBody := loopBlock(t, graph, 30, BlockLoopBody) + latch := loopBlock(t, graph, 30, BlockLoopLatch) + exit := loopBlock(t, graph, 30, BlockNormal) + initJump, initOK := init.Terminator.(*Jump) + latchJump, latchOK := latch.Terminator.(*Jump) + if !initOK || initJump.Target != loopBody || !latchOK || latchJump.Target != loopBody { + t.Fatalf("infinite loop topology: init=%#v latch=%#v, want body", init.Terminator, latch.Terminator) + } + if !exit.Reachable { + t.Fatal("infinite-loop exit unreachable despite break") + } + foundAfter := false + for _, site := range exit.Sites { + if site.Kind == SiteStatement && site.NodeID == 50 { + foundAfter = true + } + } + if !foundAfter { + t.Fatalf("loop exit sites = %#v, want post-loop statement", exit.Sites) + } +} + func TestAnalyzeDoesNotRebuildFinalizedTopology(t *testing.T) { body := &ast.BlockStmt{NodeIDHolder: ast.NodeIDHolder{NodeID: 10}} - module := BuildModule(testModule(body, nil), nil) + module := BuildModule(testModule(body, nil), BuildQueries{}) graph := module.Functions[0] before := append([]*Block(nil), graph.Entry.Predecessors...) graph.Entry.Sites = nil @@ -207,7 +454,7 @@ func TestAnalyzeReportsMissingReturn(t *testing.T) { body := &ast.BlockStmt{NodeIDHolder: ast.NodeIDHolder{NodeID: 10}} returnType := &ast.NamedType{NodeIDHolder: ast.NodeIDHolder{NodeID: 11}, Name: "i32"} diag := diagnostics.NewDiagnosticBag() - Analyze(BuildModule(testModule(body, returnType), nil), diag, nil) + Analyze(BuildModule(testModule(body, returnType), BuildQueries{}), diag, nil) if !hasDiagnosticCode(diag, diagnostics.ErrMissingReturn) { t.Fatalf("diagnostics = %#v, want missing return", diag.Diagnostics()) } @@ -222,7 +469,7 @@ func TestAnalyzeReportsConstantIfCondition(t *testing.T) { Location: location, }}} diag := diagnostics.NewDiagnosticBag() - Analyze(BuildModule(testModule(body, nil), nil), diag, func(conditionID, scopeID ir.NodeID) (bool, bool) { + Analyze(BuildModule(testModule(body, nil), BuildQueries{}), diag, func(conditionID, scopeID ir.NodeID) (bool, bool) { if conditionID != 31 || scopeID != 10 { t.Fatalf("constant condition query = (%d, %d), want (31, 10)", conditionID, scopeID) } @@ -243,7 +490,7 @@ func TestAnalyzeDoesNotReportConstantLoopCondition(t *testing.T) { }}} diag := diagnostics.NewDiagnosticBag() queries := 0 - Analyze(BuildModule(testModule(body, nil), nil), diag, func(ir.NodeID, ir.NodeID) (bool, bool) { + Analyze(BuildModule(testModule(body, nil), BuildQueries{}), diag, func(ir.NodeID, ir.NodeID) (bool, bool) { queries++ return false, true }) @@ -252,6 +499,24 @@ func TestAnalyzeDoesNotReportConstantLoopCondition(t *testing.T) { } } +func loopBlock(t *testing.T, graph *Graph, loopID ir.NodeID, origin BlockOrigin) *Block { + t.Helper() + var found *Block + for _, block := range graph.Blocks { + if block.NodeID != loopID || block.Origin != origin { + continue + } + if found != nil { + t.Fatalf("multiple loop blocks for NodeID %d and origin %d", loopID, origin) + } + found = block + } + if found == nil { + t.Fatalf("loop block missing for NodeID %d and origin %d", loopID, origin) + } + return found +} + func hasDiagnosticCode(diag *diagnostics.DiagnosticBag, code string) bool { for _, item := range diag.Diagnostics() { if item != nil && item.Code == code { diff --git a/internal/ir/cfg/model.go b/internal/ir/cfg/model.go index b86fc6bb..06bdea86 100644 --- a/internal/ir/cfg/model.go +++ b/internal/ir/cfg/model.go @@ -80,12 +80,15 @@ const ( BlockNormal BlockOrigin = iota BlockThen BlockElse + BlockLoopInit BlockLoop BlockLoopBody + BlockLoopLatch ) type Block struct { ID int + NodeID ir.NodeID Origin BlockOrigin Location *source.Location Sites []*Site diff --git a/internal/ir/hir/fold/fold.go b/internal/ir/hir/fold/fold.go index 40181780..8cb234ca 100644 --- a/internal/ir/hir/fold/fold.go +++ b/internal/ir/hir/fold/fold.go @@ -96,7 +96,15 @@ func foldStmt(types *ir.TypeTable, stmt hir.Stmt, env map[string]constvalue.Valu if node.Cond != nil { cond = ir.FoldExpr(types, node.Cond, env) } - return []hir.Stmt{&hir.For{Cond: cond, Body: foldBlock(types, node.Body, cloneConstEnv(env)), NodeID: node.NodeID, Location: node.Location}} + return []hir.Stmt{&hir.For{ + Init: foldBlock(types, node.Init, cloneConstEnv(env)), + Cond: cond, + Bindings: foldBlock(types, node.Bindings, cloneConstEnv(env)), + Body: foldBlock(types, node.Body, cloneConstEnv(env)), + Next: foldBlock(types, node.Next, cloneConstEnv(env)), + NodeID: node.NodeID, + Location: node.Location, + }} case *hir.SwitchVariant: cases := make([]hir.VariantCaseBlock, len(node.Cases)) for index, variantCase := range node.Cases { diff --git a/internal/ir/hir/fold/fold_test.go b/internal/ir/hir/fold/fold_test.go index 7af353ee..cfbadc36 100644 --- a/internal/ir/hir/fold/fold_test.go +++ b/internal/ir/hir/fold/fold_test.go @@ -34,6 +34,41 @@ func TestApplyTypedExpressionFoldingPreservesConstantBranches(t *testing.T) { } } +func TestApplyTypedExpressionFoldingFoldsAllForSegments(t *testing.T) { + types := ir.NewTypeTable() + i32 := types.Intern(ir.Type{Kind: ir.TypeInteger, Signed: true, Bits: 32}) + add := func(left, right string) ir.Expr { + return &ir.Binary{Op: "+", Left: &ir.IntLit{Value: left, Type: i32}, Right: &ir.IntLit{Value: right, Type: i32}, Type: i32} + } + loop := &hir.For{ + Init: &hir.Block{NodeID: 2, Stmts: []hir.Stmt{&hir.Binding{Value: add("1", "1")}}}, + Cond: &ir.Binary{Op: "<", Left: add("1", "1"), Right: &ir.IntLit{Value: "3", Type: i32}, Type: types.Intern(ir.Type{Kind: ir.TypeBool})}, + Bindings: &hir.Block{NodeID: 3, Stmts: []hir.Stmt{&hir.Binding{Value: add("2", "2")}}}, + Body: &hir.Block{NodeID: 4, Stmts: []hir.Stmt{&hir.ExprStmt{Value: add("3", "3")}}}, + Next: &hir.Block{NodeID: 5, Stmts: []hir.Stmt{&hir.Assign{Target: &ir.Place{Root: &ir.Ident{Name: "cursor", Type: i32}, Type: i32}, Value: add("4", "4")}}}, + NodeID: 1, + } + mod := &hir.Module{Types: types, Funcs: []*hir.Function{{Name: "main", Body: &hir.Block{Stmts: []hir.Stmt{loop}}}}} + + out := ApplyTypedExpressionFolding(mod) + folded := out.Funcs[0].Body.Stmts[0].(*hir.For) + values := []ir.Expr{ + folded.Init.Stmts[0].(*hir.Binding).Value, + folded.Bindings.Stmts[0].(*hir.Binding).Value, + folded.Body.Stmts[0].(*hir.ExprStmt).Value, + folded.Next.Stmts[0].(*hir.Assign).Value, + } + for index, value := range values { + literal, ok := value.(*ir.IntLit) + if !ok || literal.Value != []string{"2", "4", "6", "8"}[index] { + t.Fatalf("folded segment %d = %#v", index, value) + } + } + if folded.Init.NodeID != 2 || folded.Bindings.NodeID != 3 || folded.Body.NodeID != 4 || folded.Next.NodeID != 5 { + t.Fatalf("folded loop segment identities = %#v", folded) + } +} + func TestApplyTypedExpressionFoldingPreservesStatementsAfterReturn(t *testing.T) { types := ir.NewTypeTable() i32 := types.Intern(ir.Type{Kind: ir.TypeInteger, Signed: true, Bits: 32}) diff --git a/internal/ir/hir/lower/module_lower.go b/internal/ir/hir/lower/module_lower.go index 6e339e36..4df53715 100644 --- a/internal/ir/hir/lower/module_lower.go +++ b/internal/ir/hir/lower/module_lower.go @@ -235,18 +235,7 @@ func appendStmt(module *project.Module, scope *symbols.Scope, out *hir.Block, st } out.Stmts = append(out.Stmts, ifStmt) case *ast.ForStmt: - var condExpr ir.Expr - if node.Cond != nil { - condExpr = lowerASTExpr(ctx, module, scope, node.Cond, &typeinfo.BoolType{}) - } - loop := &hir.For{ - Cond: condExpr, - Body: &hir.Block{Stmts: make([]hir.Stmt, 0), NodeID: hir.NodeID(node.Body.ID()), Location: ast.LocOf(node.Body)}, - NodeID: hir.NodeID(node.ID()), - Location: ast.LocOf(node), - } - appendBlock(module, scope, loop.Body, node.Body, returnType, ctx) - out.Stmts = append(out.Stmts, loop) + out.Stmts = append(out.Stmts, lowerForStmt(ctx, module, scope, node, returnType)) case *ast.MatchStmt: evidence, found := module.Semantics.Matches[node.ID()] if !found || len(evidence.Arms) != len(node.Arms) { @@ -309,6 +298,8 @@ func appendStmt(module *project.Module, scope *symbols.Scope, out *hir.Block, st targetType := exprResolvedType(module, node.Target) valueExpr := lowerASTExpr(ctx, module, scope, node.Value, targetType) out.Stmts = append(out.Stmts, &hir.Assign{Target: targetExpr, Value: valueExpr, NodeID: hir.NodeID(node.ID()), Location: ast.LocOf(node)}) + case *ast.BreakStmt, *ast.ContinueStmt: + // CFG owns loop transfer; no executable HIR statement is needed. case *ast.BadStmt, *ast.BadDecl, *ast.ImportDecl, *ast.FnDecl, *ast.TypeAliasDecl, *ast.StructDecl, *ast.InterfaceDecl, *ast.EnumDecl: out.Stmts = append(out.Stmts, &hir.Invalid{Message: "unsupported statement", NodeID: hir.NodeID(node.ID()), Location: ast.LocOf(node)}) @@ -317,6 +308,123 @@ func appendStmt(module *project.Module, scope *symbols.Scope, out *hir.Block, st } } +func lowerForStmt(ctx *project.CompilerContext, module *project.Module, scope *symbols.Scope, node *ast.ForStmt, returnType typeinfo.Type) hir.Stmt { + location := ast.LocOf(node) + loop := &hir.For{ + Body: &hir.Block{Stmts: make([]hir.Stmt, 0), NodeID: hir.NodeID(node.Body.ID()), Location: ast.LocOf(node.Body)}, + NodeID: hir.NodeID(node.ID()), + Location: location, + } + appendBlock(module, scope, loop.Body, node.Body, returnType, ctx) + if node.Iterable == nil { + if node.Cond != nil { + loop.Cond = lowerASTExpr(ctx, module, scope, node.Cond, &typeinfo.BoolType{}) + } + return loop + } + + evidence, found := module.Semantics.ForIterations[node.ID()] + if !found || evidence.Cursor == nil || evidence.Value == nil { + return &hir.Invalid{Message: "for-in statement missing semantic evidence", NodeID: hir.NodeID(node.ID()), Location: location} + } + loop.Init = &hir.Block{Stmts: make([]hir.Stmt, 0), Location: location} + loop.Bindings = &hir.Block{Stmts: make([]hir.Stmt, 0), Location: location} + loop.Next = &hir.Block{Stmts: make([]hir.Stmt, 0), Location: location} + boolType := loweredTypeID(ctx, module, &typeinfo.BoolType{}) + + switch evidence.Kind { + case project.ForIterationRange: + rangeExpr, ok := node.Iterable.(*ast.RangeExpr) + if !ok || rangeExpr.Start == nil || rangeExpr.End == nil || evidence.End == nil { + return &hir.Invalid{Message: "range iteration evidence does not match syntax", NodeID: hir.NodeID(node.ID()), Location: location} + } + loop.Init.Stmts = append(loop.Init.Stmts, + generatedBinding(ctx, module, evidence.Cursor, lowerASTExpr(ctx, module, scope, rangeExpr.Start, evidence.ElementType), location), + generatedBinding(ctx, module, evidence.End, lowerASTExpr(ctx, module, scope, rangeExpr.End, evidence.ElementType), location), + ) + if evidence.Ordinal != nil { + ordinalType := loweredTypeID(ctx, module, evidence.Ordinal.Type) + loop.Init.Stmts = append(loop.Init.Stmts, generatedBinding(ctx, module, evidence.Ordinal, + &ir.IntLit{Value: "0", Type: ordinalType, SourceInfo: ir.SourceInfo{Location: location}}, location)) + } + loop.Cond = &ir.Binary{ + Op: "<", Left: generatedIdent(ctx, module, evidence.Cursor, location), Right: generatedIdent(ctx, module, evidence.End, location), Type: boolType, + SourceInfo: ir.SourceInfo{Location: location}, + } + if evidence.Index != nil { + loop.Bindings.Stmts = append(loop.Bindings.Stmts, + generatedBinding(ctx, module, evidence.Index, generatedIdent(ctx, module, evidence.Ordinal, location), location)) + } + loop.Bindings.Stmts = append(loop.Bindings.Stmts, + generatedBinding(ctx, module, evidence.Value, generatedIdent(ctx, module, evidence.Cursor, location), location)) + loop.Next.Stmts = append(loop.Next.Stmts, incrementSymbol(ctx, module, evidence.Cursor, location)) + if evidence.Ordinal != nil { + loop.Next.Stmts = append(loop.Next.Stmts, incrementSymbol(ctx, module, evidence.Ordinal, location)) + } + case project.ForIterationSequence: + if evidence.Carrier == nil { + return &hir.Invalid{Message: "sequence iteration missing carrier evidence", NodeID: hir.NodeID(node.ID()), Location: location} + } + carrier := generatedIdent(ctx, module, evidence.Carrier, location) + cursor := generatedIdent(ctx, module, evidence.Cursor, location) + cursorType := loweredTypeID(ctx, module, evidence.Cursor.Type) + elementType := loweredTypeID(ctx, module, evidence.ElementType) + loop.Init.Stmts = append(loop.Init.Stmts, + generatedBinding(ctx, module, evidence.Carrier, + lowerImplicitReferenceValue(ctx, module, scope, node.Iterable, evidence.CarrierType), location), + generatedBinding(ctx, module, evidence.Cursor, + &ir.IntLit{Value: "0", Type: cursorType, SourceInfo: ir.SourceInfo{Location: location}}, location), + ) + loop.Cond = &ir.Binary{ + Op: "<", Left: cursor, + Right: &ir.Len{Value: carrier, Type: cursorType, SourceInfo: ir.SourceInfo{Location: location}}, + Type: boolType, SourceInfo: ir.SourceInfo{Location: location}, + } + if evidence.Index != nil { + loop.Bindings.Stmts = append(loop.Bindings.Stmts, + generatedBinding(ctx, module, evidence.Index, generatedIdent(ctx, module, evidence.Cursor, location), location)) + } + loop.Bindings.Stmts = append(loop.Bindings.Stmts, + generatedBinding(ctx, module, evidence.Value, &ir.Load{Place: &ir.Place{ + Root: generatedIdent(ctx, module, evidence.Carrier, location), + Projections: []ir.PlaceProjection{{ + Kind: ir.PlaceProjectionIndex, Index: generatedIdent(ctx, module, evidence.Cursor, location), Type: elementType, Location: location, + }}, + Type: elementType, Location: location, + }, SourceInfo: ir.SourceInfo{Location: location}}, location)) + loop.Next.Stmts = append(loop.Next.Stmts, incrementSymbol(ctx, module, evidence.Cursor, location)) + default: + return &hir.Invalid{Message: "unknown for-in iteration evidence", NodeID: hir.NodeID(node.ID()), Location: location} + } + return loop +} + +func generatedBinding(ctx *project.CompilerContext, module *project.Module, sym *symbols.Symbol, value ir.Expr, location *source.Location) *hir.Binding { + return &hir.Binding{ + Name: symbolName(module, sym), Type: loweredTypeID(ctx, module, sym.Type), Value: value, SymbolID: sym.ID, Location: location, + } +} + +func generatedIdent(ctx *project.CompilerContext, module *project.Module, sym *symbols.Symbol, location *source.Location) *ir.Ident { + return &ir.Ident{ + Name: symbolName(module, sym), Type: loweredTypeID(ctx, module, sym.Type), SymbolID: sym.ID, + SourceInfo: ir.SourceInfo{Location: location}, + } +} + +func incrementSymbol(ctx *project.CompilerContext, module *project.Module, sym *symbols.Symbol, location *source.Location) *hir.Assign { + typeID := loweredTypeID(ctx, module, sym.Type) + return &hir.Assign{ + Target: &ir.Place{Root: generatedIdent(ctx, module, sym, location), Type: typeID, Location: location}, + Value: &ir.Binary{ + Op: "+", Left: generatedIdent(ctx, module, sym, location), + Right: &ir.IntLit{Value: "1", Type: typeID, SourceInfo: ir.SourceInfo{Location: location}}, + Type: typeID, SourceInfo: ir.SourceInfo{Location: location}, + }, + Location: location, + } +} + func lowerPlace(ctx *project.CompilerContext, module *project.Module, scope *symbols.Scope, expr ast.Expr) *ir.Place { if selector, ok := expr.(*ast.SelectorExpr); ok && selector != nil && selector.Expr != nil && selector.Name != nil { if module != nil && module.Flow != nil { diff --git a/internal/ir/hir/lower/module_lower_test.go b/internal/ir/hir/lower/module_lower_test.go index fab131c5..32ef8d12 100644 --- a/internal/ir/hir/lower/module_lower_test.go +++ b/internal/ir/hir/lower/module_lower_test.go @@ -41,7 +41,10 @@ func generateTestHIR(t *testing.T, filePath, importPath, src string, beforeLower resolver.Resolve(ctx, module) typechecker.Check(ctx, module) module.TypedASTNodes = ast.Index(module.AST) - module.CFG = cfg.BuildModule(module.AST, module.Semantics.MatchCases) + module.CFG = cfg.BuildModule(module.AST, cfg.BuildQueries{ + MatchCases: module.Semantics.MatchCases, + LoopGuaranteedEntry: module.Semantics.ForLoopGuaranteedEntry, + }) module.Flow = typechecker.CheckFlow(ctx, module) if diag.HasErrors() { t.Fatalf("unexpected diagnostics:\n%s", diag.EmitAllToString()) @@ -53,6 +56,96 @@ func generateTestHIR(t *testing.T, filePath, importPath, src string, beforeLower return out } +func TestGenerateHIRLowersRangeForIntoStructuredSegments(t *testing.T) { + out := generateTestHIR(t, "hir_for_range_test"+peeper.SourceExt, "hir_for_range_test", `fn main() { + for index, value in 1i64..3i64 {} +}`) + loop, ok := out.Funcs[0].Body.Stmts[0].(*hir.For) + if !ok { + t.Fatalf("range statement = %#v, want hir.For", out.Funcs[0].Body.Stmts[0]) + } + if loop.Init == nil || len(loop.Init.Stmts) != 3 || loop.Bindings == nil || len(loop.Bindings.Stmts) != 2 || loop.Next == nil || len(loop.Next.Stmts) != 2 { + t.Fatalf("range segments = init %#v bindings %#v next %#v", loop.Init, loop.Bindings, loop.Next) + } + cursor := loop.Init.Stmts[0].(*hir.Binding) + end := loop.Init.Stmts[1].(*hir.Binding) + ordinal := loop.Init.Stmts[2].(*hir.Binding) + if out.Types.Text(cursor.Type) != "i64" || out.Types.Text(end.Type) != "i64" || out.Types.Text(ordinal.Type) != "i32" { + t.Fatalf("range state types = %s, %s, %s", out.Types.Text(cursor.Type), out.Types.Text(end.Type), out.Types.Text(ordinal.Type)) + } + cond, ok := loop.Cond.(*ir.Binary) + if !ok || cond.Op != "<" || cond.Left.TypeID() != cursor.Type || cond.Right.TypeID() != end.Type || out.Types.Text(cond.Type) != "bool" { + t.Fatalf("range condition = %#v", loop.Cond) + } + indexBinding := loop.Bindings.Stmts[0].(*hir.Binding) + valueBinding := loop.Bindings.Stmts[1].(*hir.Binding) + if out.Types.Text(indexBinding.Type) != "i32" || out.Types.Text(valueBinding.Type) != "i64" { + t.Fatalf("range source binding types = %s, %s", out.Types.Text(indexBinding.Type), out.Types.Text(valueBinding.Type)) + } + for index, stmt := range loop.Next.Stmts { + assignment, ok := stmt.(*hir.Assign) + if !ok || assignment.Target.TypeID() != []ir.TypeID{cursor.Type, ordinal.Type}[index] { + t.Fatalf("range increment %d = %#v", index, stmt) + } + } +} + +func TestGenerateHIRLowersSequenceForIntoStructuredSegments(t *testing.T) { + tests := []struct { + name string + source string + loopStmtIndex int + borrowed bool + }{ + {name: "fixed array", source: `fn main() { let items = [2]i32{1, 2}; for index, value in items {} }`, loopStmtIndex: 1, borrowed: true}, + {name: "dynamic array", source: `fn main() { let items = []i32{1, 2}; for index, value in items {} }`, loopStmtIndex: 1, borrowed: true}, + {name: "slice view", source: `fn Read(items: &[..]i32) { for index, value in items {} }`, loopStmtIndex: 0}, + } + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + out := generateTestHIR(t, "hir_for_sequence_test"+peeper.SourceExt, "hir_for_sequence_test", test.source) + loop := out.Funcs[0].Body.Stmts[test.loopStmtIndex].(*hir.For) + if loop.Init == nil || len(loop.Init.Stmts) != 2 || loop.Bindings == nil || len(loop.Bindings.Stmts) != 2 || loop.Next == nil || len(loop.Next.Stmts) != 1 { + t.Fatalf("sequence segments = init %#v bindings %#v next %#v", loop.Init, loop.Bindings, loop.Next) + } + carrier := loop.Init.Stmts[0].(*hir.Binding) + _, address := carrier.Value.(*ir.AddrOf) + if address != test.borrowed { + t.Fatalf("carrier initializer = %T, borrowed=%v", carrier.Value, test.borrowed) + } + cursor := loop.Init.Stmts[1].(*hir.Binding) + cond, ok := loop.Cond.(*ir.Binary) + if !ok || cond.Op != "<" || cond.Left.TypeID() != cursor.Type { + t.Fatalf("sequence condition = %#v", loop.Cond) + } + length, ok := cond.Right.(*ir.Len) + if !ok || length.Type != cursor.Type { + t.Fatalf("sequence length = %#v, cursor type %s", cond.Right, out.Types.Text(cursor.Type)) + } + indexBinding := loop.Bindings.Stmts[0].(*hir.Binding) + indexValue, ok := indexBinding.Value.(*ir.Ident) + if !ok || indexValue.TypeID() != cursor.Type || indexBinding.Type != cursor.Type { + t.Fatalf("sequence index binding = %#v", indexBinding) + } + valueBinding := loop.Bindings.Stmts[1].(*hir.Binding) + load, ok := valueBinding.Value.(*ir.Load) + if !ok || load.Place == nil || len(load.Place.Projections) != 1 || load.Place.Projections[0].Kind != ir.PlaceProjectionIndex || load.Place.Projections[0].Index.TypeID() != cursor.Type { + t.Fatalf("sequence value binding = %#v", valueBinding) + } + }) + } +} + +func TestGenerateHIRRejectsForInWithoutSemanticEvidence(t *testing.T) { + out := generateTestHIR(t, "hir_for_evidence_test"+peeper.SourceExt, "hir_for_evidence_test", `fn main() { for value in 0..2 {} }`, func(module *project.Module) { + module.Semantics.ForIterations = make(map[ast.NodeID]project.ForIteration) + }) + invalid, ok := out.Funcs[0].Body.Stmts[0].(*hir.Invalid) + if !ok || !strings.Contains(invalid.Message, "missing semantic evidence") { + t.Fatalf("for-in without evidence = %#v", out.Funcs[0].Body.Stmts[0]) + } +} + func TestGenerateHIRCallableNamesAreStableAndModuleAware(t *testing.T) { const src = `struct Counter { value: i32 } fn Value() -> i32 { return 1; } diff --git a/internal/ir/hir/model.go b/internal/ir/hir/model.go index 0ebfae54..24c668e7 100644 --- a/internal/ir/hir/model.go +++ b/internal/ir/hir/model.go @@ -115,8 +115,11 @@ type If struct { } type For struct { + Init *Block Cond ir.Expr + Bindings *Block Body *Block + Next *Block NodeID NodeID Location *source.Location } @@ -168,7 +171,13 @@ func (s *If) forEachChild(visit func(Stmt)) { visit(s.Then) visit(s.Else) } -func (s *For) forEachChild(visit func(Stmt)) { visit(s.Body) } +func (s *For) forEachChild(visit func(Stmt)) { + for _, segment := range []*Block{s.Init, s.Bindings, s.Body, s.Next} { + if segment != nil { + visit(segment) + } + } +} func (s *SwitchVariant) forEachChild(visit func(Stmt)) { for _, variantCase := range s.Cases { visit(variantCase.Body) @@ -349,7 +358,22 @@ func (s *For) appendText(b *strings.Builder, indent int) { b.WriteString(s.Cond.String()) } b.WriteString(" {\n") + appendForSegmentText(b, "init", s.Init, indent+1) + appendForSegmentText(b, "bindings", s.Bindings, indent+1) appendBlockText(b, s.Body, indent+1) + appendForSegmentText(b, "next", s.Next, indent+1) + writeIndent(b, indent) + b.WriteString("}\n") +} + +func appendForSegmentText(b *strings.Builder, name string, block *Block, indent int) { + if block == nil || len(block.Stmts) == 0 { + return + } + writeIndent(b, indent) + b.WriteString(name) + b.WriteString(" {\n") + appendBlockText(b, block, indent+1) writeIndent(b, indent) b.WriteString("}\n") } diff --git a/internal/ir/hir/model_test.go b/internal/ir/hir/model_test.go index c325723b..34eb8959 100644 --- a/internal/ir/hir/model_test.go +++ b/internal/ir/hir/model_test.go @@ -49,6 +49,37 @@ func TestInspectStmtTraversesStructuredChildren(t *testing.T) { } } +func TestForHIRKeepsGeneratedSegmentsInTraversalAndText(t *testing.T) { + loop := &For{ + Init: &Block{Stmts: []Stmt{&Binding{Name: "cursor"}}}, + Cond: &ir.Ident{Name: "more"}, + Bindings: &Block{Stmts: []Stmt{&Binding{Name: "value"}}}, + Body: &Block{NodeID: 6, Stmts: []Stmt{&ExprStmt{Value: &ir.Ident{Name: "work"}, NodeID: 7}}}, + Next: &Block{Stmts: []Stmt{&Assign{Target: &ir.Place{Root: &ir.Ident{Name: "cursor"}}, Value: &ir.Ident{Name: "next"}}}}, + NodeID: 1, + } + visited := make([]NodeID, 0) + InspectStmt(loop, func(stmt Stmt) bool { + visited = append(visited, NodeIDOf(stmt)) + return true + }) + wantVisited := []NodeID{1, 0, 0, 0, 0, 6, 7, 0, 0} + if len(visited) != len(wantVisited) { + t.Fatalf("visited loop nodes = %v, want %v", visited, wantVisited) + } + for index := range wantVisited { + if visited[index] != wantVisited[index] { + t.Fatalf("visited loop nodes = %v, want %v", visited, wantVisited) + } + } + var text strings.Builder + loop.appendText(&text, 0) + wantText := "for more {\n init {\n let cursor\n }\n bindings {\n let value\n }\n work\n next {\n cursor = next\n }\n}\n" + if got := text.String(); got != wantText { + t.Fatalf("loop text = %q, want %q", got, wantText) + } +} + func TestSwitchVariantHIRKeepsCaseBlocksAndText(t *testing.T) { switchStmt := &SwitchVariant{ Value: &ir.Ident{Name: "status"}, diff --git a/internal/ir/mir/module_lower.go b/internal/ir/mir/module_lower.go index 014c854e..bfa2f6db 100644 --- a/internal/ir/mir/module_lower.go +++ b/internal/ir/mir/module_lower.go @@ -127,7 +127,9 @@ func GenerateMIR(in *hir.Module, graphs *cfg.Module, ownership ownershipresult.R } statements := make(map[ir.NodeID]hir.Stmt) hir.InspectStmt(hirFn.Body, func(stmt hir.Stmt) bool { - statements[hir.NodeIDOf(stmt)] = stmt + if id := hir.NodeIDOf(stmt); id != 0 { + statements[id] = stmt + } return true }) fn, ok := lowerCFGFunction(out, hirFn, graph, statements, ownership[hirFn.NodeID]) @@ -207,6 +209,7 @@ func lowerCFGFunction(mod *Module, sourceFn *hir.Function, graph *cfg.Graph, sta } } } + // Loop segments have no AST sites; CFG block origin places their execution. for _, source := range graph.Blocks { block := blocks[source] if block == nil { @@ -217,6 +220,29 @@ func lowerCFGFunction(mod *Module, sourceFn *hir.Function, graph *cfg.Graph, sta if entry, found := l.variantEntries[source]; found && !l.lowerVariantBindings(entry) { return nil, false } + if forStmt, ok := statements[source.NodeID].(*hir.For); ok { + var segment *hir.Block + switch source.Origin { + case cfg.BlockLoopInit: + segment = forStmt.Init + case cfg.BlockLoopBody: + segment = forStmt.Bindings + case cfg.BlockLoopLatch: + segment = forStmt.Next + } + if segment != nil { + for _, stmt := range segment.Stmts { + if binding, ok := stmt.(*hir.Binding); ok && binding.SymbolID != 0 { + l.symbolValues[binding.SymbolID] = &RefName{Name: binding.Name, Type: binding.Type, Location: binding.Location} + } + } + for _, stmt := range segment.Stmts { + if !l.lowerCFGStmt(stmt) { + return nil, false + } + } + } + } for _, site := range source.Sites { switch site.Kind { case cfg.SiteStatement: @@ -226,7 +252,7 @@ func lowerCFGFunction(mod *Module, sourceFn *hir.Function, graph *cfg.Graph, sta case cfg.SiteScopeExit: if l.cleanup != nil { l.location = site.Location - l.appendPlannedDrops(l.cleanup.AfterScope[site.NodeID], &block.Instrs) + l.appendPlannedDrops(l.cleanup.AfterScope[site.ID], &block.Instrs) } } } diff --git a/internal/ir/mir/module_lower_test.go b/internal/ir/mir/module_lower_test.go index 7b6a6994..0d34ebf0 100644 --- a/internal/ir/mir/module_lower_test.go +++ b/internal/ir/mir/module_lower_test.go @@ -179,7 +179,7 @@ func cfgForHIR(module *hir.Module) *cfg.Module { Location: fn.Location, }) } - return cfg.BuildModule(source, nil) + return cfg.BuildModule(source, cfg.BuildQueries{}) } func TestGenerateMIRAddsImplicitVoidReturn(t *testing.T) { @@ -271,16 +271,18 @@ func TestGenerateMIRLowersReturnCleanupBeforeTerminator(t *testing.T) { func TestGenerateMIRAppliesOwnershipCleanupPlan(t *testing.T) { tests := []struct { - name string - body *hir.Block - plan *ownershipresult.CleanupPlan - want int + name string + body *hir.Block + plan *ownershipresult.CleanupPlan + scopeNodeID ir.NodeID + want int }{ { - name: "scope exit", - body: &hir.Block{NodeID: 10}, - plan: &ownershipresult.CleanupPlan{AfterScope: map[ir.NodeID][]symbols.SymbolID{10: {1}}}, - want: 1, + name: "scope exit", + body: &hir.Block{NodeID: 10}, + plan: &ownershipresult.CleanupPlan{AfterScope: make(map[cfg.SiteID][]symbols.SymbolID)}, + scopeNodeID: 10, + want: 1, }, { name: "return", @@ -317,6 +319,18 @@ func TestGenerateMIRAppliesOwnershipCleanupPlan(t *testing.T) { Body: tt.body, }}} graphs := cfgForHIR(mod) + if tt.scopeNodeID != 0 { + for _, block := range graphs.Functions[0].Blocks { + if block == nil { + continue + } + for _, site := range block.Sites { + if site != nil && site.Kind == cfg.SiteScopeExit && site.NodeID == tt.scopeNodeID { + tt.plan.AfterScope[site.ID] = []symbols.SymbolID{1} + } + } + } + } plans := ownershipresult.Result{mod.Funcs[0].NodeID: tt.plan} out := GenerateMIR(mod, graphs, plans, nil, nil) if out == nil || len(out.Funcs) != 1 || len(out.Funcs[0].Blocks) == 0 { @@ -340,6 +354,58 @@ func TestGenerateMIRAppliesOwnershipCleanupPlan(t *testing.T) { } } +func TestGenerateMIRScopesCleanupToExactCFGSite(t *testing.T) { + const sharedScopeID ir.NodeID = 30 + mod := &hir.Module{Name: "test", Types: mirTypes.table, Funcs: []*hir.Function{{ + Name: "main", + Params: []ir.Param{{Name: "owner", Type: mirTypes.ownedI32, SymbolID: 1}}, + ReturnType: mirTypes.void, + Body: &hir.Block{Stmts: []hir.Stmt{&hir.If{ + Cond: &ir.BoolLit{Value: true, Type: mirTypes.boolType}, + Then: &hir.Block{NodeID: sharedScopeID}, + Else: &hir.Block{NodeID: sharedScopeID}, + }}}, + }}} + graphs := cfgForHIR(mod) + graph := graphs.Function(mod.Funcs[0].NodeID) + var exits []cfg.SiteID + for _, block := range graph.Blocks { + if block == nil { + continue + } + for _, site := range block.Sites { + if site != nil && site.Kind == cfg.SiteScopeExit && site.NodeID == sharedScopeID { + exits = append(exits, site.ID) + } + } + } + if len(exits) != 2 || exits[0] == exits[1] { + t.Fatalf("scope exits = %v, want two distinct sites", exits) + } + plan := &ownershipresult.CleanupPlan{AfterScope: map[cfg.SiteID][]symbols.SymbolID{ + exits[0]: {1}, + }} + out := GenerateMIR(mod, graphs, ownershipresult.Result{mod.Funcs[0].NodeID: plan}, nil, nil) + if out == nil || len(out.Funcs) != 1 { + t.Fatalf("MIR = %#v, want one function", out) + } + for _, block := range out.Funcs[0].Blocks { + drops := 0 + for _, instr := range block.Instrs { + if _, ok := instr.(*Drop); ok { + drops++ + } + } + want := 0 + if block.ID == exits[0].Block { + want = 1 + } + if drops != want { + t.Fatalf("block %d drops = %d, want %d; exits = %v", block.ID, drops, want, exits) + } + } +} + func TestGenerateMIRStaticDataUsesSemanticConstValues(t *testing.T) { mod := &hir.Module{Name: "test", Types: mirTypes.table} scope := symbols.NewScope(nil) @@ -1287,7 +1353,13 @@ func TestGenerateMIRLowersForLoop(t *testing.T) { Body: &hir.Block{ Stmts: []hir.Stmt{ &hir.For{ + Init: &hir.Block{Stmts: []hir.Stmt{&hir.Binding{ + Name: "cursor", Type: mirTypes.i32, Value: &ir.IntLit{Value: "0", Type: mirTypes.i32}, SymbolID: 100, + }}}, Cond: &ir.IntLit{Value: "1", Type: mirTypes.boolType}, + Bindings: &hir.Block{Stmts: []hir.Stmt{&hir.Binding{ + Name: "value", Type: mirTypes.i32, Value: &ir.Ident{Name: "cursor", Type: mirTypes.i32}, SymbolID: 101, + }}}, Body: &hir.Block{ Stmts: []hir.Stmt{ &hir.ExprStmt{ @@ -1298,6 +1370,10 @@ func TestGenerateMIRLowersForLoop(t *testing.T) { }, }, }, + Next: &hir.Block{Stmts: []hir.Stmt{&hir.Assign{ + Target: &ir.Place{Root: &ir.Ident{Name: "cursor", Type: mirTypes.i32}, Type: mirTypes.i32}, + Value: &ir.Binary{Op: "+", Left: &ir.Ident{Name: "cursor", Type: mirTypes.i32}, Right: &ir.IntLit{Value: "1", Type: mirTypes.i32}, Type: mirTypes.i32}, + }}}, }, }, }, @@ -1311,38 +1387,75 @@ func TestGenerateMIRLowersForLoop(t *testing.T) { t.Fatalf("expected one MIR function, got %#v", out) } fn := out.Funcs[0] - if len(fn.Blocks) != 4 { - t.Fatalf("expected four blocks for loop, got %#v", fn.Blocks) - } - index := 0 - for _, block := range graphs.Functions[0].Blocks { - if block == nil || block == graphs.Functions[0].Exit || !block.Reachable { - continue - } - if fn.Blocks[index].ID != block.ID { - t.Fatalf("MIR block %d ID = %d, want CFG ID %d", index, fn.Blocks[index].ID, block.ID) + // Canonical loop shape: entry → init → header → body → latch → (header|exit). + if len(fn.Blocks) != 6 { + t.Fatalf("expected six blocks for loop, got %#v", fn.Blocks) + } + byID := make(map[int]*Block, len(fn.Blocks)) + for _, block := range fn.Blocks { + byID[block.ID] = block + } + var header *Block + for _, block := range fn.Blocks { + if _, ok := block.Term.(*Branch); ok { + header = block + break } - index++ + } + if header == nil { + t.Fatalf("expected one loop header branch, got %#v", fn.Blocks) } entry := fn.Blocks[0] entryJump, ok := entry.Term.(*Jump) if !ok { t.Fatalf("expected entry jump terminator, got %#v", entry.Term) } - header := fn.Blocks[1] - if entryJump.TargetID != header.ID { - t.Fatalf("expected jump to loop header, got %#v", entry.Term) + init := byID[entryJump.TargetID] + if init == nil || init == header { + t.Fatalf("expected entry jump to init block, got %#v", entry.Term) } - term, ok := header.Term.(*Branch) - if !ok { - t.Fatalf("expected header branch terminator, got %#v", header.Term) + if len(init.Instrs) != 1 { + t.Fatalf("init instructions = %#v, want cursor assignment", init.Instrs) } - if term.ThenID != fn.Blocks[2].ID || term.ElseID != fn.Blocks[3].ID { + if assignment, ok := init.Instrs[0].(*Assign); !ok || assignment.Name != "cursor" { + t.Fatalf("init instruction = %#v, want cursor assignment", init.Instrs[0]) + } + initJump, ok := init.Term.(*Jump) + if !ok || initJump.TargetID != header.ID { + t.Fatalf("expected init jump to header block, got %#v", init.Term) + } + term := header.Term.(*Branch) + body := byID[term.ThenID] + exit := byID[term.ElseID] + if body == nil || exit == nil || body == exit { t.Fatalf("unexpected loop targets: %#v", term) } - bodyTerm, ok := fn.Blocks[2].Term.(*Jump) - if !ok || bodyTerm.TargetID != header.ID { - t.Fatalf("expected backedge to header block, got %#v", fn.Blocks[2].Term) + if len(body.Instrs) < 1 { + t.Fatalf("body instructions = %#v, want generated value binding", body.Instrs) + } + if assignment, ok := body.Instrs[0].(*Assign); !ok || assignment.Name != "value" { + t.Fatalf("first body instruction = %#v, want value assignment", body.Instrs[0]) + } + bodyTerm, ok := body.Term.(*Jump) + if !ok { + t.Fatalf("expected body jump terminator, got %#v", body.Term) + } + latch := byID[bodyTerm.TargetID] + if latch == nil || latch == header { + t.Fatalf("expected body jump to latch block, got %#v", body.Term) + } + if len(latch.Instrs) != 2 { + t.Fatalf("latch instructions = %#v, want increment and cursor assignment", latch.Instrs) + } + if assignment, ok := latch.Instrs[1].(*Assign); !ok || assignment.Name != "cursor" { + t.Fatalf("final latch instruction = %#v, want cursor assignment", latch.Instrs[1]) + } + latchTerm, ok := latch.Term.(*Jump) + if !ok || latchTerm.TargetID != header.ID { + t.Fatalf("expected latch backedge to header block, got %#v", latch.Term) + } + if _, ok := exit.Term.(*Ret); !ok { + t.Fatalf("expected loop exit to return, got %#v", exit.Term) } } diff --git a/internal/pipeline/pipeline.go b/internal/pipeline/pipeline.go index 30181b9d..76fa76d3 100644 --- a/internal/pipeline/pipeline.go +++ b/internal/pipeline/pipeline.go @@ -419,7 +419,10 @@ func advanceModulePhase(ctx *project.CompilerContext, module *project.Module, di return true } if module.Phase < phase.CFG { - module.CFG = cfg.BuildModule(module.AST, module.Semantics.MatchCases) + module.CFG = cfg.BuildModule(module.AST, cfg.BuildQueries{ + MatchCases: module.Semantics.MatchCases, + LoopGuaranteedEntry: module.Semantics.ForLoopGuaranteedEntry, + }) cfg.Analyze(module.CFG, phaseDiag, func(conditionID, scopeID ir.NodeID) (bool, bool) { node := module.TypedASTNodes[ast.NodeID(conditionID)] expr, ok := node.(ast.Expr) diff --git a/internal/pipeline/pipeline_test.go b/internal/pipeline/pipeline_test.go index 5f6a1887..92c107fe 100644 --- a/internal/pipeline/pipeline_test.go +++ b/internal/pipeline/pipeline_test.go @@ -4,18 +4,24 @@ import ( "os" "os/exec" "path/filepath" + "slices" "strings" "testing" "compiler/internal/constvalue" "compiler/internal/diagnostics" + "compiler/internal/frontend/ast" "compiler/internal/frontend/lexer" "compiler/internal/frontend/parser" + "compiler/internal/ir" + "compiler/internal/ir/cfg" + "compiler/internal/ir/hir" "compiler/internal/ir/mir" "compiler/internal/phase" "compiler/internal/project" "compiler/internal/semantics/intrinsics" "compiler/internal/semantics/symbols" + "compiler/internal/target" "compiler/pkg/peeper" ) @@ -29,7 +35,7 @@ func parseModuleSource(filePath, src string, diag *diagnostics.DiagnosticBag) *p } } -func buildPipelineTestWithConfig(t *testing.T, cfg project.Config, preludeSrc, entrySrc string) *diagnostics.DiagnosticBag { +func buildPipelineTestWithConfig(t *testing.T, cfg project.Config, preludeSrc, entrySrc string, afterRun ...func(*project.Module)) *diagnostics.DiagnosticBag { t.Helper() const preludePath = "core/global" + peeper.SourceExt const entryPath = "entry" + peeper.SourceExt @@ -53,6 +59,9 @@ func buildPipelineTestWithConfig(t *testing.T, cfg project.Config, preludeSrc, e if err := Run(ctx, entry); err != nil { t.Fatalf("pipeline.Run returned error: %v", err) } + for _, inspect := range afterRun { + inspect(entry) + } return diag } @@ -141,6 +150,217 @@ fn invalid(point: Point) { t.Fatalf("expected use-after-move diagnostic from constant false branch, got:\n%s", diag.EmitAllToString()) } +func TestPipelineLowersSequenceIndexesAsUsizeAcrossTargets(t *testing.T) { + for _, test := range []struct { + arch string + typeText string + llvmType string + }{ + {arch: "386", typeText: "u32", llvmType: "i32"}, + {arch: "amd64", typeText: "u64", llvmType: "i64"}, + } { + t.Run(test.arch, func(t *testing.T) { + targetInfo, err := target.New("linux", test.arch) + if err != nil { + t.Fatal(err) + } + diag := buildPipelineTestWithConfig(t, project.Config{ + RootDir: ".", + Extension: peeper.SourceExt, + TargetOS: "linux", + TargetArch: test.arch, + }, "", `fn main() { + let items = [1]i32{1}; + for index, value in items {} +}`, func(entry *project.Module) { + if entry.HIR == nil || entry.MIR == nil || len(entry.HIR.Funcs) != 1 || len(entry.HIR.Funcs[0].Body.Stmts) != 2 { + t.Fatalf("pipeline artifacts missing: HIR=%v MIR=%v", entry.HIR != nil, entry.MIR != nil) + } + loop, ok := entry.HIR.Funcs[0].Body.Stmts[1].(*hir.For) + if !ok || loop.Init == nil || len(loop.Init.Stmts) != 2 || loop.Bindings == nil || len(loop.Bindings.Stmts) != 2 { + t.Fatalf("loop = %#v, want sequence segments", entry.HIR.Funcs[0].Body.Stmts[1]) + } + cursor := loop.Init.Stmts[1].(*hir.Binding) + index := loop.Bindings.Stmts[0].(*hir.Binding) + if gotCursor, gotIndex := entry.HIR.Types.Text(cursor.Type), entry.HIR.Types.Text(index.Type); gotCursor != test.typeText || gotIndex != test.typeText { + t.Fatalf("cursor/index types = %s/%s, want %s/%s", gotCursor, gotIndex, test.typeText, test.typeText) + } + indexValue, ok := index.Value.(*ir.Ident) + if !ok || indexValue.Type != cursor.Type { + t.Fatalf("index binding = %#v, want direct cursor value", index) + } + cond, ok := loop.Cond.(*ir.Binary) + if !ok { + t.Fatalf("condition = %#v, want binary bounds check", loop.Cond) + } + length, ok := cond.Right.(*ir.Len) + if !ok || cond.Left.TypeID() != cursor.Type || length.Type != cursor.Type { + t.Fatalf("condition = %#v, want target-sized cursor and length", cond) + } + + refType := func(ref mir.ValueRef) ir.TypeID { + switch value := ref.(type) { + case *mir.RefConst: + return value.Type + case *mir.RefName: + return value.Type + default: + return ir.InvalidType + } + } + foundCompare := false + foundIndexMove := false + foundProjection := false + for _, function := range entry.MIR.Funcs { + for _, block := range function.Blocks { + for _, instruction := range block.Instrs { + assign, ok := instruction.(*mir.Assign) + if !ok { + continue + } + switch value := assign.Value.(type) { + case *mir.Binary: + if value.Op == "<" && refType(value.Left) == cursor.Type && refType(value.Right) == cursor.Type { + foundCompare = true + } + case *mir.Move: + if assign.Name == index.Name && value.Type == cursor.Type && refType(value.Src) == cursor.Type { + foundIndexMove = true + } + case *mir.Load: + if value.Place != nil && len(value.Place.Projections) == 1 && value.Place.Projections[0].Kind == mir.PlaceProjectionIndex && + refType(value.Place.Projections[0].Index) == cursor.Type { + foundProjection = true + } + } + } + } + } + if !foundCompare || !foundIndexMove || !foundProjection { + t.Fatalf("MIR target-width evidence missing: compare=%v index=%v projection=%v\n%s", foundCompare, foundIndexMove, foundProjection, entry.MIR.Text()) + } + if !strings.Contains(entry.LLVMIR, "icmp ult "+test.llvmType) || strings.Contains(entry.LLVMIR, "trunc i64") { + t.Fatalf("LLVM index width invalid for %s:\n%s", test.arch, entry.LLVMIR) + } + clang, err := exec.LookPath("clang") + if err != nil { + t.Skip("clang unavailable for LLVM IR validation") + } + cmd := exec.Command(clang, "-target", targetInfo.LLVMTriple, "-x", "ir", "-c", "-o", filepath.Join(t.TempDir(), "for-loop.o"), "-") + cmd.Stdin = strings.NewReader(entry.LLVMIR) + if output, err := cmd.CombinedOutput(); err != nil { + t.Fatalf("%s for-loop LLVM is invalid: %v\n%s\n%s", test.arch, err, output, entry.LLVMIR) + } + }) + if diag.HasErrors() { + t.Fatalf("unexpected diagnostics:\n%s", diag.EmitAllToString()) + } + }) + } +} + +func TestPipelineLowersExactLoopExitCleanupToMIR(t *testing.T) { + diag := buildPipelineTestWithConfig(t, project.Config{RootDir: ".", Extension: peeper.SourceExt}, "", `fn main() { + for i in 0..3 { + let first = alloc(i); + if i == 0 { continue; } + let second = alloc(i); + if i == 1 { break; } + } +}`, func(entry *project.Module) { + fn := entry.AST.Stmts[0].(*ast.FnDecl) + loop := fn.Body.Stmts[0].(*ast.ForStmt) + continueStmt := loop.Body.Stmts[1].(*ast.IfStmt).Then.Stmts[0].(*ast.ContinueStmt) + breakStmt := loop.Body.Stmts[3].(*ast.IfStmt).Then.Stmts[0].(*ast.BreakStmt) + graph := entry.CFG.Function(ir.NodeID(fn.ID())) + if graph == nil || entry.MIR == nil { + t.Fatalf("pipeline artifacts missing: CFG=%v MIR=%v", graph != nil, entry.MIR != nil) + } + + var continueExit, breakExit, fallthroughExit cfg.SiteID + var continueFound, breakFound, fallthroughFound bool + for _, block := range graph.Blocks { + if block == nil || !block.Reachable { + continue + } + var exit *cfg.Site + hasContinue := false + hasBreak := false + for _, site := range block.Sites { + if site == nil { + continue + } + hasContinue = hasContinue || site.NodeID == ir.NodeID(continueStmt.ID()) + hasBreak = hasBreak || site.NodeID == ir.NodeID(breakStmt.ID()) + if site.Kind == cfg.SiteScopeExit && site.NodeID == ir.NodeID(loop.Body.ID()) { + exit = site + } + } + if exit == nil { + continue + } + switch { + case hasContinue: + continueExit, continueFound = exit.ID, true + case hasBreak: + breakExit, breakFound = exit.ID, true + default: + fallthroughExit, fallthroughFound = exit.ID, true + } + } + if !continueFound || !breakFound || !fallthroughFound { + t.Fatalf("loop exits missing: continue=%v break=%v fallthrough=%v", continueFound, breakFound, fallthroughFound) + } + + var mirFn *mir.Function + for _, function := range entry.MIR.Funcs { + if function.Name == "main" { + mirFn = function + break + } + } + if mirFn == nil { + t.Fatal("main MIR function missing") + } + dropNames := func(blockID int) []string { + var names []string + for _, block := range mirFn.Blocks { + if block.ID != blockID { + continue + } + for _, instruction := range block.Instrs { + drop, ok := instruction.(*mir.Drop) + if !ok { + continue + } + name, ok := drop.Value.(*mir.RefName) + if !ok { + t.Fatalf("drop value = %#v, want named owner", drop.Value) + } + names = append(names, strings.SplitN(name.Name, "$", 2)[0]) + } + } + return names + } + for _, test := range []struct { + name string + site cfg.SiteID + want []string + }{ + {name: "continue", site: continueExit, want: []string{"first"}}, + {name: "break", site: breakExit, want: []string{"second", "first"}}, + {name: "fallthrough", site: fallthroughExit, want: []string{"second", "first"}}, + } { + if got := dropNames(test.site.Block); !slices.Equal(got, test.want) { + t.Fatalf("%s MIR drops = %v, want %v\n%s", test.name, got, test.want, entry.MIR.Text()) + } + } + }) + if diag.HasErrors() { + t.Fatalf("unexpected diagnostics:\n%s", diag.EmitAllToString()) + } +} + func TestPipelineRequiresBuildEntrypoint(t *testing.T) { tests := []struct { name string diff --git a/internal/project/modules.go b/internal/project/modules.go index ad1956e7..ea448c8f 100644 --- a/internal/project/modules.go +++ b/internal/project/modules.go @@ -107,6 +107,7 @@ type SemanticInfo struct { CompilerCalls map[ast.NodeID]CompilerCall StringConcatenations map[ast.NodeID]struct{} VariantConstructions map[ast.NodeID]VariantConstruction + ForIterations map[ast.NodeID]ForIteration OperationFunctions []*symbols.Symbol } @@ -119,6 +120,29 @@ type VariantConstruction struct { Value ast.Expr } +type ForIterationKind uint8 + +const ( + ForIterationRange ForIterationKind = iota + ForIterationSequence +) + +// ForIteration is typechecker-owned evidence consumed by HIR lowering. +// Generated symbols carry hidden loop state; source bindings remain body-scoped. +type ForIteration struct { + Kind ForIterationKind + GuaranteedEntry bool + + ElementType typeinfo.Type + CarrierType typeinfo.Type + Carrier *symbols.Symbol + Cursor *symbols.Symbol + End *symbols.Symbol + Ordinal *symbols.Symbol + Index *symbols.Symbol + Value *symbols.Symbol +} + // CompilerCall is typechecker-owned dispatch evidence consumed by HIR. type CompilerCall struct { Operation symbols.CompilerOp @@ -172,12 +196,23 @@ func NewSemanticInfo() *SemanticInfo { CompilerCalls: make(map[ast.NodeID]CompilerCall), StringConcatenations: make(map[ast.NodeID]struct{}), VariantConstructions: make(map[ast.NodeID]VariantConstruction), + ForIterations: make(map[ast.NodeID]ForIteration), OperationFunctions: make([]*symbols.Symbol, 0), } } // MatchCases exposes resolved case indexes without leaking match artifacts // into CFG's source-topology package. +// ForLoopGuaranteedEntry exposes typechecker proof that one loop executes its +// body before its first condition check. +func (s *SemanticInfo) ForLoopGuaranteedEntry(id ast.NodeID) bool { + if s == nil { + return false + } + iteration, found := s.ForIterations[id] + return found && iteration.GuaranteedEntry +} + func (s *SemanticInfo) MatchCases(id ast.NodeID) ([]int, bool) { if s == nil { return nil, false diff --git a/internal/semantics/definiteinit/initialization_test.go b/internal/semantics/definiteinit/initialization_test.go index 18461589..edd210d0 100644 --- a/internal/semantics/definiteinit/initialization_test.go +++ b/internal/semantics/definiteinit/initialization_test.go @@ -38,7 +38,10 @@ func analyzeInitializationSource(t *testing.T, source string) (*functionResult, resolver.Resolve(ctx, module) typechecker.Check(ctx, module) module.TypedASTNodes = ast.Index(module.AST) - module.CFG = cfg.BuildModule(module.AST, module.Semantics.MatchCases) + module.CFG = cfg.BuildModule(module.AST, cfg.BuildQueries{ + MatchCases: module.Semantics.MatchCases, + LoopGuaranteedEntry: module.Semantics.ForLoopGuaranteedEntry, + }) symbol, found := module.ModuleScope.Lookup("choose") if !found || symbol == nil { t.Fatal("choose function symbol missing") @@ -125,6 +128,31 @@ func TestInitializationLoopMayExecuteZeroTimes(t *testing.T) { } } +func TestInitializationUsesGuaranteedRangeEntry(t *testing.T) { + for _, test := range []struct { + name string + rangeText string + wantError bool + }{ + {name: "guaranteed entry", rangeText: "0..1"}, + {name: "empty range", rangeText: "0..0", wantError: true}, + {name: "runtime range", rangeText: "start..end", wantError: true}, + } { + t.Run(test.name, func(t *testing.T) { + _, diag, _ := analyzeInitializationSource(t, `fn choose(start: i32, end: i32) -> i32 { + let mut value: i32; + for item in `+test.rangeText+` { + value = item; + } + return value; +}`) + if got := hasDiagnosticCode(diag, diagnostics.ErrUninitializedVariable); got != test.wantError { + t.Fatalf("uninitialized diagnostic = %v, want %v:\n%s", got, test.wantError, diag.EmitAllToString()) + } + }) + } +} + func TestInitializationAcceptsDirectAssignment(t *testing.T) { _, diag, _ := analyzeInitializationSource(t, `fn choose(flag: bool) -> i32 { let mut value: i32; diff --git a/internal/semantics/ownership/ownership.go b/internal/semantics/ownership/ownership.go index 3842dd20..4cc36d94 100644 --- a/internal/semantics/ownership/ownership.go +++ b/internal/semantics/ownership/ownership.go @@ -17,10 +17,11 @@ import ( ) type site struct { - cfgSite *cfg.Site - stmt ast.Stmt - block *ast.BlockStmt - scope *symbols.Scope + cfgSite *cfg.Site + cfgBlock *cfg.Block + stmt ast.Stmt + block *ast.BlockStmt + scope *symbols.Scope } type analyzer struct { @@ -36,7 +37,7 @@ type analyzer struct { inStates map[cfg.SiteID]state symbolLiveIn map[cfg.SiteID]map[*symbols.Symbol]ast.Node symbolLiveOut map[cfg.SiteID]map[*symbols.Symbol]ast.Node - deadMatchCarrierAtExit map[ast.NodeID]*symbols.Symbol + deadMatchCarrierAtExit map[cfg.SiteID]*symbols.Symbol } type pointerOrigin struct { @@ -64,7 +65,7 @@ func Check(ctx *project.CompilerContext, module *project.Module) ownershipresult continue } result[graph.NodeID] = &ownershipresult.CleanupPlan{ - AfterScope: make(map[ir.NodeID][]symbols.SymbolID), + AfterScope: make(map[cfg.SiteID][]symbols.SymbolID), BeforeReturn: make(map[ir.NodeID][]symbols.SymbolID), BeforeAssign: make(map[ir.NodeID]struct{}), DiscardedValue: make(map[ir.NodeID]struct{}), @@ -142,7 +143,7 @@ func indexSites(module *project.Module, cfgFn *cfg.Graph, scope *symbols.Scope) if resolvedScope == nil { resolvedScope = scope } - indexed := &site{cfgSite: flowSite, scope: resolvedScope} + indexed := &site{cfgSite: flowSite, cfgBlock: block, scope: resolvedScope} switch flowSite.Kind { case cfg.SiteStatement, cfg.SiteTerminator: if stmt, ok := nodes[ast.NodeID(flowSite.NodeID)].(ast.Stmt); ok && stmt != nil { @@ -193,6 +194,13 @@ func (a *analyzer) run() { queued[id] = false node := a.sites[id] next := copyState(a.inStates[id]) + if node != nil && node.cfgBlock != nil && node.cfgBlock.Origin == cfg.BlockNormal { + loopID := ast.NodeID(node.cfgBlock.NodeID) + if evidence, found := a.module.Semantics.ForIterations[loopID]; found && + evidence.Kind == project.ForIterationSequence && evidence.Carrier != nil { + releaseIterationLoans(next, nil, loopID) + } + } if node != nil { switch node.cfgSite.Kind { case cfg.SiteStatement, cfg.SiteTerminator: @@ -227,11 +235,11 @@ func (a *analyzer) run() { } func (a *analyzer) planDeadMatchCarrierCleanup() { - a.deadMatchCarrierAtExit = make(map[ast.NodeID]*symbols.Symbol) - scopeExits := make(map[ast.NodeID]*site) + a.deadMatchCarrierAtExit = make(map[cfg.SiteID]*symbols.Symbol) + scopeExits := make(map[ast.NodeID][]*site) for _, node := range a.sites { if node != nil && node.cfgSite != nil && node.cfgSite.Kind == cfg.SiteScopeExit && node.block != nil { - scopeExits[node.block.ID()] = node + scopeExits[node.block.ID()] = append(scopeExits[node.block.ID()], node) } } @@ -249,32 +257,47 @@ func (a *analyzer) planDeadMatchCarrierCleanup() { continue } - fallingBodies := make([]ast.NodeID, 0, len(match.Arms)) - var join cfg.SiteID - joinFound := false - movesCarrier := false + exitsByJoin := make(map[cfg.SiteID][]*site) + movesByJoin := make(map[cfg.SiteID]bool) + armsByJoin := make(map[cfg.SiteID]map[ast.NodeID]struct{}) for _, arm := range match.Arms { - exit := scopeExits[arm.BodyID] - if exit == nil || exit.cfgSite == nil || len(exit.cfgSite.Successors) != 1 { + for _, exit := range scopeExits[arm.BodyID] { + if exit == nil || exit.cfgSite == nil || len(exit.cfgSite.Successors) != 1 { + continue + } + join := exit.cfgSite.Successors[0].To + for { + joinNode := a.sites[join] + if joinNode == nil || joinNode.cfgSite == nil { + break + } + if joinNode.cfgSite.Kind != cfg.SiteScopeExit { + exitsByJoin[join] = append(exitsByJoin[join], exit) + if armsByJoin[join] == nil { + armsByJoin[join] = make(map[ast.NodeID]struct{}) + } + armsByJoin[join][arm.BodyID] = struct{}{} + movesByJoin[join] = movesByJoin[join] || matchArmMovesCarrier(arm) + break + } + if len(joinNode.cfgSite.Successors) != 1 { + break + } + join = joinNode.cfgSite.Successors[0].To + } + } + } + + for join, arms := range armsByJoin { + if len(arms) < 2 || !movesByJoin[join] { continue } - armJoin := exit.cfgSite.Successors[0].To - if joinFound && armJoin != join { - panic("match ownership cleanup requires one shared falling-arm join") + if _, live := a.symbolLiveIn[join][carrier]; live { + continue + } + for _, exit := range exitsByJoin[join] { + a.deadMatchCarrierAtExit[exit.cfgSite.ID] = carrier } - join = armJoin - joinFound = true - fallingBodies = append(fallingBodies, arm.BodyID) - movesCarrier = movesCarrier || matchArmMovesCarrier(arm) - } - if !joinFound || len(fallingBodies) < 2 || !movesCarrier { - continue - } - if _, live := a.symbolLiveIn[join][carrier]; live { - continue - } - for _, bodyID := range fallingBodies { - a.deadMatchCarrierAtExit[bodyID] = carrier } } } @@ -290,6 +313,27 @@ func copyState(src state) state { return dst } +// releaseIterationLoans ends synthetic carrier borrows when control leaves +// their loop. A zero loop ID releases all active loops, as required by return. +func releaseIterationLoans(st state, loans *loanContext, loopID ast.NodeID) { + matches := func(loan referenceLoan) bool { + return loan.loop != 0 && (loopID == 0 || loan.loop == loopID) + } + for holder, active := range st.references { + remaining := slices.DeleteFunc(active, matches) + if len(remaining) == 0 { + delete(st.references, holder) + continue + } + st.references[holder] = remaining + } + if loans != nil { + loans.persistent = slices.DeleteFunc(loans.persistent, func(fact loanFact) bool { + return matches(fact.loan) + }) + } +} + func (a *analyzer) mergeState(nodeID cfg.SiteID, dst, src state, exists bool) (state, bool) { if !exists { return copyState(src), true @@ -353,13 +397,13 @@ func newState() state { } func (a *analyzer) applyBlockExit(node *site, st state, loans *loanContext) { - if a == nil || node == nil || node.block == nil || node.scope == nil { + if a == nil || node == nil || node.cfgSite == nil || node.block == nil || node.scope == nil { return } a.checkScopeDestruction(node.scope, node.block, loans) - delete(a.cleanup.AfterScope, ir.NodeID(node.block.ID())) + delete(a.cleanup.AfterScope, node.cfgSite.ID) cleanup := cleanupSymbols(node.scope, st) - if carrier := a.deadMatchCarrierAtExit[node.block.ID()]; carrier != nil { + if carrier := a.deadMatchCarrierAtExit[node.cfgSite.ID]; carrier != nil { if _, live := st.live[carrier]; live { a.reportLoanConflict([]place.Origin{{Root: carrier}}, nil, storageDestroy, node.block, loans) if typ, ok := symbols.GetSymbolType(carrier); ok && typeinfo.NeedsDrop(typ) { @@ -371,7 +415,7 @@ func (a *analyzer) applyBlockExit(node *site, st state, loans *loanContext) { } } if len(cleanup) > 0 { - a.cleanup.AfterScope[ir.NodeID(node.block.ID())] = symbolIDs(cleanup) + a.cleanup.AfterScope[node.cfgSite.ID] = symbolIDs(cleanup) } clearScopeOwnership(node.scope, st) } @@ -484,6 +528,7 @@ func (a *analyzer) applyStmt(node *site, st state) { a.checkPointerEscape(scope, s.Value, st) a.validateReferenceReturn(scope, s, st) a.checkExpr(scope, s.Value, st, useConsume, loans, false) + releaseIterationLoans(st, loans, 0) a.cleanupBeforeReturn(scope, s, st, loans) case *ast.ExprStmt: a.checkExpr(scope, s.Expr, st, useRead, loans, false) @@ -493,7 +538,31 @@ func (a *analyzer) applyStmt(node *site, st state) { case *ast.IfStmt: a.checkExpr(scope, s.Cond, st, useRead, loans, false) case *ast.ForStmt: - a.checkExpr(scope, s.Cond, st, useRead, loans, false) + if s.Iterable == nil { + a.checkExpr(scope, s.Cond, st, useRead, loans, false) + break + } + evidence, found := a.module.Semantics.ForIterations[s.ID()] + if !found || evidence.Kind != project.ForIterationSequence || evidence.Carrier == nil { + a.checkExpr(scope, s.Iterable, st, useRead, loans, false) + break + } + a.checkExpr(scope, s.Iterable, st, useRead, loans, true) + a.checkStorageAccess(s.Iterable, loans, storageSharedBorrow) + origins := a.originsForExpr(s.Iterable) + if ident, ok := s.Iterable.(*ast.Ident); ok { + sym := a.module.Semantics.ResolvedSymbols[ident.ID()] + if value, found := st.references[sym]; found { + origins = referenceOrigins(value) + } + } else if value, hasValue := a.referenceValueForExpr(s.Iterable, st); hasValue { + origins = referenceOrigins(value) + } + if len(origins) > 0 { + st.references[evidence.Carrier] = []referenceLoan{{ + id: loanID{node: s.Iterable}, origins: origins, site: s.Iterable, loop: s.ID(), + }} + } case *ast.MatchStmt: a.checkExpr(scope, s.Subject, st, useRead, loans, false) } @@ -527,7 +596,7 @@ func (a *analyzer) applyMatchEdge(node *site, edge cfg.Edge, st state) { "move-only variant payload cannot be moved from partial place; borrow it instead", ast.LocOf(subject), "") } if movesCarrier && carrier != nil { - moveSite, _ := a.module.TypedASTNodes[arm.ArmID].(ast.Node) + moveSite, _ := a.module.TypedASTNodes[arm.ArmID] if moveSite == nil { moveSite = subject } diff --git a/internal/semantics/ownership/ownership_test.go b/internal/semantics/ownership/ownership_test.go index b2bc15f4..580c6ced 100644 --- a/internal/semantics/ownership/ownership_test.go +++ b/internal/semantics/ownership/ownership_test.go @@ -49,7 +49,10 @@ func checkOwnershipSource(t *testing.T, src string) *ownershipResult { resolver.Resolve(ctx, module) typechecker.Check(ctx, module) module.TypedASTNodes = ast.Index(module.AST) - module.CFG = cfg.BuildModule(module.AST, module.Semantics.MatchCases) + module.CFG = cfg.BuildModule(module.AST, cfg.BuildQueries{ + MatchCases: module.Semantics.MatchCases, + LoopGuaranteedEntry: module.Semantics.ForLoopGuaranteedEntry, + }) module.Flow = typechecker.CheckFlow(ctx, module) module.Ownership = Check(ctx, module) return &ownershipResult{DiagnosticBag: diag, ctx: ctx, module: module} @@ -123,6 +126,31 @@ func cleanupPlanForFunction(t *testing.T, result *ownershipResult, fn *ast.FnDec return plan } +func scopeExitSiteID(t *testing.T, graph *cfg.Graph, blockID ast.NodeID) cfg.SiteID { + t.Helper() + var id cfg.SiteID + found := false + for _, block := range graph.Blocks { + if block == nil || !block.Reachable { + continue + } + for _, site := range block.Sites { + if site == nil || site.Kind != cfg.SiteScopeExit || site.NodeID != ir.NodeID(blockID) { + continue + } + if found { + t.Fatalf("multiple scope exits for block %d", blockID) + } + id = site.ID + found = true + } + } + if !found { + t.Fatalf("scope exit for block %d missing", blockID) + } + return id +} + func cleanupSymbolNames(module *project.Module, cleanup []symbols.SymbolID) []string { names := make(map[symbols.SymbolID]string) if module != nil && module.ModuleScope != nil { @@ -215,7 +243,7 @@ func TestOwnershipCheckClearsAllDerivedPlans(t *testing.T) { fn := result.module.AST.Stmts[0].(*ast.FnDecl) plan := cleanupPlanForFunction(t, result, fn) staleID := ir.NodeID(999999) - plan.AfterScope[staleID] = []symbols.SymbolID{999999} + plan.AfterScope[cfg.SiteID{Block: 999999, Index: 999999}] = []symbols.SymbolID{999999} plan.BeforeReturn[staleID] = []symbols.SymbolID{999999} plan.BeforeAssign[staleID] = struct{}{} plan.DiscardedValue[staleID] = struct{}{} @@ -247,11 +275,13 @@ fn second() { let two = make(); }`) if len(firstPlan.AfterScope) != 1 || len(secondPlan.AfterScope) != 1 { t.Fatalf("unexpected function cleanup plans: first=%#v second=%#v", firstPlan, secondPlan) } - if _, found := firstPlan.AfterScope[ir.NodeID(second.Body.ID())]; found { - t.Fatalf("first function received second function cleanup") + firstGraph := result.module.CFG.Function(ir.NodeID(first.ID())) + secondGraph := result.module.CFG.Function(ir.NodeID(second.ID())) + if got := cleanupSymbolNames(result.module, firstPlan.AfterScope[scopeExitSiteID(t, firstGraph, first.Body.ID())]); !slices.Equal(got, []string{"one"}) { + t.Fatalf("first function cleanup = %v, want [one]", got) } - if _, found := secondPlan.AfterScope[ir.NodeID(first.Body.ID())]; found { - t.Fatalf("second function received first function cleanup") + if got := cleanupSymbolNames(result.module, secondPlan.AfterScope[scopeExitSiteID(t, secondGraph, second.Body.ID())]); !slices.Equal(got, []string{"two"}) { + t.Fatalf("second function cleanup = %v, want [two]", got) } } @@ -312,15 +342,87 @@ fn main() { } fn := result.module.AST.Stmts[1].(*ast.FnDecl) nested := fn.Body.Stmts[1].(*ast.BlockStmt) + graph := result.module.CFG.Function(ir.NodeID(fn.ID())) plan := cleanupPlanForFunction(t, result, fn) - if got := cleanupSymbolNames(result.module, plan.AfterScope[ir.NodeID(nested.ID())]); !slices.Equal(got, []string{"nested"}) { + if got := cleanupSymbolNames(result.module, plan.AfterScope[scopeExitSiteID(t, graph, nested.ID())]); !slices.Equal(got, []string{"nested"}) { t.Fatalf("nested cleanup = %v, want [nested]", got) } - if got := cleanupSymbolNames(result.module, plan.AfterScope[ir.NodeID(fn.Body.ID())]); !slices.Equal(got, []string{"last", "first"}) { + if got := cleanupSymbolNames(result.module, plan.AfterScope[scopeExitSiteID(t, graph, fn.Body.ID())]); !slices.Equal(got, []string{"last", "first"}) { t.Fatalf("function cleanup = %v, want [last first]", got) } } +func TestCleanupPlanDistinguishesLoopExitSites(t *testing.T) { + result := checkOwnershipSource(t, `fn make() -> *i32; +fn main() { + for i in 0..3 { + let first = make(); + if i == 0 { continue; } + let second = make(); + if i == 1 { break; } + } +}`) + if result.HasErrors() { + t.Fatalf("unexpected loop cleanup diagnostics:\n%s", result.EmitAllToString()) + } + + fn := result.module.AST.Stmts[1].(*ast.FnDecl) + loop := fn.Body.Stmts[0].(*ast.ForStmt) + continueStmt := loop.Body.Stmts[1].(*ast.IfStmt).Then.Stmts[0].(*ast.ContinueStmt) + breakStmt := loop.Body.Stmts[3].(*ast.IfStmt).Then.Stmts[0].(*ast.BreakStmt) + graph := result.module.CFG.Function(ir.NodeID(fn.ID())) + plan := cleanupPlanForFunction(t, result, fn) + + var continueExit, breakExit, fallthroughExit cfg.SiteID + continueFound := false + breakFound := false + fallthroughFound := false + for _, block := range graph.Blocks { + if block == nil || !block.Reachable { + continue + } + hasContinue := false + hasBreak := false + var exit *cfg.Site + for _, site := range block.Sites { + if site == nil { + continue + } + hasContinue = hasContinue || site.NodeID == ir.NodeID(continueStmt.ID()) + hasBreak = hasBreak || site.NodeID == ir.NodeID(breakStmt.ID()) + if site.Kind == cfg.SiteScopeExit && site.NodeID == ir.NodeID(loop.Body.ID()) { + exit = site + } + } + if exit == nil { + continue + } + switch { + case hasContinue: + continueExit, continueFound = exit.ID, true + case hasBreak: + breakExit, breakFound = exit.ID, true + default: + fallthroughExit, fallthroughFound = exit.ID, true + } + } + if !continueFound || !breakFound || !fallthroughFound { + t.Fatalf("loop exits missing: continue=%v break=%v fallthrough=%v", continueFound, breakFound, fallthroughFound) + } + if continueExit == breakExit || continueExit == fallthroughExit || breakExit == fallthroughExit { + t.Fatalf("loop exits share CFG identity: continue=%v break=%v fallthrough=%v", continueExit, breakExit, fallthroughExit) + } + if got := cleanupSymbolNames(result.module, plan.AfterScope[continueExit]); !slices.Equal(got, []string{"first"}) { + t.Fatalf("continue cleanup = %v, want [first]", got) + } + if got := cleanupSymbolNames(result.module, plan.AfterScope[breakExit]); !slices.Equal(got, []string{"second", "first"}) { + t.Fatalf("break cleanup = %v, want [second first]", got) + } + if got := cleanupSymbolNames(result.module, plan.AfterScope[fallthroughExit]); !slices.Equal(got, []string{"second", "first"}) { + t.Fatalf("fallthrough cleanup = %v, want [second first]", got) + } +} + func TestReturnCleanupSuppressesMovedResult(t *testing.T) { result := checkOwnershipSource(t, `fn pass(value: *i32, spare: *i32) -> *i32 { return value; @@ -335,6 +437,20 @@ func TestReturnCleanupSuppressesMovedResult(t *testing.T) { } } +func TestReturnExpressionEndsOrdinaryReferenceLoansBeforeCleanup(t *testing.T) { + result := checkOwnershipSource(t, `fn ranges(xs: [4]i32) -> i32 { + let prefix = xs[..2]; + let suffix = xs[2..]; + let middle = xs[1..3]; + let inclusive = xs[1..=2]; + let full = xs[..]; + return prefix[0] + suffix[0] + middle[0] + inclusive[0] + full[0]; +}`) + if result.HasErrors() { + t.Fatalf("unexpected return diagnostics:\n%s", result.EmitAllToString()) + } +} + func TestReturnCleanupClearsStateBeforeExitMerge(t *testing.T) { result := checkOwnershipSource(t, `fn make() -> *i32; fn main(cond: bool) { @@ -659,18 +775,148 @@ fn valid(resource: Resource) { function, _ := result.module.ModuleScope.Lookup("valid") resource, _ := function.Scope.Lookup("resource") ownedBodyID := ir.NodeID(match.Arms[0].Body.ID()) - pendingBodyID := ir.NodeID(match.Arms[1].Body.ID()) + graph := result.module.CFG.Function(ir.NodeID(fn.ID())) if got := plan.MatchCarrierMoves[ownedBodyID]; got != resource.ID { t.Fatalf("owned arm carrier move = %d, want %d", got, resource.ID) } - if got := cleanupSymbolNames(result.module, plan.AfterScope[pendingBodyID]); !slices.Equal(got, []string{"resource"}) { + if got := cleanupSymbolNames(result.module, plan.AfterScope[scopeExitSiteID(t, graph, match.Arms[1].Body.ID())]); !slices.Equal(got, []string{"resource"}) { t.Fatalf("pending arm cleanup = %v, want [resource]", got) } - if got := cleanupSymbolNames(result.module, plan.AfterScope[ir.NodeID(fn.Body.ID())]); slices.Contains(got, "resource") { + if got := cleanupSymbolNames(result.module, plan.AfterScope[scopeExitSiteID(t, graph, fn.Body.ID())]); slices.Contains(got, "resource") { t.Fatalf("dead carrier remains in function cleanup: %v", got) } } +func TestExternalMoveOnlyMatchCarrierConvergesThroughGuaranteedLoopEntry(t *testing.T) { + result := checkOwnershipSource(t, `enum Resource { + Owned: { value: *i32 }, + Pending +} +fn valid(resource: Resource) { + for i in 0..1 { + match resource { + Resource::Owned with { value = owned } => { + free(owned); + break; + } + Resource::Pending => { + break; + } + } + } +}`) + if result.HasErrors() { + t.Fatalf("unexpected guaranteed-entry diagnostics:\n%s", result.EmitAllToString()) + } +} + +func TestExternalMoveOnlyMatchCarrierRequiresGuaranteedLoopEntry(t *testing.T) { + for _, test := range []struct { + name string + header string + params string + }{ + {name: "empty constant range", header: "1..1"}, + {name: "runtime range", header: "start..end", params: ", start: i32, end: i32"}, + } { + t.Run(test.name, func(t *testing.T) { + result := checkOwnershipSource(t, `enum Resource { + Owned: { value: *i32 }, + Pending +} +fn invalid(resource: Resource`+test.params+`) { + for i in `+test.header+` { + match resource { + Resource::Owned with { value = owned } => { + free(owned); + break; + } + Resource::Pending => { + break; + } + } + } +}`) + if !hasOwnershipCode(result, diagnostics.ErrInvalidAssignment) || + !strings.Contains(result.EmitAllToString(), "ownership state differs across control-flow paths") { + t.Fatalf("expected zero-entry convergence diagnostic, got:\n%s", result.EmitAllToString()) + } + }) + } +} + +func TestDeadMoveOnlyMatchCarrierConvergesThroughLoopTransfers(t *testing.T) { + for _, test := range []struct { + name string + transfer string + }{ + {name: "break", transfer: "break"}, + {name: "continue", transfer: "continue"}, + } { + t.Run(test.name, func(t *testing.T) { + result := checkOwnershipSource(t, `enum Resource { + Owned: { value: *i32 }, + Pending +} +fn valid() { + for i in 0..2 { + let resource = Resource::Owned with .{ value = alloc(i) }; + match resource { + Resource::Owned with { value = owned } => { + free(owned); + `+test.transfer+`; + } + Resource::Pending => { + `+test.transfer+`; + } + } + } +}`) + if result.HasErrors() { + t.Fatalf("unexpected match %s diagnostics:\n%s", test.transfer, result.EmitAllToString()) + } + + fn := result.module.AST.Stmts[1].(*ast.FnDecl) + loop := fn.Body.Stmts[0].(*ast.ForStmt) + match := loop.Body.Stmts[1].(*ast.MatchStmt) + plan := cleanupPlanForFunction(t, result, fn) + graph := result.module.CFG.Function(ir.NodeID(fn.ID())) + if got := cleanupSymbolNames(result.module, plan.AfterScope[scopeExitSiteID(t, graph, match.Arms[0].Body.ID())]); slices.Contains(got, "resource") { + t.Fatalf("consuming arm received duplicate carrier cleanup: %v", got) + } + if got := cleanupSymbolNames(result.module, plan.AfterScope[scopeExitSiteID(t, graph, match.Arms[1].Body.ID())]); !slices.Equal(got, []string{"resource"}) { + t.Fatalf("preserving arm cleanup = %v, want [resource]", got) + } + }) + } +} + +func TestDeadMoveOnlyMatchCarrierConvergesThroughNestedLoopBreak(t *testing.T) { + result := checkOwnershipSource(t, `enum Resource { + Owned: { value: *i32 }, + Pending +} +fn valid() { + for outer in 0..2 { + for inner in 0..2 { + let resource = Resource::Owned with .{ value = alloc(outer + inner) }; + match resource { + Resource::Owned with { value = owned } => { + free(owned); + break; + } + Resource::Pending => { + break; + } + } + } + } +}`) + if result.HasErrors() { + t.Fatalf("unexpected nested match break diagnostics:\n%s", result.EmitAllToString()) + } +} + func TestLiveMoveOnlyMatchCarrierMustConverge(t *testing.T) { result := checkOwnershipSource(t, `enum Resource { Owned: { value: *i32 }, @@ -802,14 +1048,14 @@ fn valid(resource: Resource) { match := fn.Body.Stmts[0].(*ast.MatchStmt) plan := cleanupPlanForFunction(t, result, fn) ownedBodyID := ir.NodeID(match.Arms[0].Body.ID()) - pendingBodyID := ir.NodeID(match.Arms[1].Body.ID()) + graph := result.module.CFG.Function(ir.NodeID(fn.ID())) if got := plan.MatchFieldDrops[ownedBodyID]; !slices.Equal(got, []int{0}) { t.Fatalf("owned discard drops = %v, want [0]", got) } - if got := cleanupSymbolNames(result.module, plan.AfterScope[ownedBodyID]); slices.Contains(got, "resource") { + if got := cleanupSymbolNames(result.module, plan.AfterScope[scopeExitSiteID(t, graph, match.Arms[0].Body.ID())]); slices.Contains(got, "resource") { t.Fatalf("consuming arm received duplicate carrier cleanup: %v", got) } - if got := cleanupSymbolNames(result.module, plan.AfterScope[pendingBodyID]); !slices.Equal(got, []string{"resource"}) { + if got := cleanupSymbolNames(result.module, plan.AfterScope[scopeExitSiteID(t, graph, match.Arms[1].Body.ID())]); !slices.Equal(got, []string{"resource"}) { t.Fatalf("preserving arm cleanup = %v, want [resource]", got) } } @@ -999,10 +1245,11 @@ fn consume(resource: Resource) { if got := plan.MatchCarrierMoves[bodyID]; got != resource.ID { t.Fatalf("match carrier move = %d, want %d", got, resource.ID) } - if got := cleanupSymbolNames(result.module, plan.AfterScope[ir.NodeID(match.Arms[0].Body.ID())]); !slices.Equal(got, []string{"selected"}) { + graph := result.module.CFG.Function(ir.NodeID(fn.ID())) + if got := cleanupSymbolNames(result.module, plan.AfterScope[scopeExitSiteID(t, graph, match.Arms[0].Body.ID())]); !slices.Equal(got, []string{"selected"}) { t.Fatalf("arm binding cleanup = %v, want [selected]", got) } - if got := cleanupSymbolNames(result.module, plan.AfterScope[ir.NodeID(fn.Body.ID())]); slices.Contains(got, "resource") { + if got := cleanupSymbolNames(result.module, plan.AfterScope[scopeExitSiteID(t, graph, fn.Body.ID())]); slices.Contains(got, "resource") { t.Fatalf("consumed carrier remains in function cleanup: %v", got) } } @@ -1827,6 +2074,111 @@ fn main(flag: bool) { } } +func TestForInRejectsMovedIterable(t *testing.T) { + result := checkOwnershipSource(t, `fn Take(_: []i32) {} +fn bad(values: []i32) { + Take(values); + for value in values {} +}`) + if !hasOwnershipCode(result, diagnostics.ErrUseAfterMove) { + t.Fatalf("expected moved iterable diagnostic, got:\n%s", result.EmitAllToString()) + } +} + +func TestForInKeepsSequenceStorageSharedBorrowed(t *testing.T) { + tests := []struct { + name string + src string + }{ + {name: "fixed mutation before break", src: `fn bad(mut values: [2]i32) { + for value in values { + values[0] = 3; + break; + } +}`}, + {name: "dynamic append before break", src: `fn bad(mut values: []i32) { + for value in values { + append(&mut values, 3); + break; + } +}`}, + {name: "dynamic move before break", src: `fn Take(_: []i32) {} +fn bad(values: []i32) { + for value in values { + Take(values); + break; + } +}`}, + {name: "slice backing mutation before break", src: `fn bad(mut values: [2]i32) { + let view = values[..]; + for value in view { + values[0] = 3; + break; + } +}`}, + } + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + result := checkOwnershipSource(t, test.src) + if !hasOwnershipCode(result, diagnostics.ErrBorrowConflict) { + t.Fatalf("expected iteration borrow conflict, got:\n%s", result.EmitAllToString()) + } + }) + } +} + +func TestForInLoanEndsBeforeLoopExitStatements(t *testing.T) { + result := checkOwnershipSource(t, `fn valid(mut fixed: [2]i32, mut dynamic: []i32) { + for value in fixed { break; } + fixed[0] = 3; + for value in dynamic { break; } + append(&mut dynamic, 4); +}`) + if result.HasErrors() { + t.Fatalf("unexpected diagnostics after loop exits:\n%s", result.EmitAllToString()) + } +} + +func TestForInLoanEndsBeforeReturnCleanup(t *testing.T) { + tests := []struct { + name string + src string + }{ + {name: "fixed", src: `fn valid(values: [2]i32) { + for value in values { return; } +}`}, + {name: "dynamic", src: `fn valid(values: []i32) { + for value in values { return; } +}`}, + {name: "slice", src: `fn valid(values: &[..]i32) { + for value in values { return; } +}`}, + {name: "nested", src: `fn valid(first: [2]i32, second: []i32) { + for outer in first { + for inner in second { return; } + } +}`}, + } + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + result := checkOwnershipSource(t, test.src) + if result.HasErrors() { + t.Fatalf("unexpected return cleanup diagnostics:\n%s", result.EmitAllToString()) + } + }) + } +} + +func TestForInReturnExpressionStillUsesActiveLoan(t *testing.T) { + result := checkOwnershipSource(t, `fn invalid(values: []i32) -> []i32 { + for value in values { return values; } + return []i32{}; +}`) + if !hasOwnershipCode(result, diagnostics.ErrBorrowConflict) { + t.Fatalf("expected return expression borrow conflict, got:\n%s", result.EmitAllToString()) + } +} + func TestMoveInLoopRejectsLaterUse(t *testing.T) { diag := checkOwnershipSource(t, `struct Buffer { ptr: *u8, diff --git a/internal/semantics/ownership/reference.go b/internal/semantics/ownership/reference.go index a3b02249..b5c4b8f9 100644 --- a/internal/semantics/ownership/reference.go +++ b/internal/semantics/ownership/reference.go @@ -23,6 +23,7 @@ type referenceLoan struct { origins []place.Origin mutable bool site ast.Node + loop ast.NodeID } type symbolUse struct { @@ -76,9 +77,17 @@ func (a *analyzer) newLoanContext(node *site, st state) *loanContext { for _, use := range a.symbolUseSequence(node, referenceHoldingSymbol) { ctx.remaining[use.symbol]++ } - for sym, keepingAlive := range a.symbolLiveIn[node.cfgSite.ID] { - value, tracked := st.references[sym] - if !tracked { + for sym, value := range st.references { + keepingAlive, live := a.symbolLiveIn[node.cfgSite.ID][sym] + if !live { + for _, loan := range value { + if loan.loop != 0 { + live = true + break + } + } + } + if !live { continue } for _, loan := range value { @@ -516,7 +525,7 @@ func sameReferenceLoans(left, right []referenceLoan) bool { return false } rightLoan := right[index] - if leftLoan.mutable != rightLoan.mutable || leftLoan.site != rightLoan.site || + if leftLoan.mutable != rightLoan.mutable || leftLoan.site != rightLoan.site || leftLoan.loop != rightLoan.loop || !place.SameOrigins(leftLoan.origins, rightLoan.origins) { return false } diff --git a/internal/semantics/ownershipresult/result.go b/internal/semantics/ownershipresult/result.go index 926758c7..63589b5f 100644 --- a/internal/semantics/ownershipresult/result.go +++ b/internal/semantics/ownershipresult/result.go @@ -2,12 +2,13 @@ package ownershipresult import ( "compiler/internal/ir" + "compiler/internal/ir/cfg" "compiler/internal/semantics/symbols" ) -// CleanupPlan records ownership effects at stable HIR source sites. +// CleanupPlan records ownership effects at CFG and stable HIR source sites. type CleanupPlan struct { - AfterScope map[ir.NodeID][]symbols.SymbolID + AfterScope map[cfg.SiteID][]symbols.SymbolID BeforeReturn map[ir.NodeID][]symbols.SymbolID BeforeAssign map[ir.NodeID]struct{} DiscardedValue map[ir.NodeID]struct{} diff --git a/internal/semantics/resolver/resolver.go b/internal/semantics/resolver/resolver.go index f91cb72f..f4c075fb 100644 --- a/internal/semantics/resolver/resolver.go +++ b/internal/semantics/resolver/resolver.go @@ -169,10 +169,28 @@ func (r *resolver) resolveStmt(scope *symbols.Scope, stmt ast.Stmt) { r.resolveStmt(scope, node.Else) } case *ast.ForStmt: + if node.Iterable != nil { + r.resolveExpr(scope, node.Iterable) + bodyScope := symbols.NewScope(scope) + if node.Index != nil { + if binding := r.resolveLocalBinding(bodyScope, node.Index, symbols.SymbolVar, nil, node.Index, ast.LocOf(node.Index)); binding != nil { + r.module.Semantics.ResolvedSymbols[node.Index.ID()] = binding + } + } + if node.Value != nil { + if binding := r.resolveLocalBinding(bodyScope, node.Value, symbols.SymbolVar, nil, node.Value, ast.LocOf(node.Value)); binding != nil { + r.module.Semantics.ResolvedSymbols[node.Value.ID()] = binding + } + } + r.resolveBlock(bodyScope, node.Body) + return + } if node.Cond != nil { r.resolveExpr(scope, node.Cond) } r.resolveBlock(symbols.NewScope(scope), node.Body) + case *ast.BreakStmt, *ast.ContinueStmt: + // Legality (inside a loop) is diagnosed by the typechecker. case *ast.MatchStmt: r.resolveExpr(scope, node.Subject) for _, arm := range node.Arms { @@ -181,8 +199,7 @@ func (r *resolver) resolveStmt(scope *symbols.Scope, stmt ast.Stmt) { } armScope := symbols.NewScope(scope) if arm.Binding != nil && !arm.Discard { - r.resolveLocalBinding(armScope, arm.Binding, symbols.SymbolVar, nil, arm.Binding, arm.Location) - if binding, found := armScope.LookupNode(arm.Binding); found { + if binding := r.resolveLocalBinding(armScope, arm.Binding, symbols.SymbolVar, nil, arm.Binding, arm.Location); binding != nil { r.module.Semantics.ResolvedSymbols[arm.Binding.ID()] = binding } } @@ -190,8 +207,7 @@ func (r *resolver) resolveStmt(scope *symbols.Scope, stmt ast.Stmt) { if field.Discard { continue } - r.resolveLocalBinding(armScope, field.Binding, symbols.SymbolVar, nil, field.Binding, field.Location) - if binding, found := armScope.LookupNode(field.Binding); found { + if binding := r.resolveLocalBinding(armScope, field.Binding, symbols.SymbolVar, nil, field.Binding, field.Location); binding != nil { r.module.Semantics.ResolvedSymbols[field.Binding.ID()] = binding } } @@ -210,7 +226,7 @@ func (r *resolver) resolveStmt(scope *symbols.Scope, stmt ast.Stmt) { } } -func (r *resolver) resolveLocalBinding(scope *symbols.Scope, name *ast.Ident, kind symbols.Kind, value ast.Expr, node ast.Node, loc *source.Location) { +func (r *resolver) resolveLocalBinding(scope *symbols.Scope, name *ast.Ident, kind symbols.Kind, value ast.Expr, node ast.Node, loc *source.Location) *symbols.Symbol { sym := symbols.New(name.Name, kind, node, ast.LocOf(name)) if declaration, ok := node.(*ast.LetDecl); ok { sym.MutableLocation = declaration.MutableLocation @@ -218,12 +234,13 @@ func (r *resolver) resolveLocalBinding(scope *symbols.Scope, name *ast.Ident, ki sym.Initializing = true if err := scope.Declare(sym); err != nil { problems.ReportRedeclaration(r.ctx.Diagnostics, scope, err.Error(), name.Name, loc) - return + return nil } if value != nil { r.resolveExpr(scope, value) } sym.Initializing = false + return sym } func (r *resolver) resolveExpr(scope *symbols.Scope, expr ast.Expr) { diff --git a/internal/semantics/typechecker/check_expr.go b/internal/semantics/typechecker/check_expr.go index 2a9b9c0a..31e5f03c 100644 --- a/internal/semantics/typechecker/check_expr.go +++ b/internal/semantics/typechecker/check_expr.go @@ -707,8 +707,8 @@ func (c *checker) typeRangeIndexExpr(scope *symbols.Scope, node *ast.IndexExpr, return &typeinfo.InvalidType{} } if isStringSequence(baseType) { - c.checkRangeBound(scope, rangeIndex.Start) - c.checkRangeBound(scope, rangeIndex.End) + c.checkRangeBound(scope, rangeIndex.Start, typeinfo.DefaultIntegerType()) + c.checkRangeBound(scope, rangeIndex.End, typeinfo.DefaultIntegerType()) return &typeinfo.RefType{Target: &typeinfo.StringType{}} } elem, shape, ok := indexableSequence(baseType) @@ -717,8 +717,8 @@ func (c *checker) typeRangeIndexExpr(scope *symbols.Scope, node *ast.IndexExpr, "slicing requires array or slice value")) return &typeinfo.InvalidType{} } - c.checkRangeBound(scope, rangeIndex.Start) - c.checkRangeBound(scope, rangeIndex.End) + c.checkRangeBound(scope, rangeIndex.Start, typeinfo.DefaultIntegerType()) + c.checkRangeBound(scope, rangeIndex.End, typeinfo.DefaultIntegerType()) exprType := func(expr ast.Expr) typeinfo.Type { return c.typeExpr(scope, expr, nil) } @@ -796,19 +796,20 @@ func indexableSequence(t typeinfo.Type) (typeinfo.Type, indexableSequenceShape, return nil, 0, false } -func (c *checker) checkRangeBound(scope *symbols.Scope, expr ast.Expr) { +func (c *checker) checkRangeBound(scope *symbols.Scope, expr ast.Expr, expected typeinfo.Type) typeinfo.Type { if c == nil || expr == nil { - return + return nil } - boundType := c.typeExpr(scope, expr, typeinfo.DefaultIntegerType()) + boundType := c.typeExpr(scope, expr, expected) boundType = c.requireValueType(expr, boundType, "range bound") if typeinfo.IsInvalidOrUnknown(boundType) { - return + return boundType } if !typeinfo.IsIntegral(boundType) { c.ctx.Diagnostics.Add(invalidOperationError(expr, "range bound must be an integer")) } + return boundType } func (c *checker) typeStructLit(scope *symbols.Scope, node *ast.StructLit, expected typeinfo.Type) typeinfo.Type { diff --git a/internal/semantics/typechecker/check_stmt.go b/internal/semantics/typechecker/check_stmt.go index 2b3aac02..e593b261 100644 --- a/internal/semantics/typechecker/check_stmt.go +++ b/internal/semantics/typechecker/check_stmt.go @@ -3,9 +3,11 @@ package typechecker import ( "fmt" + "compiler/internal/constvalue" "compiler/internal/diagnostics" "compiler/internal/frontend/ast" "compiler/internal/project" + "compiler/internal/semantics/consteval" "compiler/internal/semantics/flowresult" "compiler/internal/semantics/place" "compiler/internal/semantics/symbols" @@ -81,6 +83,10 @@ func (c *checker) checkStmt(scope *symbols.Scope, stmt ast.Stmt, returnType type c.checkBlock(scope, node.Then, returnType) c.checkStmt(scope, node.Else, returnType) case *ast.ForStmt: + if node.Iterable != nil { + c.checkForInStmt(scope, node, returnType) + return + } if node.Cond != nil { condType := c.typeExpr(scope, node.Cond, nil) if condType != nil && !typeinfo.IsInvalidOrUnknown(condType) && !typeinfo.IsCondition(condType) { @@ -90,7 +96,21 @@ func (c *checker) checkStmt(scope *symbols.Scope, stmt ast.Stmt, returnType type if c.siteOnly { return } + c.loopDepth++ c.checkBlock(scope, node.Body, returnType) + c.loopDepth-- + case *ast.BreakStmt, *ast.ContinueStmt: + if c.siteOnly { + return + } + if c.loopDepth == 0 { + jump := "break" + if _, ok := stmt.(*ast.ContinueStmt); ok { + jump = "continue" + } + c.ctx.Diagnostics.AddError(diagnostics.ErrInvalidStatement, + jump+" outside loop", ast.LocOf(stmt), "`"+jump+"` exits or restarts the innermost for loop") + } case *ast.MatchStmt: c.checkMatchStmt(scope, node, returnType) case *ast.ExprStmt: @@ -480,6 +500,170 @@ func (c *checker) checkBinding(scope *symbols.Scope, node ast.Stmt, requireIniti } } +// checkForInStmt types a `for x in iterable` loop. The iterable may be a range +// expression or an indexable sequence; strings are rejected because string +// element access requires an explicit as_bytes/as_chars view. +func (c *checker) checkForInStmt(scope *symbols.Scope, node *ast.ForStmt, returnType typeinfo.Type) { + indexType := typeinfo.DefaultIntegerType() + evidence := project.ForIteration{} + if node.Index != nil { + evidence.Index = c.module.Semantics.ResolvedSymbols[node.Index.ID()] + } + if node.Value != nil { + evidence.Value = c.module.Semantics.ResolvedSymbols[node.Value.ID()] + } + valid := node.Value != nil && node.Value.Name != "" && evidence.Value != nil + if node.Index != nil && (node.Index.Name == "" || evidence.Index == nil) { + valid = false + } + + var elemType typeinfo.Type + if rangeExpr, ok := node.Iterable.(*ast.RangeExpr); ok { + evidence.Kind = project.ForIterationRange + if !rangeExpr.EndExclusive { + valid = false + c.ctx.Diagnostics.Add(invalidExpressionError(rangeExpr, "for range requires an exclusive end; use `..` instead of `..=`")) + } + _, badStart := rangeExpr.Start.(*ast.BadExpr) + if rangeExpr.Start == nil || badStart { + valid = false + c.ctx.Diagnostics.Add(invalidExpressionError(rangeExpr, "range iteration requires a start bound")) + } + _, badEnd := rangeExpr.End.(*ast.BadExpr) + if rangeExpr.End == nil || badEnd { + valid = false + c.ctx.Diagnostics.Add(invalidExpressionError(rangeExpr, "range iteration requires an end bound")) + } + + defaultType := typeinfo.DefaultIntegerType() + startNumber, startLiteral := rangeExpr.Start.(*ast.NumberLit) + endNumber, endLiteral := rangeExpr.End.(*ast.NumberLit) + startUntyped := startLiteral && startNumber.ExplicitType == "" + endUntyped := endLiteral && endNumber.ExplicitType == "" + var startType, endType typeinfo.Type + if startUntyped && !endUntyped { + endType = c.checkRangeBound(scope, rangeExpr.End, defaultType) + startType = c.checkRangeBound(scope, rangeExpr.Start, endType) + } else { + startType = c.checkRangeBound(scope, rangeExpr.Start, defaultType) + endExpected := defaultType + if endUntyped && !typeinfo.IsInvalidOrUnknown(startType) { + endExpected = startType + } + endType = c.checkRangeBound(scope, rangeExpr.End, endExpected) + } + if typeinfo.IsInvalidOrUnknown(startType) || typeinfo.IsInvalidOrUnknown(endType) || + !typeinfo.IsIntegral(startType) || !typeinfo.IsIntegral(endType) { + valid = false + } else { + elemType = typeinfo.CommonNumericType(startType, endType) + if elemType == nil || !typeinfo.IsIntegral(elemType) { + valid = false + c.ctx.Diagnostics.Add(typeMismatchError(rangeExpr, + "range bounds of type "+typeinfo.TypeText(startType)+" and "+typeinfo.TypeText(endType)+" have no common integer type")) + } else { + if !c.assignable(elemType, startType, rangeExpr.Start) || !c.assignable(elemType, endType, rangeExpr.End) { + valid = false + } + } + } + if valid { + startValue, startFound := consteval.EvaluateExpr(c.ctx, c.module, scope, rangeExpr.Start, elemType) + endValue, endFound := consteval.EvaluateExpr(c.ctx, c.module, scope, rangeExpr.End, elemType) + start, startIntegral := startValue.(*constvalue.IntConst) + end, endIntegral := endValue.(*constvalue.IntConst) + if startFound && endFound && startIntegral && endIntegral && start.Int().Cmp(end.Int()) < 0 { + evidence.GuaranteedEntry = true + } + } + evidence.ElementType = elemType + } else { + evidence.Kind = project.ForIterationSequence + var ok bool + indexType, ok = typeinfo.NumericTypeFromName("usize", c.ctx.Target) + if !ok { + panic("missing builtin usize type") + } + iterableType := c.typeExpr(scope, node.Iterable, nil) + iterableType = c.requireValueType(node.Iterable, iterableType, "iterable") + + if typeinfo.IsInvalidOrUnknown(iterableType) { + valid = false + } else if isStringSequence(iterableType) { + valid = false + c.ctx.Diagnostics.Add(invalidExpressionError(node.Iterable, + "string iteration requires `value |> as_bytes()` or `value |> as_chars()`")) + } else if elem, shape, ok := indexableSequence(iterableType); ok { + elemType = elem + if shape == indexableFixedArray || shape == indexableDynamicArray { + exprType := func(expr ast.Expr) typeinfo.Type { + return c.typeExpr(scope, expr, nil) + } + if !place.Addressable(scope, node.Iterable, exprType, c.expandedDefaultBinding) { + valid = false + c.ctx.Diagnostics.Add(invalidExpressionError(node.Iterable, + "for-in requires addressable array storage")) + } + } + if !typeinfo.IsImplicitCopyType(elem) { + valid = false + c.ctx.Diagnostics.Add(invalidExpressionError(node.Iterable, + "for-in requires copyable sequence elements; iterate indexes and borrow move-only elements explicitly")) + } + if _, array := typeinfo.Underlying(iterableType).(*typeinfo.ArrayType); array { + evidence.CarrierType = &typeinfo.RefType{Target: iterableType} + } else { + evidence.CarrierType = iterableType + } + evidence.ElementType = elem + } else { + valid = false + c.ctx.Diagnostics.Add(invalidExpressionError(node.Iterable, + "cannot iterate over "+typeinfo.TypeText(iterableType))) + } + } + if node.Index != nil { + c.bindLoopVariable(node.Index, indexType) + } + if node.Value != nil { + c.bindLoopVariable(node.Value, elemType) + } + if c.siteOnly { + return + } + delete(c.module.Semantics.ForIterations, node.ID()) + if valid && elemType != nil && !typeinfo.IsInvalidOrUnknown(elemType) { + location := ast.LocOf(node) + evidence.Cursor = symbols.New("$for.cursor", symbols.SymbolVar, nil, location) + if evidence.Kind == project.ForIterationRange { + evidence.Cursor.BindType(elemType) + evidence.End = symbols.New("$for.end", symbols.SymbolVar, nil, location) + evidence.End.BindType(elemType) + if node.Index != nil { + evidence.Ordinal = symbols.New("$for.ordinal", symbols.SymbolVar, nil, location) + evidence.Ordinal.BindType(indexType) + } + } else { + evidence.Cursor.BindType(indexType) + evidence.Carrier = symbols.New("$for.carrier", symbols.SymbolVar, nil, location) + evidence.Carrier.BindType(evidence.CarrierType) + } + c.module.Semantics.ForIterations[node.ID()] = evidence + } + c.loopDepth++ + c.checkBlock(scope, node.Body, returnType) + c.loopDepth-- +} + +func (c *checker) bindLoopVariable(name *ast.Ident, typ typeinfo.Type) { + if typ == nil { + return + } + if sym := c.module.Semantics.ResolvedSymbols[name.ID()]; sym != nil { + sym.BindType(typ) + } +} + func (c *checker) rejectUnsizedType(typ typeinfo.Type, site ast.Node, context string) bool { if typeinfo.IsSizedType(typ) { return false diff --git a/internal/semantics/typechecker/flow_test.go b/internal/semantics/typechecker/flow_test.go index 7ec76a57..af684611 100644 --- a/internal/semantics/typechecker/flow_test.go +++ b/internal/semantics/typechecker/flow_test.go @@ -39,7 +39,10 @@ func checkFlowSource(t *testing.T, src string) (*project.Module, *diagnostics.Di resolver.Resolve(ctx, module) Check(ctx, module) module.TypedASTNodes = ast.Index(module.AST) - module.CFG = cfg.BuildModule(module.AST, module.Semantics.MatchCases) + module.CFG = cfg.BuildModule(module.AST, cfg.BuildQueries{ + MatchCases: module.Semantics.MatchCases, + LoopGuaranteedEntry: module.Semantics.ForLoopGuaranteedEntry, + }) module.Flow = CheckFlow(ctx, module) return module, diag } diff --git a/internal/semantics/typechecker/for_in_test.go b/internal/semantics/typechecker/for_in_test.go new file mode 100644 index 00000000..48616e7f --- /dev/null +++ b/internal/semantics/typechecker/for_in_test.go @@ -0,0 +1,404 @@ +package typechecker + +import ( + "strings" + "testing" + + "compiler/internal/frontend/ast" + "compiler/internal/project" + "compiler/internal/semantics/typeinfo" + "compiler/internal/target" +) + +func TestCheckForInOverRange(t *testing.T) { + src := `fn main() -> i32 { +let mut total: i32 = 0; +for i in 0..5 { + total = total + i; +} +return total; +}` + module, diag := checkTypeModule(t, src) + if diag.HasErrors() { + t.Fatalf("unexpected diagnostics: %s", diag.EmitAllToString()) + } + fn := module.AST.Stmts[0].(*ast.FnDecl) + loop := fn.Body.Stmts[1].(*ast.ForStmt) + binding := module.Semantics.ResolvedSymbols[loop.Value.ID()] + if binding == nil { + t.Fatal("missing resolved loop binding") + } + if got := typeinfo.TypeText(binding.Type); got != "i32" { + t.Fatalf("loop binding type = %s, want i32", got) + } + var reference *ast.Ident + ast.Inspect(loop.Body, func(node ast.Node) bool { + if ident, ok := node.(*ast.Ident); ok && ident.Name == "i" { + reference = ident + } + return true + }) + if reference == nil { + t.Fatal("missing loop binding reference") + } + if resolved := module.Semantics.ResolvedSymbols[reference.ID()]; resolved != binding { + t.Fatalf("loop reference resolved to %#v, want declaration symbol %#v", resolved, binding) + } +} + +func TestCheckForInIndexValueOverRange(t *testing.T) { + src := `fn main() -> i32 { +let mut total: i32 = 0; +for index, value in 0..5 { + total = total + index + value; +} +return total; +}` + module, diag := checkTypeModule(t, src) + if diag.HasErrors() { + t.Fatalf("unexpected diagnostics: %s", diag.EmitAllToString()) + } + fn := module.AST.Stmts[0].(*ast.FnDecl) + loop := fn.Body.Stmts[1].(*ast.ForStmt) + evidence, ok := module.Semantics.ForIterations[loop.ID()] + if !ok { + t.Fatal("missing range iteration evidence") + } + if evidence.Kind != project.ForIterationRange || evidence.Cursor == nil || evidence.End == nil || evidence.Ordinal == nil { + t.Fatalf("range iteration evidence = %#v", evidence) + } + if evidence.Index != module.Semantics.ResolvedSymbols[loop.Index.ID()] || evidence.Value != module.Semantics.ResolvedSymbols[loop.Value.ID()] { + t.Fatal("range evidence does not preserve source binding symbols") + } + for name, symbol := range map[string]string{ + "cursor": typeinfo.TypeText(evidence.Cursor.Type), + "end": typeinfo.TypeText(evidence.End.Type), + "ordinal": typeinfo.TypeText(evidence.Ordinal.Type), + "index": typeinfo.TypeText(evidence.Index.Type), + "value": typeinfo.TypeText(evidence.Value.Type), + } { + if symbol != "i32" { + t.Fatalf("%s type = %s, want i32", name, symbol) + } + } +} + +func TestCheckForInRangeTypeIsBoundOrderIndependent(t *testing.T) { + for _, test := range []struct { + name string + rangeText string + }{ + {name: "typed start", rangeText: "0i64..3"}, + {name: "typed end", rangeText: "0..3i64"}, + } { + t.Run(test.name, func(t *testing.T) { + module, diag := checkTypeModule(t, "fn main() { for value in "+test.rangeText+" {} }") + if diag.HasErrors() { + t.Fatalf("unexpected diagnostics: %s", diag.EmitAllToString()) + } + fn := module.AST.Stmts[0].(*ast.FnDecl) + loop := fn.Body.Stmts[0].(*ast.ForStmt) + evidence, found := module.Semantics.ForIterations[loop.ID()] + if !found { + t.Fatal("missing range iteration evidence") + } + for name, typ := range map[string]typeinfo.Type{ + "element": evidence.ElementType, + "cursor": evidence.Cursor.Type, + "end": evidence.End.Type, + "value": evidence.Value.Type, + } { + if got := typeinfo.TypeText(typ); got != "i64" { + t.Fatalf("%s type = %s, want i64", name, got) + } + } + if !evidence.GuaranteedEntry { + t.Fatal("ascending constant range lost guaranteed-entry proof") + } + }) + } +} + +func TestCheckForInPreservesRangeValueWidth(t *testing.T) { + src := `fn main() -> i64 { +for value in 0i64..3i64 { + return value; +} +return 0i64; +}` + module, diag := checkTypeModule(t, src) + if diag.HasErrors() { + t.Fatalf("unexpected diagnostics: %s", diag.EmitAllToString()) + } + fn := module.AST.Stmts[0].(*ast.FnDecl) + loop := fn.Body.Stmts[0].(*ast.ForStmt) + evidence := module.Semantics.ForIterations[loop.ID()] + for name, typ := range map[string]typeinfo.Type{ + "element": evidence.ElementType, + "cursor": evidence.Cursor.Type, + "end": evidence.End.Type, + "value": evidence.Value.Type, + } { + if got := typeinfo.TypeText(typ); got != "i64" { + t.Fatalf("%s type = %s, want i64", name, got) + } + } +} + +func TestCheckForInRecordsGuaranteedRangeEntry(t *testing.T) { + for _, test := range []struct { + name string + source string + guaranteed bool + }{ + {name: "ascending constants", source: "fn main() { for value in 0..1 {} }", guaranteed: true}, + {name: "equal constants", source: "fn main() { for value in 1..1 {} }"}, + {name: "descending constants", source: "fn main() { for value in 1..0 {} }"}, + {name: "runtime bounds", source: "fn main(start: i32, end: i32) { for value in start..end {} }"}, + } { + t.Run(test.name, func(t *testing.T) { + module, diag := checkTypeModule(t, test.source) + if diag.HasErrors() { + t.Fatalf("unexpected diagnostics: %s", diag.EmitAllToString()) + } + fn := module.AST.Stmts[0].(*ast.FnDecl) + loop := fn.Body.Stmts[0].(*ast.ForStmt) + evidence, found := module.Semantics.ForIterations[loop.ID()] + if !found { + t.Fatal("missing range iteration evidence") + } + if evidence.GuaranteedEntry != test.guaranteed { + t.Fatalf("guaranteed entry = %v, want %v", evidence.GuaranteedEntry, test.guaranteed) + } + }) + } +} + +func TestCheckForInOverArray(t *testing.T) { + src := `fn main() -> i32 { +let mut total: i32 = 0; +let items = [3]i32{1, 2, 3}; +for v in items { + total = total + v; +} +return total; +}` + module, diag := checkTypeModule(t, src) + if diag.HasErrors() { + t.Fatalf("unexpected diagnostics: %s", diag.EmitAllToString()) + } + fn := module.AST.Stmts[0].(*ast.FnDecl) + loop := fn.Body.Stmts[2].(*ast.ForStmt) + evidence, ok := module.Semantics.ForIterations[loop.ID()] + if !ok { + t.Fatal("missing sequence iteration evidence") + } + if evidence.Kind != project.ForIterationSequence || evidence.Carrier == nil || evidence.Cursor == nil { + t.Fatalf("sequence iteration evidence = %#v", evidence) + } + if got := typeinfo.TypeText(evidence.Carrier.Type); got != "&[3]i32" { + t.Fatalf("carrier type = %s, want &[3]i32", got) + } + wantCursor, ok := typeinfo.NumericTypeFromName("usize", target.Host()) + if !ok || !typeinfo.SameType(evidence.Cursor.Type, wantCursor) { + t.Fatalf("cursor type = %s, want target usize", typeinfo.TypeText(evidence.Cursor.Type)) + } + if got := typeinfo.TypeText(evidence.ElementType); got != "i32" { + t.Fatalf("element type = %s, want i32", got) + } +} + +func TestCheckForInRejectsTemporaryArrayStorage(t *testing.T) { + src := `fn main() { + for value in []i32{1, 2} {} +}` + diag := checkTypeSource(t, src) + if !diag.HasErrors() { + t.Fatal("expected diagnostic for temporary array iteration") + } + if !strings.Contains(diag.EmitAllToString(), "requires addressable array storage") { + t.Fatalf("expected addressable-storage diagnostic, got: %s", diag.EmitAllToString()) + } +} + +func TestCheckForInRejectsMoveOnlySequenceElements(t *testing.T) { + src := `struct Item { value: i32 } +fn main() { + let items = [1]Item{.{ value = 1 }}; + for item in items {} +}` + diag := checkTypeSource(t, src) + if !diag.HasErrors() { + t.Fatal("expected diagnostic for move-only sequence elements") + } + if !strings.Contains(diag.EmitAllToString(), "requires copyable sequence elements") { + t.Fatalf("expected copyable-element diagnostic, got: %s", diag.EmitAllToString()) + } +} + +func TestCheckForInRejectsNonIterable(t *testing.T) { + src := `fn main() -> i32 { +for v in 5 { + return 1; +} +return 0; +}` + diag := checkTypeSource(t, src) + if !diag.HasErrors() { + t.Fatal("expected diagnostic for non-iterable") + } + if !strings.Contains(diag.EmitAllToString(), "cannot iterate over") { + t.Fatalf("expected iterate diagnostic, got: %s", diag.EmitAllToString()) + } +} + +func TestCheckForInRejectsInclusiveRange(t *testing.T) { + src := `fn main() -> i32 { +for v in 0..=5 { + return v; +} +return 0; +}` + diag := checkTypeSource(t, src) + if !diag.HasErrors() { + t.Fatal("expected diagnostic for inclusive for range") + } + if !strings.Contains(diag.EmitAllToString(), "requires an exclusive end") { + t.Fatalf("expected exclusive-range diagnostic, got: %s", diag.EmitAllToString()) + } +} + +func TestCheckForInRejectsUnboundedRange(t *testing.T) { + src := `fn main() -> i32 { +for v in 0.. { + return 1; +} +return 0; +}` + // The parser rejects the missing end bound; the typechecker keeps a + // defensive guard for recovery paths that produce a boundless range. + diag := checkTypeSource(t, src) + if !diag.HasErrors() { + t.Fatal("expected diagnostic for unbounded range") + } +} + +func TestCheckForInRejectsStringIteration(t *testing.T) { + src := `fn main() -> i32 { +for b in "abc" { + return 1; +} +return 0; +}` + diag := checkTypeSource(t, src) + if !diag.HasErrors() { + t.Fatal("expected diagnostic for string iteration") + } + if !strings.Contains(diag.EmitAllToString(), "as_bytes") { + t.Fatalf("expected as_bytes help, got: %s", diag.EmitAllToString()) + } +} + +func TestRejectedForInDoesNotPublishIterationEvidence(t *testing.T) { + for _, test := range []struct { + name string + src string + }{ + {name: "inclusive range", src: "fn main() { for value in 0..=2 {} }"}, + {name: "missing range end", src: "fn main() { for value in 0.. {} }"}, + {name: "non-integral range", src: "fn main() { for value in 0.5..2.5 {} }"}, + {name: "incompatible range", src: "fn main() { for value in 0i32..2u32 {} }"}, + {name: "temporary array", src: "fn main() { for value in []i32{1, 2} {} }"}, + {name: "move-only elements", src: "struct Item { value: i32 } fn main() { let items = [1]Item{.{ value = 1 }}; for item in items {} }"}, + {name: "non-iterable", src: "fn main() { for value in 5 {} }"}, + {name: "string", src: "fn main() { for value in \"text\" {} }"}, + {name: "recovery binding", src: "fn main() { let values = [1]i32{1}; for index, in values {} }"}, + } { + t.Run(test.name, func(t *testing.T) { + module, diag := checkTypeModule(t, test.src) + if !diag.HasErrors() { + t.Fatal("expected rejected for-in diagnostic") + } + var loop *ast.ForStmt + for _, stmt := range module.AST.Stmts { + ast.Inspect(stmt, func(node ast.Node) bool { + if candidate, ok := node.(*ast.ForStmt); ok && loop == nil { + loop = candidate + } + return true + }) + } + if loop == nil { + t.Fatal("missing recovered for-in loop") + } + if _, found := module.Semantics.ForIterations[loop.ID()]; found { + t.Fatal("rejected for-in loop retained semantic evidence") + } + }) + } +} + +func TestRejectedForInStillChecksBody(t *testing.T) { + module, diag := checkTypeModule(t, `fn main() { + for value in 0..=2 { + missing = value; + } +}`) + if !strings.Contains(diag.EmitAllToString(), "requires an exclusive end") || + !strings.Contains(diag.EmitAllToString(), "unknown identifier") { + t.Fatalf("expected header and body diagnostics, got: %s", diag.EmitAllToString()) + } + fn := module.AST.Stmts[0].(*ast.FnDecl) + loop := fn.Body.Stmts[0].(*ast.ForStmt) + if _, found := module.Semantics.ForIterations[loop.ID()]; found { + t.Fatal("rejected loop retained semantic evidence") + } +} + +func TestCheckBreakContinueInsideLoop(t *testing.T) { + src := `fn main() -> i32 { +for i in 0..10 { + if i == 3 { + continue; + } + if i == 6 { + break; + } +} +return 0; +}` + diag := checkTypeSource(t, src) + if diag.HasErrors() { + t.Fatalf("unexpected diagnostics: %s", diag.EmitAllToString()) + } +} + +func TestCheckBreakOutsideLoopRejected(t *testing.T) { + src := `fn main() -> i32 { +break; +return 0; +}` + diag := checkTypeSource(t, src) + if !diag.HasErrors() { + t.Fatal("expected diagnostic for break outside loop") + } + if !strings.Contains(diag.EmitAllToString(), "break outside loop") { + t.Fatalf("expected break diagnostic, got: %s", diag.EmitAllToString()) + } +} + +func TestCheckContinueOutsideLoopRejected(t *testing.T) { + src := `fn main() -> i32 { +if true { + continue; +} +return 0; +}` + diag := checkTypeSource(t, src) + if !diag.HasErrors() { + t.Fatal("expected diagnostic for continue outside loop") + } + if !strings.Contains(diag.EmitAllToString(), "continue outside loop") { + t.Fatalf("expected continue diagnostic, got: %s", diag.EmitAllToString()) + } +} diff --git a/internal/semantics/typechecker/typechecker.go b/internal/semantics/typechecker/typechecker.go index d0a85ee6..29ca1f3d 100644 --- a/internal/semantics/typechecker/typechecker.go +++ b/internal/semantics/typechecker/typechecker.go @@ -16,6 +16,7 @@ type checker struct { payloadContext int optionalTestContext int wholeCarrierExpr ast.Expr + loopDepth int } // Concrete references convert to satisfied interface borrows, while owned diff --git a/specs/002-for-loop/data-model.md b/specs/002-for-loop/data-model.md new file mode 100644 index 00000000..5e23c1df --- /dev/null +++ b/specs/002-for-loop/data-model.md @@ -0,0 +1,111 @@ +# Data Model: for loop + +## AST (`internal/frontend/ast/stmt.go`) + +```go +type ForStmt struct { + Index *Ident // optional exposed index in index, value form + Value *Ident // set for for-in form + Iterable Expr // mutually exclusive with Cond + Cond Expr // nil for for-in and infinite forms + Body *BlockStmt +} + +type BreakStmt struct { NodeIDHolder; Location *source.Location } +type ContinueStmt struct { NodeIDHolder; Location *source.Location } +``` + +Valid states: + +- condition loop: `Cond != nil`, `Iterable == nil` +- infinite loop: `Cond == nil`, `Iterable == nil` +- for-in loop: `Cond == nil`, `Iterable != nil` +- `Cond != nil` and `Iterable != nil`: invalid AST state + +## Semantic evidence (`project.ForIteration`) + +`SemanticInfo.ForIterations` is keyed by source `ForStmt.NodeID`. Typechecker-owned evidence records resolved iteration kind and owns all hidden symbols needed by later lowering: + +- `Kind`: range or sequence +- `ElementType`, `CarrierType`: resolved lowering evidence +- `Carrier`: hidden loop-lifetime sequence carrier +- `Cursor`: hidden range cursor or target-`usize` sequence cursor +- `End`: hidden exclusive range end +- `Ordinal`: hidden range ordinal when exposed index is requested +- `Index`, `Value`: resolved source symbols scoped to loop body + +Evidence does not own or synthesize CFG site IDs. CFG creates and finalizes sites independently before HIR. + +Sequence invariants: + +- fixed and dynamic owner arrays must be addressable +- carrier retains a shared borrow for whole loop lifetime +- element type must be implicitly copyable; move-only elements are rejected +- hidden cursor is target `usize` +- exposed sequence index is target `usize`; range ordinal/index remains source-default `i32` + +## CFG (`internal/ir/cfg`) + +Canonical topology: + +```text +maybe empty: entry → init → header ─true→ body → latch → header + └false→ exit +guaranteed entry: entry → init → body → latch → header ─true→ body + └false→ exit +``` + +- Origins are `BlockLoopInit`, `BlockLoop`, `BlockLoopBody`, `BlockLoopLatch`, and `BlockNormal` for exit. +- Loop blocks carry source loop `NodeID` so later lowering can find corresponding `hir.For`. +- Body fallthrough and `continue` target latch; latch targets header. +- `break` targets exit. +- Break/continue append required innermost-first lexical `SiteScopeExit` sites before their jump. +- Cleanup plans key `AfterScope` entries by exact finalized `cfg.SiteID{Block, Index}`. + +## HIR (`internal/ir/hir`) + +```go +type For struct { + Init *Block + Cond ir.Expr + Bindings *Block + Body *Block + Next *Block + NodeID NodeID + Location *source.Location +} +``` + +Range example, `for index, value in start..end`: + +```text +Init: cursor = start; end = end; ordinal: i32 = 0 +Cond: cursor < end +Bindings: index = ordinal; value = cursor +Body: source body +Next: cursor = cursor + 1; ordinal = ordinal + 1 +``` + +Sequence example, `for index, value in array`: + +```text +Init: carrier = shared-reference(array); cursor: usize = 0 +Cond: cursor < len(carrier) +Bindings: index: usize = cursor; value = load carrier[cursor] +Body: source body +Next: cursor = cursor + 1 +``` + +Generated `Init`, `Bindings`, and `Next` statements carry source location for diagnostics but zero AST node/site identity. MIR source-statement indexing excludes zero IDs; generated segments remain reachable only through parent loop identity and CFG block origin. + +## MIR / LLVM + +MIR lowering is loop-aware: + +- CFG `BlockLoopInit` executes `hir.For.Init` +- CFG `BlockLoopBody` executes `hir.For.Bindings` before AST-site body statements +- CFG `BlockLoopLatch` executes `hir.For.Next` +- header lowers `hir.For.Cond`; jumps preserve CFG topology +- `SiteScopeExit` cleanup uses exact `cfg.SiteID` + +LLVM consumes resulting MIR normally; no second backend-specific for-in implementation exists. diff --git a/specs/002-for-loop/plan.md b/specs/002-for-loop/plan.md new file mode 100644 index 00000000..dc15227a --- /dev/null +++ b/specs/002-for-loop/plan.md @@ -0,0 +1,91 @@ +# Implementation Plan: for loop + +Feature branch: `feature/for-loop` +Spec: `specs/002-for-loop/spec.md` + +## Technical Context + +- Pipeline: tree-sitter grammar → hand-written parser (`internal/frontend/parser`) → AST → resolver/typechecker/ownership (`internal/semantics`) → CFG (`internal/ir/cfg`) → HIR (`internal/ir/hir`) → MIR (`internal/ir/mir`) → LLVM backend (`internal/backend/llvm`). CFG is built before HIR. +- Canonical loop model: `ast.ForStmt` plus semantic `ForIteration` evidence → CFG init/header/body/latch/exit topology → `hir.For{Init, Cond, Bindings, Body, Next}` → MIR blocks. +- Semantic evidence owns hidden loop symbols. CFG owns control-flow sites and exact `cfg.SiteID` values; generated HIR loop segments do not invent AST sites. +- MIR maps CFG `BlockLoopInit`, `BlockLoopBody`, and `BlockLoopLatch` origins to HIR `Init`, `Bindings`, and `Next` respectively. + +## Constitution Check + +- No pass-through wrappers or stale aliases: use existing AST, semantic evidence, CFG, HIR, and MIR models directly. +- No duplicated lowering: semantic analysis resolves iteration shape once; CFG owns transfers; HIR materializes resolved evidence; MIR executes segments by block origin; backend consumes MIR. +- Behavior preservation: condition, infinite, for-in, break, continue, ownership, and cleanup all retain phase boundaries. +- Change scope: implement and validate loop behavior only; do not add a parallel for-in IR or backend path. + +## Phase 0: Research (resolved) + +See `research.md`. Key decisions: + +1. `ast.ForStmt.Cond` and `Iterable` are mutually exclusive; both nil means infinite loop. +2. Typechecker-owned `ForIteration` evidence owns hidden symbols for lowering. +3. CFG is built before HIR with canonical init/header/body/latch/exit topology. +4. `continue` targets latch and `break` targets exit after lexical scope exits. +5. Generated HIR segments have no AST sites; MIR schedules them by CFG block origin. +6. Sequence iteration keeps a loop-lifetime shared borrow, requires addressable owner arrays, and rejects move-only elements. + +## Phase 1: Design + +See `data-model.md` and `quickstart.md`. + +### Steps (each stops for review) + +**Step 1 — Parser and AST** + +- Add `ast.BreakStmt` and `ast.ContinueStmt`; extend `ast.ForStmt` with flat `Index`, `Value`, and `Iterable` fields. +- Enforce `Iterable`/`Cond` mutual exclusion; both nil represents an infinite loop. +- Parse condition, single-binding, and index/value forms. Reject deferred labels clearly. +- Validate with parser unit tests. + +**Step 2 — semantic evidence and ownership** + +- Resolver registers source index/value bindings in body scope and validates loop-control nesting. +- Typechecker accepts exclusive bounded ranges, fixed arrays, dynamic arrays, and slices; rejects direct strings and non-iterables. +- Populate `ForIteration` with source bindings and hidden carrier/cursor/end/ordinal symbols. +- Use target `usize` for sequence cursor and exposed index; keep range ordinal/index as source-default `i32`. +- Require owner arrays to be addressable; retain sequence backing storage through a loop-lifetime shared borrow; reject conflicting mutation and moved iterables. +- Reject move-only sequence elements instead of deferring owned-element behavior. +- Validate with typechecker and ownership tests plus negative fixtures. + +**Step 3 — CFG topology and cleanup sites** + +- Build canonical init/header/body/latch/exit blocks before HIR. +- Route body fallthrough and `continue` through latch; route latch to header; route `break` to exit. +- Emit lexical scope-exit sites before loop-control jumps. +- Keep cleanup plans keyed by exact finalized `cfg.SiteID` so equal scope IDs on distinct paths do not collide. +- Validate topology, nested targets, unreachable recovery, and cleanup-path sites with CFG/ownership tests. + +**Step 4 — HIR and MIR lowering** + +- Lower every loop to `hir.For{Init, Cond, Bindings, Body, Next}`. +- Range `Init` evaluates bounds once; sequence `Init` captures shared carrier and zero `usize` cursor. +- Sequence `Bindings` copies hidden `usize` cursor directly to exposed `usize` index and copies element from indexed storage. +- Do not assign AST node/site IDs to generated `Init`, `Bindings`, or `Next` statements. +- MIR executes generated segments from CFG `BlockLoopInit`, `BlockLoopBody`, and `BlockLoopLatch` origins, then lowers ordinary body statements through CFG sites. +- Validate HIR shape, MIR segment placement, target-width cursor operations, and exact-site cleanup. + +**Step 5 — fixtures and end-to-end validation** + +- Positive runtime fixtures cover range, fixed array, dynamic array, slice, break/continue, nested loops, and cleanup on normal, continue, and break paths. +- Negative fixtures cover non-iterable, direct string, inclusive/unbounded range, break/continue outside loops, temporary owner arrays, moved iterables, mutation under loop borrow, and move-only elements. +- Validate each fixture from its project root with bundled `build/bin/peeper`, then run full Go tests. + +## Risks + +- Skipping `Next` on `continue`: prevented by dedicated latch target and MIR block-origin mapping. +- Cleanup collision between paths sharing one scope ID: prevented by exact `cfg.SiteID` keys. +- Borrow ending too early: sequence carrier loan must remain live until loop exit, including break/continue paths. +- Target-width mismatch: sequence cursor, length, projection index, and exposed index remain target `usize`; 386 and amd64 pipeline tests validate MIR and LLVM operands. +- Accidental owner-array move: owner arrays require addressable storage and are captured by shared reference. + +## Validation commands + +```sh +go test ./internal/... +go test ./x_test/ +build/bin/peeper run x_test/for_range_loop +``` diff --git a/specs/002-for-loop/quickstart.md b/specs/002-for-loop/quickstart.md new file mode 100644 index 00000000..2035ed98 --- /dev/null +++ b/specs/002-for-loop/quickstart.md @@ -0,0 +1,72 @@ +# Quickstart: for loop validation + +Run commands from compiler repository root. Prerequisite: bundled compiler exists at `build/bin/peeper`. + +## Runtime scenarios + +1. Range loop + + ```peep + fn main() -> i32 { + let mut total: i32 = 0; + for i in 0..5 { total = total + i; } + println(total); + return 0; + } + ``` + + Expected: fixture succeeds and stdout contains `10`. + +2. Fixed, dynamic, and slice iteration + + ```peep + for value in fixed { ... } + for index, value in dynamic { ... } + for value in dynamic[1..3] { ... } + ``` + + Expected: elements are copied in order; exposed sequence index is target `usize`; dynamic and slice paths produce expected sums. + +3. Break, continue, and cleanup + + ```peep + for i in 0..10 { + let first = alloc(i); + if i == 3 { continue; } + let second = alloc(i); + if i == 6 { break; } + } + ``` + + Expected: `continue` executes latch before next header check, `break` exits loop, and loop-body owners are cleaned on both paths. Source-driven pipeline tests verify exact drop order; runtime fixture is a smoke check. + +4. Nested loops + + Expected: `break` and `continue` target innermost active loop. + +## Negative scenarios + +- `for x in 5 {}` → cannot-iterate diagnostic. +- `break;` at function top level → `break outside loop`. +- `continue;` outside loop → `continue outside loop`. +- `for x in 0..=5 {}` → exclusive-range diagnostic. +- `for x in "abc" {}` → explicit `as_bytes()`/`as_chars()` view diagnostic. +- iterating temporary fixed/dynamic owner array → addressable-array-storage diagnostic. +- mutating sequence owner in loop body → shared-borrow conflict. +- using iterable after it was moved → use-after-move diagnostic. +- iterating move-only sequence elements → copyable-element diagnostic. + +## Commands + +Fixture argument is fixture project root, not an individual source file: + +```sh +build/bin/peeper run x_test/for_range_loop +build/bin/peeper run x_test/for_array_loop +build/bin/peeper run x_test/for_break_continue + +go test ./internal/... +go test ./x_test/ +``` + +Expected fixture wording follows each `peeper.toml`: successful runtime fixtures report `outcome = "success"` and required `stdout_contains` values; negative fixtures report `outcome = "failure"` and required `stderr_contains` diagnostics. diff --git a/specs/002-for-loop/research.md b/specs/002-for-loop/research.md new file mode 100644 index 00000000..6447ac9c --- /dev/null +++ b/specs/002-for-loop/research.md @@ -0,0 +1,83 @@ +# Research: for loop + +## D1: Where does iteration evidence live? + +Decision: typechecker-owned `project.ForIteration` evidence is keyed by source `ForStmt.NodeID` and owns resolved iteration kind, types, source bindings, and hidden carrier/cursor/end/ordinal symbols. + +Rationale: semantic analysis has type and target information needed to choose range width, sequence carrier shape, target `usize` cursor/index, and range `i32` ordinal. HIR consumes this evidence instead of rediscovering iterable semantics. + +Alternatives: + +- parser desugaring: rejected because parser lacks semantic and target information +- HIR rediscovery: rejected because it duplicates semantic decisions +- generated statement/site IDs in evidence: rejected because evidence owns symbols, while CFG owns sites + +## D2: What is canonical phase order and loop shape? + +Decision: build CFG before HIR. CFG gives every lowered loop canonical init/header/body/latch/exit topology. + +Rationale: ownership and cleanup depend on real control-flow paths before HIR/MIR lowering. Distinct init, body, and latch origins provide stable execution points for generated loop segments. + +```text +entry → init → header ─true→ body → latch → header + └false→ exit +``` + +Alternatives: + +- header/body/exit with body-to-header back edge: rejected because `continue` would skip increment +- HIR-first synthesized CFG sites: rejected because generated loop operations are not AST statements + +## D3: How are generated HIR segments executed? + +Decision: represent all loops as `hir.For{Init, Cond, Bindings, Body, Next}`. Generated `Init`, `Bindings`, and `Next` statements have no AST sites. MIR executes them from CFG `BlockLoopInit`, `BlockLoopBody`, and `BlockLoopLatch` origins. + +Rationale: CFG is already built when HIR is generated. Inventing generated AST/Site IDs would create false source identity and conflict with CFG site ownership. Block origin plus loop `NodeID` gives MIR exact placement without a parallel lowering path. + +Alternatives: + +- a minimal loop shape without explicit generated segments: rejected because one-time evaluation, per-iteration binding, and latch work need explicit phase data +- new `hir.ForIn`: rejected because it duplicates loop lowering +- relying on ordinary AST-site MIR scheduling alone: rejected because MIR must schedule generated segments by CFG origin + +## D4: How do break, continue, and cleanup interact? + +Decision: CFG converts AST `break`/`continue` to existing jumps through a loop-target stack. `continue` targets latch; `break` targets exit. Required lexical scope-exit sites precede each transfer. + +Rationale: latch must execute `Next` before returning to header. Cleanup can differ at multiple exits that share one scope identity, so ownership plans and MIR lookup use exact finalized `cfg.SiteID`, not scope or AST node ID alone. + +Alternatives: + +- bypassing latch on `continue`: rejected because it skips `Next` +- HIR-level break/continue nodes: rejected because CFG already owns transfer resolution +- cleanup keyed only by scope ID: rejected because distinct CFG sites would collide + +## D5: What are sequence ownership rules? + +Decision: sequence iteration evaluates iterable once and keeps a shared borrow of backing storage for loop lifetime. Fixed and dynamic owner arrays must be addressable. Sequence elements must be implicitly copyable; move-only elements are rejected. + +Rationale: carrier must remain valid across header, body, latch, `continue`, and `break` paths. Addressability prevents borrowing temporary owner storage. Rejecting move-only elements avoids implicit repeated moves from indexed places; users can iterate indexes and borrow elements explicitly. + +Alternatives: + +- move owner array into hidden carrier: rejected because for-in is non-consuming and source remains usable after loop +- defer owned-element behavior: rejected because current contract must reject it explicitly +- end shared borrow after init: rejected because generated loads continue through every iteration + +## D6: What types do sequence indexes use? + +Decision: sequence cursor and exposed index binding are target `usize`; range ordinal/index remains source-default `i32`. + +Rationale: sequence lengths and physical indexes are target-sized, so narrowing the cursor could wrap while storage traversal continued. Direct `usize` binding keeps cursor, length, projection, MIR, and LLVM operands representable on both 32- and 64-bit targets. Range ordinal is ordinary source arithmetic and retains default integer wrapping semantics. + +## D7: Which iterables are accepted? + +Decision: bounded exclusive ranges (`start..end`), fixed arrays, dynamic arrays, and slice views. Direct strings are rejected with guidance to use `as_bytes()` or `as_chars()`. Maps and direct UTF-8 string iteration remain deferred. + +Rationale: explicit string views preserve existing indexing semantics and avoid implicit encoding behavior. + +## D8: Labels + +Decision: grammar may accept labels, but parser rejects them with a clear "labels not supported yet" diagnostic. + +Rationale: labeled control flow requires named-loop tracking through semantics and CFG; it is outside core loop scope. diff --git a/specs/002-for-loop/spec.md b/specs/002-for-loop/spec.md new file mode 100644 index 00000000..382a4f02 --- /dev/null +++ b/specs/002-for-loop/spec.md @@ -0,0 +1,71 @@ +# Spec: for loop + +## Summary + +Complete `for` loops with range and sequence iteration plus `break` and `continue`, while preserving condition and infinite loops through the canonical compiler pipeline. + +## Contract + +- AST supports condition, infinite, and for-in forms plus `break`/`continue`. +- `ast.ForStmt.Cond` and `ast.ForStmt.Iterable` are mutually exclusive. Both nil means an infinite loop; both non-nil is invalid. +- Semantic analysis records one `ForIteration` evidence value per valid for-in AST node. Evidence owns hidden carrier, cursor, end, and ordinal symbols; source index/value symbols remain body-scoped. +- CFG is built before HIR. Every loop uses canonical init/header/body/latch/exit blocks; typechecker proof may route init directly to body for a guaranteed first iteration. +- HIR represents loops as `hir.For{Init, Cond, Bindings, Body, Next}`. +- Generated `Init`, `Bindings`, and `Next` statements have no AST sites. MIR executes them by matching CFG `BlockLoopInit`, `BlockLoopBody`, and `BlockLoopLatch` origins. +- `continue` transfers to latch so `Next` executes; latch transfers to header. `break` transfers to exit. Both emit required lexical scope exits first. +- Ownership cleanup is keyed by exact `cfg.SiteID`, not only AST or scope identity. + +## Requirements + +### R1: for-in over ranges + +```peep +for i in 0..10 { ... } +for index, value in 0..10 { ... } +``` + +- Range form is bounded `start..end` with exclusive end. +- Bounds are evaluated once in `Init` and retained in hidden semantic symbols. +- Value binding receives current range value. Optional exposed index is `i32` and counts from zero. +- Source loop bindings are fresh, immutable, body-scoped bindings for each iteration. + +### R2: for-in over sequences + +- Supported sequences are fixed arrays, dynamic arrays, and slice views. +- Iterable is evaluated once. Iteration retains a loop-lifetime shared borrow of backing storage. +- Owner fixed and dynamic arrays must be addressable; temporary owner arrays are rejected. +- Hidden cursor and optional exposed index use target `usize` for length comparison and indexing, avoiding narrowing while iterating target-sized storage. +- Elements are loaded under existing copy rules. Move-only elements are rejected; users must iterate indexes and borrow such elements explicitly. +- Direct strings are rejected with guidance to use `value |> as_bytes()` or `value |> as_chars()`. + +### R3: break / continue + +```peep +for ... { break; continue; } +``` + +- `break` exits innermost loop through exit. +- `continue` exits active body scopes and transfers to latch, never directly to header. +- Both are illegal outside a loop. +- Labels are deferred; parser rejects them with a clear diagnostic. + +### R4: lowering and cleanup + +- CFG precedes HIR and provides canonical init/header/body/latch/exit blocks. +- MIR runs generated HIR segments by CFG block origin because generated statements intentionally have no AST sites or synthesized site IDs. +- Scope-exit cleanup on normal fallthrough, `continue`, and `break` is selected by exact `cfg.SiteID`. + +## Out of scope + +- Labeled break/continue. +- C-style 3-clause `for i := 0; i < n; i += 1`. +- Map iteration and direct UTF-8 string iteration. +- Moving ownership-bearing elements out of a sequence. +- `while` keyword (use `for`). + +## Validation + +- Positive runtime fixtures in `x_test/`: range, fixed array, dynamic array, slice, break/continue, nested loops, and ownership cleanup on normal, continue, and break paths. +- Negative fixtures: non-iterable, direct string, inclusive/unbounded range, break/continue outside loops, non-addressable owner array, mutation during loop-lifetime shared borrow, use after move, and move-only sequence elements. +- Focused CFG/HIR/MIR/ownership tests verify topology, generated-segment execution, target-sized cursor/index lowering on 386 and amd64, and exact-site cleanup from source through MIR. +- Run `go test ./...` and fixture projects with bundled `build/bin/peeper`. diff --git a/x_test/for_array_loop/peeper.toml b/x_test/for_array_loop/peeper.toml new file mode 100644 index 00000000..6106211d --- /dev/null +++ b/x_test/for_array_loop/peeper.toml @@ -0,0 +1,7 @@ +name = "for_array_loop" +build = "program" + +[test] +mode = "run" +outcome = "success" +stdout_contains = ["50", "6", "15", "10", "8", "7", "9"] diff --git a/x_test/for_array_loop/src/main.peep b/x_test/for_array_loop/src/main.peep new file mode 100644 index 00000000..c7c9e1dc --- /dev/null +++ b/x_test/for_array_loop/src/main.peep @@ -0,0 +1,46 @@ +fn FirstFixed(values: [2]i32) -> i32 { + for value in values { + return value; + } + return 0; +} + +fn FirstDynamic(values: []i32) -> i32 { + for value in values { + return value; + } + return 0; +} + +fn main() -> i32 { + let items = [4]i32{5, 10, 15, 20}; + let mut total: i32 = 0; + for v in items { + total = total + v; + } + let mut pairs: i32 = 0; + for index, value in 0..3 { + pairs = pairs + index + value; + } + let mut dynamic = []i32{2, 4, 6}; + let mut dynamicTotal: i32 = 0; + for index, value in dynamic { + dynamicTotal = dynamicTotal + (index as i32) + value; + } + dynamic |> append(8); + + let view = dynamic[1..3]; + let mut viewTotal: i32 = 0; + for value in view { + viewTotal = viewTotal + value; + } + + println(total); + println(pairs); + println(dynamicTotal); + println(viewTotal); + println(dynamic[3]); + println(FirstFixed([2]i32{7, 8})); + println(FirstDynamic([]i32{9, 10})); + return 0; +} diff --git a/x_test/for_break_continue/peeper.toml b/x_test/for_break_continue/peeper.toml new file mode 100644 index 00000000..46995b49 --- /dev/null +++ b/x_test/for_break_continue/peeper.toml @@ -0,0 +1,7 @@ +name = "for_break_continue" +build = "program" + +[test] +mode = "run" +outcome = "success" +stdout_contains = ["12"] diff --git a/x_test/for_break_continue/src/main.peep b/x_test/for_break_continue/src/main.peep new file mode 100644 index 00000000..2dcb7826 --- /dev/null +++ b/x_test/for_break_continue/src/main.peep @@ -0,0 +1,71 @@ +enum TransferResource { + Owned: { value: *i32 }, + Pending +} + +fn BreakMatch(resource: TransferResource) { + for i in 0..1 { + match resource { + TransferResource::Owned with { value = owned } => { + free(owned); + break; + } + TransferResource::Pending => { + break; + } + } + } +} + +fn ContinueMatch() { + for i in 0..2 { + let resource = TransferResource::Owned with .{ value = alloc(i) }; + match resource { + TransferResource::Owned with { value = owned } => { + free(owned); + continue; + } + TransferResource::Pending => { + continue; + } + } + } +} + +fn NestedBreakMatch() { + for outer in 0..2 { + for inner in 0..2 { + let resource = TransferResource::Owned with .{ value = alloc(outer + inner) }; + match resource { + TransferResource::Owned with { value = owned } => { + free(owned); + break; + } + TransferResource::Pending => { + break; + } + } + } + } +} + +fn main() -> i32 { + let mut seen: i32 = 0; + for i in 0..10 { + let first = alloc(i); + if i == 3 { + continue; + } + let second = alloc(i); + if i == 6 { + break; + } + seen = seen + i; + } + BreakMatch(TransferResource::Owned with .{ value = alloc(7) }); + BreakMatch(TransferResource::Pending); + ContinueMatch(); + NestedBreakMatch(); + println(seen); + return 0; +} diff --git a/x_test/for_nested_loops/peeper.toml b/x_test/for_nested_loops/peeper.toml new file mode 100644 index 00000000..a5e1036f --- /dev/null +++ b/x_test/for_nested_loops/peeper.toml @@ -0,0 +1,7 @@ +name = "for_nested_loops" +build = "program" + +[test] +mode = "run" +outcome = "success" +stdout_contains = ["6"] diff --git a/x_test/for_nested_loops/src/main.peep b/x_test/for_nested_loops/src/main.peep new file mode 100644 index 00000000..6cfab5dd --- /dev/null +++ b/x_test/for_nested_loops/src/main.peep @@ -0,0 +1,16 @@ +fn main() -> i32 { + let mut innerRuns: i32 = 0; + for i in 0..3 { + for j in 0..4 { + if j == 1 { + continue; + } + if j == 3 { + break; + } + innerRuns = innerRuns + 1; + } + } + println(innerRuns); + return 0; +} diff --git a/x_test/for_range_loop/peeper.toml b/x_test/for_range_loop/peeper.toml new file mode 100644 index 00000000..2cb361dc --- /dev/null +++ b/x_test/for_range_loop/peeper.toml @@ -0,0 +1,7 @@ +name = "for_range_loop" +build = "program" + +[test] +mode = "run" +outcome = "success" +stdout_contains = ["10", "3", "3"] diff --git a/x_test/for_range_loop/src/main.peep b/x_test/for_range_loop/src/main.peep new file mode 100644 index 00000000..79b852d4 --- /dev/null +++ b/x_test/for_range_loop/src/main.peep @@ -0,0 +1,18 @@ +fn main() -> i32 { + let mut total: i32 = 0; + for i in 0..5 { + total = total + i; + } + let mut typed_start: i64 = 0i64; + for value in 0i64..3 { + typed_start = typed_start + value; + } + let mut typed_end: i64 = 0i64; + for value in 0..3i64 { + typed_end = typed_end + value; + } + println(total); + println(typed_start); + println(typed_end); + return 0; +} diff --git a/x_test/negative_break_outside_loop/peeper.toml b/x_test/negative_break_outside_loop/peeper.toml new file mode 100644 index 00000000..3126f56e --- /dev/null +++ b/x_test/negative_break_outside_loop/peeper.toml @@ -0,0 +1,7 @@ +name = "negative_break_outside_loop" +build = "lib" + +[test] +mode = "check" +outcome = "failure" +stderr_contains = ["break outside loop"] diff --git a/x_test/negative_break_outside_loop/src/main.peep b/x_test/negative_break_outside_loop/src/main.peep new file mode 100644 index 00000000..a4bdaa6f --- /dev/null +++ b/x_test/negative_break_outside_loop/src/main.peep @@ -0,0 +1,3 @@ +fn Invalid() { + break; +} diff --git a/x_test/negative_continue_outside_loop/peeper.toml b/x_test/negative_continue_outside_loop/peeper.toml new file mode 100644 index 00000000..75c8e7cf --- /dev/null +++ b/x_test/negative_continue_outside_loop/peeper.toml @@ -0,0 +1,7 @@ +name = "negative_continue_outside_loop" +build = "lib" + +[test] +mode = "check" +outcome = "failure" +stderr_contains = ["continue outside loop"] diff --git a/x_test/negative_continue_outside_loop/src/main.peep b/x_test/negative_continue_outside_loop/src/main.peep new file mode 100644 index 00000000..e833beef --- /dev/null +++ b/x_test/negative_continue_outside_loop/src/main.peep @@ -0,0 +1,3 @@ +fn Invalid() { + continue; +} diff --git a/x_test/negative_for_inclusive_range/peeper.toml b/x_test/negative_for_inclusive_range/peeper.toml new file mode 100644 index 00000000..83436222 --- /dev/null +++ b/x_test/negative_for_inclusive_range/peeper.toml @@ -0,0 +1,7 @@ +name = "negative_for_inclusive_range" +build = "lib" + +[test] +mode = "check" +outcome = "failure" +stderr_contains = ["for range requires an exclusive end"] diff --git a/x_test/negative_for_inclusive_range/src/main.peep b/x_test/negative_for_inclusive_range/src/main.peep new file mode 100644 index 00000000..bf0eba9b --- /dev/null +++ b/x_test/negative_for_inclusive_range/src/main.peep @@ -0,0 +1,3 @@ +fn Invalid() { + for value in 0..=5 {} +} diff --git a/x_test/negative_for_iterable_ownership/peeper.toml b/x_test/negative_for_iterable_ownership/peeper.toml new file mode 100644 index 00000000..bd8b4f09 --- /dev/null +++ b/x_test/negative_for_iterable_ownership/peeper.toml @@ -0,0 +1,7 @@ +name = "negative_for_iterable_ownership" +build = "lib" + +[test] +mode = "check" +outcome = "failure" +stderr_contains = ["T0034", "value used after move", "T0037"] diff --git a/x_test/negative_for_iterable_ownership/src/main.peep b/x_test/negative_for_iterable_ownership/src/main.peep new file mode 100644 index 00000000..ec1310c1 --- /dev/null +++ b/x_test/negative_for_iterable_ownership/src/main.peep @@ -0,0 +1,14 @@ +fn Take(_: []i32) {} + +fn Moved() { + let values = []i32{1, 2}; + Take(values); + for value in values {} +} + +fn Mutated() { + let mut values = []i32{1, 2}; + for value in values { + values |> append(3); + } +} diff --git a/x_test/negative_for_malformed_header/peeper.toml b/x_test/negative_for_malformed_header/peeper.toml new file mode 100644 index 00000000..6061e1e0 --- /dev/null +++ b/x_test/negative_for_malformed_header/peeper.toml @@ -0,0 +1,7 @@ +name = "negative_for_malformed_header" +build = "lib" + +[test] +mode = "check" +outcome = "failure" +stderr_contains = ["P0006", "expected identifier"] diff --git a/x_test/negative_for_malformed_header/src/main.peep b/x_test/negative_for_malformed_header/src/main.peep new file mode 100644 index 00000000..edba58a1 --- /dev/null +++ b/x_test/negative_for_malformed_header/src/main.peep @@ -0,0 +1,4 @@ +fn Invalid() { + let values = [2]i32{1, 2}; + for index, in values {} +} diff --git a/x_test/negative_for_non_iterable/peeper.toml b/x_test/negative_for_non_iterable/peeper.toml new file mode 100644 index 00000000..0d0c8b7c --- /dev/null +++ b/x_test/negative_for_non_iterable/peeper.toml @@ -0,0 +1,7 @@ +name = "negative_for_non_iterable" +build = "lib" + +[test] +mode = "check" +outcome = "failure" +stderr_contains = ["cannot iterate over i32"] diff --git a/x_test/negative_for_non_iterable/src/main.peep b/x_test/negative_for_non_iterable/src/main.peep new file mode 100644 index 00000000..12ef185f --- /dev/null +++ b/x_test/negative_for_non_iterable/src/main.peep @@ -0,0 +1,3 @@ +fn Invalid() { + for value in 5 {} +} diff --git a/x_test/negative_for_sequence_requirements/peeper.toml b/x_test/negative_for_sequence_requirements/peeper.toml new file mode 100644 index 00000000..4fb3e055 --- /dev/null +++ b/x_test/negative_for_sequence_requirements/peeper.toml @@ -0,0 +1,7 @@ +name = "negative_for_sequence_requirements" +build = "lib" + +[test] +mode = "check" +outcome = "failure" +stderr_contains = ["for-in requires addressable array storage", "for-in requires copyable sequence elements"] diff --git a/x_test/negative_for_sequence_requirements/src/main.peep b/x_test/negative_for_sequence_requirements/src/main.peep new file mode 100644 index 00000000..e3e64e27 --- /dev/null +++ b/x_test/negative_for_sequence_requirements/src/main.peep @@ -0,0 +1,12 @@ +struct Item { + value: i32 +} + +fn TemporaryStorage() { + for value in []i32{1, 2} {} +} + +fn MoveOnlyElements() { + let items = [1]Item{.{ value = 1 }}; + for item in items {} +} diff --git a/x_test/negative_for_string/peeper.toml b/x_test/negative_for_string/peeper.toml new file mode 100644 index 00000000..6a77f6df --- /dev/null +++ b/x_test/negative_for_string/peeper.toml @@ -0,0 +1,7 @@ +name = "negative_for_string" +build = "lib" + +[test] +mode = "check" +outcome = "failure" +stderr_contains = ["string iteration requires"] diff --git a/x_test/negative_for_string/src/main.peep b/x_test/negative_for_string/src/main.peep new file mode 100644 index 00000000..3c26abce --- /dev/null +++ b/x_test/negative_for_string/src/main.peep @@ -0,0 +1,3 @@ +fn Invalid() { + for value in "abc" {} +} diff --git a/x_test/negative_for_unbounded_range/peeper.toml b/x_test/negative_for_unbounded_range/peeper.toml new file mode 100644 index 00000000..d0ccd69b --- /dev/null +++ b/x_test/negative_for_unbounded_range/peeper.toml @@ -0,0 +1,7 @@ +name = "negative_for_unbounded_range" +build = "lib" + +[test] +mode = "check" +outcome = "failure" +stderr_contains = ["P0003", "range iteration requires an end bound"] diff --git a/x_test/negative_for_unbounded_range/src/main.peep b/x_test/negative_for_unbounded_range/src/main.peep new file mode 100644 index 00000000..3e99a1cb --- /dev/null +++ b/x_test/negative_for_unbounded_range/src/main.peep @@ -0,0 +1,3 @@ +fn Invalid() { + for value in 0.. {} +} diff --git a/x_test/negative_for_zero_entry_move/peeper.toml b/x_test/negative_for_zero_entry_move/peeper.toml new file mode 100644 index 00000000..9e263989 --- /dev/null +++ b/x_test/negative_for_zero_entry_move/peeper.toml @@ -0,0 +1,7 @@ +name = "negative_for_zero_entry_move" +build = "lib" + +[test] +mode = "check" +outcome = "failure" +stderr_contains = ["T0007", "ownership state differs across control-flow paths"] diff --git a/x_test/negative_for_zero_entry_move/src/main.peep b/x_test/negative_for_zero_entry_move/src/main.peep new file mode 100644 index 00000000..92ae23c4 --- /dev/null +++ b/x_test/negative_for_zero_entry_move/src/main.peep @@ -0,0 +1,18 @@ +enum Resource { + Owned: { value: *i32 }, + Pending +} + +fn Invalid(resource: Resource, start: i32, end: i32) { + for i in start..end { + match resource { + Resource::Owned with { value = owned } => { + free(owned); + break; + } + Resource::Pending => { + break; + } + } + } +}