From d61433666b22ae36bc634ba58c6790fda3acb284 Mon Sep 17 00:00:00 2001 From: itsfuad Date: Tue, 8 Sep 2026 13:25:18 +0600 Subject: [PATCH 1/5] Add static iteration over local struct cursors Recognize Next() returning an optional scalar item with one loop binding. Expand into checked calls, optional guards and scoped bindings before CFG, effects and ownership analysis; preserve built-in loops and central receiver guards. Keep temporary sources, complex places, richer items and cross-module method discovery outside this first slice. Add actionable diagnostics, phase regressions and runnable positive/negative fixtures. Share synthetic node IDs with default expansion, preserve lexical scope identity through InsertParent, centralize structural recognition and reuse generated-node indexing. These helpers protect semantic invariants and avoid duplicated phase logic. Validated with go test ./..., go run ./scripts/bundle.go, the full bundled x_test suite, gofmt and git diff --check. Refs #123. --- internal/frontend/ast/clone.go | 11 +- internal/ir/cfg/build.go | 4 + internal/ir/hir/lower/module_lower.go | 3 + internal/ir/hir/lower/module_lower_test.go | 28 ++++ internal/pipeline/pipeline.go | 1 + internal/project/modules.go | 23 +++- .../definiteinit/initialization_test.go | 15 +++ internal/semantics/effect/build_test.go | 26 ++++ .../semantics/ownership/ownership_test.go | 33 +++++ internal/semantics/symbols/scope.go | 8 ++ internal/semantics/typechecker/check_stmt.go | 126 +++++++++++++++++- internal/semantics/typechecker/flow_test.go | 48 +++++++ internal/semantics/typechecker/for_in_test.go | 51 +++++++ internal/semantics/typecheckresult/result.go | 8 +- .../negative_structural_iterator/peeper.toml | 8 ++ .../src/main.peep | 30 +++++ .../peeper.toml | 7 + .../src/main.peep | 14 ++ .../runtime_structural_iterator/peeper.toml | 6 + .../runtime_structural_iterator/src/main.peep | 59 ++++++++ 20 files changed, 496 insertions(+), 13 deletions(-) create mode 100644 x_test/negative_structural_iterator/peeper.toml create mode 100644 x_test/negative_structural_iterator/src/main.peep create mode 100644 x_test/negative_structural_iterator_move/peeper.toml create mode 100644 x_test/negative_structural_iterator_move/src/main.peep create mode 100644 x_test/runtime_structural_iterator/peeper.toml create mode 100644 x_test/runtime_structural_iterator/src/main.peep diff --git a/internal/frontend/ast/clone.go b/internal/frontend/ast/clone.go index a8793ed8..1a2365df 100644 --- a/internal/frontend/ast/clone.go +++ b/internal/frontend/ast/clone.go @@ -4,6 +4,12 @@ import "sync/atomic" var nextSyntheticNodeID atomic.Uint32 +// NewSyntheticNodeID shares one identity space across checked expansions and +// default-argument clones, disjoint from parser-assigned nodes. +func NewSyntheticNodeID() NodeID { + return NodeID(nextSyntheticNodeID.Add(1) | (1 << 31)) +} + // SubstituteExpr clones an expression for call-site expansion. Parameter // identifiers are replaced with their already-evaluated argument expressions; // every cloned node gets a separate high-range ID so semantic caches cannot @@ -18,11 +24,8 @@ func SubstituteExpr(expr Expr, substitutions map[string]Expr) (cloned Expr, defa } defaultClones = make(map[NodeID]NodeID) argumentClones = make(map[NodeID]NodeID) - cloneID := func() NodeID { - return NodeID(nextSyntheticNodeID.Add(1) | (1 << 31)) - } newID := func(original NodeID, fromArgument bool) NodeID { - id := cloneID() + id := NewSyntheticNodeID() if fromArgument { argumentClones[id] = original } else { diff --git a/internal/ir/cfg/build.go b/internal/ir/cfg/build.go index 2d6b184d..db21d21d 100644 --- a/internal/ir/cfg/build.go +++ b/internal/ir/cfg/build.go @@ -34,6 +34,7 @@ type LoopEntryQuery func(ast.NodeID) bool type BuildQueries struct { MatchCases MatchCaseQuery LoopGuaranteedEntry LoopEntryQuery + CheckedIterations map[ast.NodeID]*ast.ForStmt } // BuildModule creates immutable control-flow topology from typed source syntax. @@ -184,6 +185,9 @@ func (b *builder) buildStmt(stmt ast.Stmt, current *Block, scopeID ir.NodeID) *B } return join case *ast.ForStmt: + if checked := b.queries.CheckedIterations[node.ID()]; checked != nil { + node = checked + } loopID := ir.NodeID(node.ID()) init := b.newBlock(BlockLoopInit, ast.LocOf(node)) bodyBlock := b.newBlock(BlockLoopBody, ast.LocOf(node)) diff --git a/internal/ir/hir/lower/module_lower.go b/internal/ir/hir/lower/module_lower.go index 74a96d67..f5d0f67b 100644 --- a/internal/ir/hir/lower/module_lower.go +++ b/internal/ir/hir/lower/module_lower.go @@ -319,6 +319,9 @@ 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 { + if checked := module.Typechecking.CheckedIterations[node.ID()]; checked != nil { + node = checked + } 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)}, diff --git a/internal/ir/hir/lower/module_lower_test.go b/internal/ir/hir/lower/module_lower_test.go index bac4b415..3ac068a7 100644 --- a/internal/ir/hir/lower/module_lower_test.go +++ b/internal/ir/hir/lower/module_lower_test.go @@ -48,6 +48,7 @@ func generateTestHIR(t *testing.T, filePath, importPath, src string, beforeLower module.CFG = cfg.BuildModule(module.AST, cfg.BuildQueries{ MatchCases: module.Typechecking.MatchCases, LoopGuaranteedEntry: module.Typechecking.ForLoopGuaranteedEntry, + CheckedIterations: module.Typechecking.CheckedIterations, }) module.Flow = typechecker.CheckFlow(ctx, module) if diag.HasErrors() { @@ -60,6 +61,33 @@ func generateTestHIR(t *testing.T, filePath, importPath, src string, beforeLower return out } +func TestGenerateHIRConsumesStructuralIteratorEvidence(t *testing.T) { + out := generateTestHIR(t, "hir_iterator_test"+peeper.SourceExt, "hir_iterator_test", `struct Cursor {} +fn (self: &mut Cursor) Next() -> ?i32 { return none; } +fn main() { + let mut cursor = Cursor.{}; + for item in cursor { if item == 1 { continue; } } +}`, func(module *project.Module) { module.Bindings.MethodsByReceiver = nil }) + var loop *hir.For + for _, fn := range out.Funcs { + for _, stmt := range fn.Body.Stmts { + if candidate, ok := stmt.(*hir.For); ok { + loop = candidate + } + } + } + if loop == nil || loop.Init != nil || loop.Cond != nil || loop.Next != nil || loop.Bindings != nil { + t.Fatalf("custom loop unexpectedly has numeric segments: %#v", loop) + } + result := loop.Body.Stmts[0].(*hir.Binding) + if _, ok := result.Value.(*ir.Call); !ok { + t.Fatalf("advancement = %T, want static call", result.Value) + } + if _, ok := loop.Body.Stmts[1].(*hir.If); !ok { + t.Fatalf("missing optional exhaustion branch: %#v", loop.Body.Stmts) + } +} + 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 {} diff --git a/internal/pipeline/pipeline.go b/internal/pipeline/pipeline.go index 9bfe49c2..ac9e527c 100644 --- a/internal/pipeline/pipeline.go +++ b/internal/pipeline/pipeline.go @@ -438,6 +438,7 @@ func advanceModulePhase(ctx *project.CompilerContext, module *project.Module, di module.CFG = cfg.BuildModule(module.AST, cfg.BuildQueries{ MatchCases: module.Typechecking.MatchCases, LoopGuaranteedEntry: module.Typechecking.ForLoopGuaranteedEntry, + CheckedIterations: module.Typechecking.CheckedIterations, }) // Structure is checkable regardless of source validity: CFG construction // promises the same topology for a program that will not compile, and a diff --git a/internal/project/modules.go b/internal/project/modules.go index 276e5adb..6e37917f 100644 --- a/internal/project/modules.go +++ b/internal/project/modules.go @@ -120,14 +120,25 @@ func (m *Module) RebuildTypedASTIndex() { if m.Typechecking == nil { return } + indexGenerated := func(node ast.Node) bool { + if sourceLoop, ok := node.(*ast.ForStmt); ok { + // An outer expansion still contains original nested loops. Do not + // overwrite their checked trees depending on map traversal order. + if checked := m.Typechecking.CheckedIterations[sourceLoop.ID()]; checked != nil && checked != sourceLoop { + return false + } + } + if node != nil { + m.TypedASTNodes[node.ID()] = node + } + return true + } + for _, loop := range m.Typechecking.CheckedIterations { + ast.Inspect(loop, indexGenerated) + } for _, args := range m.Typechecking.EffectiveCallArguments { for _, arg := range args { - ast.Inspect(arg, func(node ast.Node) bool { - if node != nil { - m.TypedASTNodes[node.ID()] = node - } - return true - }) + ast.Inspect(arg, indexGenerated) } } } diff --git a/internal/semantics/definiteinit/initialization_test.go b/internal/semantics/definiteinit/initialization_test.go index e98c65da..087e2602 100644 --- a/internal/semantics/definiteinit/initialization_test.go +++ b/internal/semantics/definiteinit/initialization_test.go @@ -42,6 +42,7 @@ func analyzeInitializationSource(t *testing.T, source string) (*functionResult, module.CFG = cfg.BuildModule(module.AST, cfg.BuildQueries{ MatchCases: module.Typechecking.MatchCases, LoopGuaranteedEntry: module.Typechecking.ForLoopGuaranteedEntry, + CheckedIterations: module.Typechecking.CheckedIterations, }) symbol, found := module.ModuleScope.Lookup("choose") if !found || symbol == nil { @@ -70,6 +71,20 @@ func analyzeInitializationSource(t *testing.T, source string) (*functionResult, return result, diag, module } +func TestStructuralIterationDoesNotGuaranteeEntry(t *testing.T) { + _, diag, _ := analyzeInitializationSource(t, `struct Cursor {} +fn (self: &Cursor) Next() -> ?i32 { return none; } +fn choose() -> i32 { + let cursor = Cursor.{}; + let mut result: i32; + for item in cursor { result = item; } + return result; +}`) + if !diag.HasErrors() || !strings.Contains(diag.EmitAllToString(), "used before it's initialized") { + t.Fatalf("expected zero-entry uninitialized diagnostic:\n%s", diag.EmitAllToString()) + } +} + func TestInitializationIgnoresTerminatingBranchAtJoin(t *testing.T) { result, diag, _ := analyzeInitializationSource(t, `fn choose(flag: bool) -> i32 { let mut value: i32; diff --git a/internal/semantics/effect/build_test.go b/internal/semantics/effect/build_test.go index 2fb9fd9f..ee03d6af 100644 --- a/internal/semantics/effect/build_test.go +++ b/internal/semantics/effect/build_test.go @@ -42,6 +42,7 @@ func buildEffects(t *testing.T, source string) (effect.Result, *project.Module) module.CFG = cfg.BuildModule(module.AST, cfg.BuildQueries{ MatchCases: module.Typechecking.MatchCases, LoopGuaranteedEntry: module.Typechecking.ForLoopGuaranteedEntry, + CheckedIterations: module.Typechecking.CheckedIterations, }) if diag.HasErrors() { t.Fatalf("unexpected diagnostics:\n%s", diag.EmitAllToString()) @@ -136,6 +137,31 @@ func describe(op effect.Op) string { return "unknown" } +func TestStructuralIterationPublishesOrdinaryReceiverCall(t *testing.T) { + result, module := buildEffects(t, `struct Cursor {} +fn (self: &mut Cursor) Next() -> ?i32 { return none; } +fn probe() { + let mut cursor = Cursor.{}; + for item in cursor { if item == 1 { continue; } } +}`) + calls, ends, borrows := 0, 0, 0 + for _, op := range publishedOps(t, result, module, "probe") { + switch op { + case "call": + calls++ + case "end": + ends++ + case "borrow cursor": + borrows++ + case "iterate cursor": + t.Fatal("custom iteration acquired a builtin sequence lifetime") + } + } + if calls != 1 || ends != 1 || borrows != 1 { + t.Fatalf("advancement effects: calls=%d ends=%d borrows=%d", calls, ends, borrows) + } +} + func TestBuildPublishesProjectionOperands(t *testing.T) { for _, test := range []struct { name string diff --git a/internal/semantics/ownership/ownership_test.go b/internal/semantics/ownership/ownership_test.go index aea44b7e..657f32a8 100644 --- a/internal/semantics/ownership/ownership_test.go +++ b/internal/semantics/ownership/ownership_test.go @@ -53,6 +53,7 @@ func checkOwnershipSource(t *testing.T, src string) *ownershipResult { module.CFG = cfg.BuildModule(module.AST, cfg.BuildQueries{ MatchCases: module.Typechecking.MatchCases, LoopGuaranteedEntry: module.Typechecking.ForLoopGuaranteedEntry, + CheckedIterations: module.Typechecking.CheckedIterations, }) module.Flow = typechecker.CheckFlow(ctx, module) module.Effects = effect.Build(module.CFG, module.TypedASTNodes, effect.BuildQueries{ @@ -70,6 +71,38 @@ func checkOwnershipSource(t *testing.T, src string) *ownershipResult { return &ownershipResult{DiagnosticBag: diag, ctx: ctx, module: module} } +func TestStructuralIterationUsesOrdinaryCallGuards(t *testing.T) { + for _, test := range []struct { + name, receiver, before, body, after, diagnostic string + }{ + {name: "mutable receiver", receiver: "&mut Cursor"}, + {name: "shared receiver", receiver: "&Cursor"}, + {name: "body access between advances", receiver: "&mut Cursor", body: "cursor.value = item;"}, + {name: "consuming backedge", receiver: "Cursor", diagnostic: "moved"}, + {name: "consuming single attempt", receiver: "Cursor", body: "break;"}, + {name: "moved source", receiver: "&mut Cursor", before: "Consume(cursor);", diagnostic: "moved"}, + {name: "body move before backedge", receiver: "&mut Cursor", body: "Consume(cursor);", diagnostic: "moved"}, + {name: "live conflicting borrow", receiver: "&mut Cursor", before: "let borrowed = &cursor;", after: "let value = borrowed.value;", diagnostic: "borrow"}, + } { + t.Run(test.name, func(t *testing.T) { + for _, implicit := range []bool{false, true} { + loop := "for { let result = cursor.Next(); if result == none { break; } let item: i32 = result; " + test.body + " }" + if implicit { + loop = "for item in cursor { " + test.body + " }" + } + result := checkOwnershipSource(t, "struct Cursor { value: i32, limit: i32 }\nfn (self: "+test.receiver+") Next() -> ?i32 { return none; }\nfn Consume(cursor: Cursor) {}\nfn main() { let mut cursor = Cursor.{ value = 0, limit = 3 }; "+test.before+loop+test.after+" }") + if test.diagnostic == "" { + if result.HasErrors() { + t.Fatalf("implicit=%v unexpected diagnostics:\n%s", implicit, result.EmitAllToString()) + } + } else if !result.HasErrors() || !strings.Contains(result.EmitAllToString(), test.diagnostic) { + t.Fatalf("implicit=%v expected %q:\n%s", implicit, test.diagnostic, result.EmitAllToString()) + } + } + }) + } +} + func inspectFunctionAnalysis(t *testing.T, result *ownershipResult, name string) *analyzer { t.Helper() sym, found := result.module.ModuleScope.Lookup(name) diff --git a/internal/semantics/symbols/scope.go b/internal/semantics/symbols/scope.go index 1aa1cd05..741ad74a 100644 --- a/internal/semantics/symbols/scope.go +++ b/internal/semantics/symbols/scope.go @@ -23,6 +23,14 @@ func NewScope(parent *Scope) *Scope { } } +// InsertParent adds a generated scope between this scope and its lexical +// parent without invalidating resolved child symbols. An explicit parent keeps +// repeated semantic checks from nesting stale generated scopes. +func (s *Scope) InsertParent(parent *Scope) *Scope { + s.parent = NewScope(parent) + return s.parent +} + func (s *Scope) Parent() *Scope { if s == nil { return nil diff --git a/internal/semantics/typechecker/check_stmt.go b/internal/semantics/typechecker/check_stmt.go index 9fcb477d..1e2bc274 100644 --- a/internal/semantics/typechecker/check_stmt.go +++ b/internal/semantics/typechecker/check_stmt.go @@ -625,9 +625,24 @@ func (c *checker) checkForInStmt(scope *symbols.Scope, node *ast.ForStmt, return } evidence.ElementType = elem } else { + if valid && !c.siteOnly { + if !c.checkStructuralIteration(scope, node, iterableType) { + d := invalidExpressionError(node.Iterable, "cannot iterate over "+typeinfo.TypeText(iterableType)) + if _, isInterface := typeinfo.InterfaceTypeOf(iterableType); isInterface { + d.WithNote("for loops do not support interface values, even when the interface declares `Next`"). + WithHelp("iterate over the original struct value before passing it as an interface") + } else { + d.WithHelp("use a range, array, slice, or a struct value with a `Next()` method available here"). + WithNote("`Next()` must return an optional item, such as `?i32`; method names are case-sensitive") + } + c.ctx.Diagnostics.Add(d) + } + if checked := c.module.Typechecking.CheckedIterations[node.ID()]; checked != nil { + c.checkStmt(scope, checked, returnType) + return + } + } valid = false - c.ctx.Diagnostics.Add(invalidExpressionError(node.Iterable, - "cannot iterate over "+typeinfo.TypeText(iterableType))) } } if node.Index != nil { @@ -667,6 +682,113 @@ func (c *checker) checkForInStmt(scope *symbols.Scope, node *ast.ForStmt, return c.loopDepth-- } +// checkStructuralIteration publishes an ordinary checked loop rather than +// adding hidden call/borrow semantics in lowering. The first slice accepts +// local concrete cursor places and scalar items; factories, complex places and +// resource-bearing payloads need the lifecycle work tracked in issue #123. +func (c *checker) checkStructuralIteration(scope *symbols.Scope, node *ast.ForStmt, iterableType typeinfo.Type) bool { + method, found := c.lookupDeclaredCallableMember(iterableType, "Next") + if !found || method.Symbol == nil { + return false + } + if node.Index != nil { + c.ctx.Diagnostics.Add(invalidExpressionError(node.Index, "iterator loops provide an item, not an index"). + WithHelp("use `for item in iterator`; if you need an index, maintain a separate counter")) + return true + } + fnType, callable := method.Type.(*typeinfo.FuncType) + decl, declared := method.Symbol.ASTNode.(*ast.FnDecl) + if !callable || !declared || decl.Receiver == nil || len(decl.ParamsWithReceiver()) != 1 || len(fnType.Params) != 1 { + c.ctx.Diagnostics.Add(invalidExpressionError(node.Iterable, "`Next` cannot take arguments in a for loop"). + WithSecondaryLabel(method.Symbol.Location, "this method declares additional parameters"). + WithNote("the loop calls `Next()` without arguments; parameters with defaults are not supported either"). + WithHelp("move iteration settings into fields on your iterator, or call `Next(...)` explicitly in a loop")) + return true + } + optional, optionalResult := typeinfo.Underlying(fnType.Return).(*typeinfo.OptionalType) + if !optionalResult || optional.Inner == nil { + c.ctx.Diagnostics.Add(invalidExpressionError(node.Iterable, "`Next` must return an optional item"). + WithSecondaryLabel(ast.LocOf(decl.ReturnType), "return type is "+typeinfo.TypeText(fnType.Return)). + WithHelp("return an optional type, such as `?i32`: return an item to continue, or `none` to end the loop")) + return true + } + cursor, local := node.Iterable.(*ast.Ident) + var cursorSymbol *symbols.Symbol + if local { + cursorSymbol = c.module.Bindings.NodeSymbols[cursor.ID()] + } + _, concrete := typeinfo.Underlying(iterableType).(*typeinfo.StructType) + var binding *ast.LetDecl + if cursorSymbol != nil { + binding, _ = cursorSymbol.ASTNode.(*ast.LetDecl) + } + if !local || binding == nil || binding.IsModuleVar || !concrete { + c.ctx.Diagnostics.Add(invalidExpressionError(node.Iterable, "this iterator must be a struct value stored in a local variable"). + WithNote("iterating directly over function results, fields, or references is not supported yet"). + WithHelp("store the iterator struct in a local `let` binding before the loop; use `let mut` if `Next` changes it")) + return true + } + switch typeinfo.Underlying(optional.Inner).(type) { + case *typeinfo.IntegerType, *typeinfo.FloatType, *typeinfo.BoolType, *typeinfo.ByteType, *typeinfo.CharType: + default: + c.ctx.Diagnostics.Add(invalidExpressionError(node.Iterable, + "iterator items of type "+typeinfo.TypeText(optional.Inner)+" are not supported yet"). + WithSecondaryLabel(ast.LocOf(decl.ReturnType), "the item type comes from this optional return type"). + WithNote("for loops currently support iterator items of integer, floating-point, bool, byte, and char types"). + WithHelp("call `Next()` explicitly in a loop and stop when it returns `none`")) + return true + } + + location := ast.LocOf(node) + resultName := fmt.Sprintf("$for.result.%d", node.ID()) + result := &ast.LetDecl{ + Name: &ast.Ident{Name: resultName, Location: location}, + Value: &ast.CallExpr{Callee: &ast.SelectorExpr{ + Expr: node.Iterable, Name: &ast.Ident{Name: "Next", Location: location}, Location: location, + }, Location: location}, Location: location, + } + stop := &ast.IfStmt{ + Cond: &ast.BinaryExpr{Left: &ast.Ident{Name: resultName, Location: location}, Op: "==", Right: &ast.NoneLit{Location: location}, Location: location}, + Then: &ast.BlockStmt{Stmts: []ast.Stmt{&ast.BreakStmt{Location: location}}, Location: location}, Location: location, + } + item := &ast.LetDecl{ + Name: node.Value, + Type: &ast.NamedType{Name: typeinfo.TypeText(optional.Inner), Location: location}, + Value: &ast.Ident{Name: resultName, Location: location}, Location: location, + } + body := *node.Body + body.Stmts = append([]ast.Stmt{item}, node.Body.Stmts...) + checked := &ast.ForStmt{ + NodeIDHolder: node.NodeIDHolder, + Body: &ast.BlockStmt{Stmts: []ast.Stmt{result, stop, &body}, Location: location}, Location: location, + } + ast.Inspect(checked, func(generated ast.Node) bool { + if generated == nil { + return false + } + if generated.ID() == 0 { + generated.SetID(ast.NewSyntheticNodeID()) + } + return true + }) + bodyScope := c.module.Bindings.BlockScopes[node.Body.ID()] + iterationScope := bodyScope.InsertParent(scope) + c.module.Bindings.BlockScopes[checked.Body.ID()] = iterationScope + c.module.Bindings.BlockScopes[stop.Then.ID()] = symbols.NewScope(iterationScope) + resultSymbol := symbols.New(resultName, symbols.SymbolVar, result, location) + resultSymbol.Used = true + if err := iterationScope.Declare(resultSymbol); err != nil { + panic(err) + } + c.module.Bindings.NodeSymbols[result.Name.ID()] = resultSymbol + c.module.Bindings.NodeSymbols[stop.Cond.(*ast.BinaryExpr).Left.ID()] = resultSymbol + c.module.Bindings.NodeSymbols[item.Value.ID()] = resultSymbol + itemSymbol := c.module.Bindings.NodeSymbols[node.Value.ID()] + itemSymbol.ASTNode = item + c.module.Typechecking.CheckedIterations[node.ID()] = checked + return true +} + func (c *checker) bindLoopVariable(name *ast.Ident, typ typeinfo.Type) { if typ == nil { return diff --git a/internal/semantics/typechecker/flow_test.go b/internal/semantics/typechecker/flow_test.go index 044ad1a4..74067f3f 100644 --- a/internal/semantics/typechecker/flow_test.go +++ b/internal/semantics/typechecker/flow_test.go @@ -42,11 +42,59 @@ func checkFlowSource(t *testing.T, src string) (*project.Module, *diagnostics.Di module.CFG = cfg.BuildModule(module.AST, cfg.BuildQueries{ MatchCases: module.Typechecking.MatchCases, LoopGuaranteedEntry: module.Typechecking.ForLoopGuaranteedEntry, + CheckedIterations: module.Typechecking.CheckedIterations, }) module.Flow = CheckFlow(ctx, module) return module, diag } +func TestStructuralIterationPublishesCheckedOperations(t *testing.T) { + module, diag := checkFlowSource(t, `struct Cursor { value: i32, limit: i32 } +fn (self: &mut Cursor) Next() -> ?i32 { return none; } +fn main() { + let mut cursor = Cursor.{ value = 0, limit = 3 }; + for cursor in cursor { + let item: i32 = cursor; + let mut inner = Cursor.{ value = item, limit = 3 }; + for value in inner { if value == 1 { continue; } } + } +}`) + if diag.HasErrors() { + t.Fatalf("unexpected diagnostics:\n%s", diag.EmitAllToString()) + } + if len(module.Typechecking.CheckedIterations) != 2 || len(module.Typechecking.ForIterations) != 0 { + t.Fatalf("iteration evidence = %#v", module.Typechecking) + } + if err := module.CFG.Validate(); err != nil { + t.Fatal(err) + } + for id, loop := range module.Typechecking.CheckedIterations { + if module.TypedASTNodes[id] != loop || loop.Iterable != nil || loop.Cond != nil { + t.Fatalf("checked loop not indexed: %#v", loop) + } + result := loop.Body.Stmts[0].(*ast.LetDecl) + call := result.Value.(*ast.CallExpr) + selector := call.Callee.(*ast.SelectorExpr) + if module.Bindings.NodeSymbols[selector.Name.ID()] == nil { + t.Fatal("missing static method evidence") + } + if mutable, found := module.Typechecking.ReferenceArguments[selector.Expr.ID()]; !found || !mutable { + t.Fatal("generated receiver missing ordinary mutable-reference evidence") + } + body := loop.Body.Stmts[2].(*ast.BlockStmt) + item := body.Stmts[0].(*ast.LetDecl) + if got := typeinfo.TypeText(module.Bindings.NodeSymbols[item.Name.ID()].Type); got != "i32" { + t.Fatalf("item type = %s", got) + } + ast.Inspect(loop, func(node ast.Node) bool { + if node != nil && module.TypedASTNodes[node.ID()] == nil { + t.Errorf("generated node %T/%d not indexed", node, node.ID()) + } + return true + }) + } +} + func TestNamedEnumCaseTestsRefineExactFields(t *testing.T) { module, diag := checkFlowSource(t, `enum Choice { Left: { value: i32 }, diff --git a/internal/semantics/typechecker/for_in_test.go b/internal/semantics/typechecker/for_in_test.go index ab0dfa74..e855558f 100644 --- a/internal/semantics/typechecker/for_in_test.go +++ b/internal/semantics/typechecker/for_in_test.go @@ -10,6 +10,57 @@ import ( "compiler/internal/target" ) +func TestStructuralIterationRecognition(t *testing.T) { + for _, test := range []struct { + name, method, binding, header, diagnostic, hint string + }{ + {name: "mutable scalar", method: "fn (self: &mut Cursor) Next() -> ?i32 { return none; }"}, + {name: "shared bool", method: "fn (self: &Cursor) Next() -> ?bool { return none; }", binding: "let cursor = Cursor.{};"}, + {name: "no method", diagnostic: "cannot iterate over", hint: "method names are case-sensitive"}, + {name: "lowercase is not protocol", method: "fn (self: &Cursor) next() -> ?i32 { return none; }", diagnostic: "cannot iterate over"}, + {name: "wrong return", method: "fn (self: &Cursor) Next() -> i32 { return 0; }", diagnostic: "must return an optional item", hint: "return an item to continue, or `none` to end the loop"}, + {name: "extra default", method: "fn (self: &Cursor) Next(value: i32 = 0) -> ?i32 { return value; }", diagnostic: "cannot take arguments in a for loop", hint: "parameters with defaults are not supported either"}, + {name: "index", method: "fn (self: &Cursor) Next() -> ?i32 { return none; }", header: "index, item in cursor", diagnostic: "provide an item, not an index", hint: "maintain a separate counter"}, + {name: "immutable", method: "fn (self: &mut Cursor) Next() -> ?i32 { return none; }", binding: "let cursor = Cursor.{};", diagnostic: "mutable"}, + {name: "temporary deferred", method: "fn (self: &mut Cursor) Next() -> ?i32 { return none; }", header: "item in Cursor.{}", diagnostic: "stored in a local variable", hint: "use `let mut` if `Next` changes it"}, + {name: "nested optional deferred", method: "fn (self: &Cursor) Next() -> ? ?i32 { return none; }", diagnostic: "are not supported yet", hint: "call `Next()` explicitly in a loop"}, + } { + t.Run(test.name, func(t *testing.T) { + binding, header := test.binding, test.header + if binding == "" { + binding = "let mut cursor = Cursor.{};" + } + if header == "" { + header = "item in cursor" + } + module, diag := checkTypeModule(t, "struct Cursor {}\n"+test.method+"\nfn main() { "+binding+" for "+header+" {} }") + if test.diagnostic == "" { + if diag.HasErrors() || len(module.Typechecking.CheckedIterations) != 1 { + t.Fatalf("missing checked iteration:\n%s", diag.EmitAllToString()) + } + } else if !diag.HasErrors() || !strings.Contains(diag.EmitAllToString(), test.diagnostic) { + t.Fatalf("expected %q:\n%s", test.diagnostic, diag.EmitAllToString()) + } + if test.hint != "" && !strings.Contains(diag.EmitAllToString(), test.hint) { + t.Fatalf("missing actionable hint %q:\n%s", test.hint, diag.EmitAllToString()) + } + }) + } +} + +func TestRejectedStructuralIterationStillChecksBody(t *testing.T) { + _, diag := checkTypeModule(t, `struct Cursor {} +fn (self: &Cursor) Next() -> i32 { return 0; } +fn main() { + let cursor = Cursor.{}; + for item in cursor { let invalid: bool = 1; } +}`) + text := diag.EmitAllToString() + if !strings.Contains(text, "must return an optional item") || !strings.Contains(text, "cannot be used as bool") { + t.Fatalf("expected header and body diagnostics:\n%s", text) + } +} + func TestCheckForInOverRange(t *testing.T) { src := `fn main() -> i32 { let mut total: i32 = 0; diff --git a/internal/semantics/typecheckresult/result.go b/internal/semantics/typecheckresult/result.go index 1e68d7d1..8ee8f9c8 100644 --- a/internal/semantics/typecheckresult/result.go +++ b/internal/semantics/typecheckresult/result.go @@ -146,7 +146,12 @@ type Result struct { CaseTests map[ast.NodeID]CaseTest Matches map[ast.NodeID]Match ForIterations map[ast.NodeID]ForIteration - ExprTypes map[ast.NodeID]typeinfo.Type + // CheckedIterations contains structural loops expanded into ordinary checked + // statements before CFG/flow/effects/ownership. These loops have no numeric + // cursor and never appear in the builtin ForIterations table. Source syntax + // stays unchanged; CFG, typed-node indexing and HIR consume this same tree. + CheckedIterations map[ast.NodeID]*ast.ForStmt + ExprTypes map[ast.NodeID]typeinfo.Type // ValueUses classifies every ownership-relevant value use, keyed by the // used expression's node ID. Reference parameters publish UseRead; the // borrow machinery in ownership still governs them. @@ -174,6 +179,7 @@ func New() *Result { CaseTests: make(map[ast.NodeID]CaseTest), Matches: make(map[ast.NodeID]Match), ForIterations: make(map[ast.NodeID]ForIteration), + CheckedIterations: make(map[ast.NodeID]*ast.ForStmt), ExprTypes: make(map[ast.NodeID]typeinfo.Type), ValueUses: make(map[ast.NodeID]typeinfo.UseKind), ReferenceArguments: make(map[ast.NodeID]bool), diff --git a/x_test/negative_structural_iterator/peeper.toml b/x_test/negative_structural_iterator/peeper.toml new file mode 100644 index 00000000..6be9c24c --- /dev/null +++ b/x_test/negative_structural_iterator/peeper.toml @@ -0,0 +1,8 @@ +name = "negative_structural_iterator" +build = "program" + +[test] +mode = "check" +outcome = "failure" +stderr_contains = ["must return an optional item", "cannot take arguments in a for loop", "provide an item, not an index", "mutable", "stored in a local variable", "iterator items of type Cursor are not supported yet", "cannot iterate over", "cannot be used as bool", "return type is i32", "parameters with defaults are not supported either", "maintain a separate counter", "store the iterator struct in a local", "call `Next()` explicitly in a loop", "iterate over the original struct value before passing it as an interface"] +stderr_excludes = ["structural iteration", "local concrete cursor", "scalar items", "$for."] diff --git a/x_test/negative_structural_iterator/src/main.peep b/x_test/negative_structural_iterator/src/main.peep new file mode 100644 index 00000000..b6507c2c --- /dev/null +++ b/x_test/negative_structural_iterator/src/main.peep @@ -0,0 +1,30 @@ +struct WrongReturn {} +fn (self: &WrongReturn) Next() -> i32 { return 1; } + +struct Extra {} +fn (self: &Extra) Next(value: i32 = 1) -> ?i32 { return value; } + +struct Cursor {} +fn (self: &mut Cursor) Next() -> ?i32 { return none; } + +struct Aggregate {} +fn (self: &Aggregate) Next() -> ?Cursor { return none; } + +iface Erased { fn (&mut Self) Next() -> ?i32 } +fn RejectErased(cursor: &mut Erased) { for item in cursor {} } + +fn Factory() -> Cursor { return Cursor.{}; } + +fn main() { + let wrong = WrongReturn.{}; + for item in wrong { let invalid: bool = 1; } + let extra = Extra.{}; + for item in extra {} + let mut indexed = Cursor.{}; + for index, item in indexed {} + let immutable = Cursor.{}; + for item in immutable {} + for item in Factory() {} + let aggregate = Aggregate.{}; + for item in aggregate {} +} diff --git a/x_test/negative_structural_iterator_move/peeper.toml b/x_test/negative_structural_iterator_move/peeper.toml new file mode 100644 index 00000000..6e9fd07e --- /dev/null +++ b/x_test/negative_structural_iterator_move/peeper.toml @@ -0,0 +1,7 @@ +name = "negative_structural_iterator_move" +build = "program" + +[test] +mode = "check" +outcome = "failure" +stderr_contains = ["moved"] diff --git a/x_test/negative_structural_iterator_move/src/main.peep b/x_test/negative_structural_iterator_move/src/main.peep new file mode 100644 index 00000000..78fd80e3 --- /dev/null +++ b/x_test/negative_structural_iterator_move/src/main.peep @@ -0,0 +1,14 @@ +struct Consumed { value: i32, limit: i32 } +fn (self: Consumed) Next() -> ?i32 { return self.value; } + +struct Cursor { value: i32, limit: i32 } +fn (self: &mut Cursor) Next() -> ?i32 { return none; } +fn Consume(cursor: Cursor) {} + +fn main() { + let consumed = Consumed.{ value = 0, limit = 2 }; + for item in consumed {} + let mut moved = Cursor.{ value = 0, limit = 2 }; + Consume(moved); + for item in moved {} +} diff --git a/x_test/runtime_structural_iterator/peeper.toml b/x_test/runtime_structural_iterator/peeper.toml new file mode 100644 index 00000000..62f0c342 --- /dev/null +++ b/x_test/runtime_structural_iterator/peeper.toml @@ -0,0 +1,6 @@ +name = "runtime_structural_iterator" +build = "program" + +[test] +mode = "run" +outcome = "success" diff --git a/x_test/runtime_structural_iterator/src/main.peep b/x_test/runtime_structural_iterator/src/main.peep new file mode 100644 index 00000000..54d0ef12 --- /dev/null +++ b/x_test/runtime_structural_iterator/src/main.peep @@ -0,0 +1,59 @@ +struct Counter { + value: i32, + limit: i32, + calls: i32 +} + +fn (self: &mut Counter) Next() -> ?i32 { + self.calls = self.calls + 1; + if self.value >= self.limit { return none; } + let value = self.value; + self.value = self.value + 1; + return value; +} + +struct Constant {} +fn (self: &Constant) Next() -> ?i32 { return 9; } + +fn First() -> i32 { + let mut cursor = Counter.{ value = 4, limit = 8, calls = 0 }; + for item in cursor { return item; } + return -1; +} + +fn main() -> i32 { + let mut cursor = Counter.{ value = 0, limit = 5, calls = 0 }; + let mut total: i32 = 0; + for item in cursor { + let owned = alloc(item); + if item == 1 { continue; } + total = total + item; + if item == 2 { break; } + } + if total != 2 || cursor.value != 3 || cursor.calls != 3 { return 1; } + for item in cursor { total = total + item; } + if total != 9 || cursor.value != 5 || cursor.calls != 6 { return 2; } + for item in cursor { return 3; } + if cursor.calls != 7 { return 4; } + + let mut empty = Counter.{ value = 0, limit = 0, calls = 0 }; + for item in empty { return 5; } + if empty.calls != 1 { return 6; } + + let mut outer = Counter.{ value = 0, limit = 3, calls = 0 }; + let mut pairs: i32 = 0; + for left in outer { + let mut inner = Counter.{ value = 0, limit = 2, calls = 0 }; + for right in inner { pairs = pairs + left + right; } + if inner.calls != 3 { return 7; } + } + if pairs != 9 || outer.calls != 4 { return 8; } + + let constant = Constant.{}; + for item in constant { + if item != 9 { return 9; } + break; + } + if First() != 4 { return 10; } + return 0; +} From e08841567eec6783f9e5bcb4a694e011538c2ee5 Mon Sep 17 00:00:00 2001 From: itsfuad Date: Tue, 8 Sep 2026 17:42:55 +0600 Subject: [PATCH 2/5] Support temporary struct iterator sources Evaluate struct factories and literals once in an ordinary scoped binding. Generalize checked iteration evidence to a block so central cleanup handles source lifetime on exhaustion, break and return without affecting existing local cursor storage. Migrate CFG, typed-node indexing and HIR consumers. Add counted runtime factory coverage and cleanup-plan assertions; preserve canonical move checks and helpful diagnostics. No new production helpers or ownership policy. Validated with go test ./..., go run ./scripts/bundle.go, full bundled x_test suite, gofmt and git diff --check. Richer item binding and general place capture remain tracked in #123. --- internal/ir/cfg/build.go | 8 +- internal/ir/hir/lower/module_lower.go | 9 ++- internal/ir/hir/lower/module_lower_test.go | 4 +- internal/project/modules.go | 2 +- .../semantics/ownership/ownership_test.go | 36 +++++++++ internal/semantics/typechecker/check_stmt.go | 54 +++++++++++--- internal/semantics/typechecker/flow_test.go | 8 +- internal/semantics/typechecker/for_in_test.go | 4 +- internal/semantics/typecheckresult/result.go | 6 +- .../negative_structural_iterator/peeper.toml | 2 +- .../src/main.peep | 5 +- .../src/main.peep | 3 + x_test/runtime_iterator_factory/peeper.toml | 6 ++ x_test/runtime_iterator_factory/src/main.peep | 73 +++++++++++++++++++ 14 files changed, 191 insertions(+), 29 deletions(-) create mode 100644 x_test/runtime_iterator_factory/peeper.toml create mode 100644 x_test/runtime_iterator_factory/src/main.peep diff --git a/internal/ir/cfg/build.go b/internal/ir/cfg/build.go index db21d21d..ca343843 100644 --- a/internal/ir/cfg/build.go +++ b/internal/ir/cfg/build.go @@ -34,7 +34,7 @@ type LoopEntryQuery func(ast.NodeID) bool type BuildQueries struct { MatchCases MatchCaseQuery LoopGuaranteedEntry LoopEntryQuery - CheckedIterations map[ast.NodeID]*ast.ForStmt + CheckedIterations map[ast.NodeID]*ast.BlockStmt } // BuildModule creates immutable control-flow topology from typed source syntax. @@ -185,8 +185,10 @@ func (b *builder) buildStmt(stmt ast.Stmt, current *Block, scopeID ir.NodeID) *B } return join case *ast.ForStmt: - if checked := b.queries.CheckedIterations[node.ID()]; checked != nil { - node = checked + if node.Iterable != nil { + if checked := b.queries.CheckedIterations[node.ID()]; checked != nil { + return b.buildStmt(checked, current, scopeID) + } } loopID := ir.NodeID(node.ID()) init := b.newBlock(BlockLoopInit, ast.LocOf(node)) diff --git a/internal/ir/hir/lower/module_lower.go b/internal/ir/hir/lower/module_lower.go index f5d0f67b..7315eb62 100644 --- a/internal/ir/hir/lower/module_lower.go +++ b/internal/ir/hir/lower/module_lower.go @@ -236,6 +236,12 @@ func appendStmt(module *project.Module, scope *symbols.Scope, out *hir.Block, st } out.Stmts = append(out.Stmts, ifStmt) case *ast.ForStmt: + if node.Iterable != nil { + if checked := module.Typechecking.CheckedIterations[node.ID()]; checked != nil { + appendStmt(module, scope, out, checked, returnType, ctx) + return + } + } out.Stmts = append(out.Stmts, lowerForStmt(ctx, module, scope, node, returnType)) case *ast.MatchStmt: evidence, found := module.Typechecking.Matches[node.ID()] @@ -319,9 +325,6 @@ 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 { - if checked := module.Typechecking.CheckedIterations[node.ID()]; checked != nil { - node = checked - } 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)}, diff --git a/internal/ir/hir/lower/module_lower_test.go b/internal/ir/hir/lower/module_lower_test.go index 3ac068a7..19d06059 100644 --- a/internal/ir/hir/lower/module_lower_test.go +++ b/internal/ir/hir/lower/module_lower_test.go @@ -71,8 +71,8 @@ fn main() { var loop *hir.For for _, fn := range out.Funcs { for _, stmt := range fn.Body.Stmts { - if candidate, ok := stmt.(*hir.For); ok { - loop = candidate + if expansion, ok := stmt.(*hir.Block); ok { + loop, _ = expansion.Stmts[len(expansion.Stmts)-1].(*hir.For) } } } diff --git a/internal/project/modules.go b/internal/project/modules.go index 6e37917f..120f6cda 100644 --- a/internal/project/modules.go +++ b/internal/project/modules.go @@ -124,7 +124,7 @@ func (m *Module) RebuildTypedASTIndex() { if sourceLoop, ok := node.(*ast.ForStmt); ok { // An outer expansion still contains original nested loops. Do not // overwrite their checked trees depending on map traversal order. - if checked := m.Typechecking.CheckedIterations[sourceLoop.ID()]; checked != nil && checked != sourceLoop { + if sourceLoop.Iterable != nil && m.Typechecking.CheckedIterations[sourceLoop.ID()] != nil { return false } } diff --git a/internal/semantics/ownership/ownership_test.go b/internal/semantics/ownership/ownership_test.go index 657f32a8..64ada0ea 100644 --- a/internal/semantics/ownership/ownership_test.go +++ b/internal/semantics/ownership/ownership_test.go @@ -103,6 +103,42 @@ func TestStructuralIterationUsesOrdinaryCallGuards(t *testing.T) { } } +func TestIteratorFactorySourceCleanup(t *testing.T) { + for _, body := range []string{"", "continue;", "break;", "return;"} { + t.Run(body, func(t *testing.T) { + result := checkOwnershipSource(t, `struct Cursor { held: *i32, value: i32 } +fn Make() -> Cursor { return Cursor.{ held = alloc(1), value = 0 }; } +fn (self: &mut Cursor) Next() -> ?i32 { return none; } +fn main() { for item in Make() { `+body+` } }`) + if result.HasErrors() { + t.Fatalf("unexpected diagnostics:\n%s", result.EmitAllToString()) + } + fn := result.module.AST.Stmts[3].(*ast.FnDecl) + sourceLoop := fn.Body.Stmts[0].(*ast.ForStmt) + expansion := result.module.Typechecking.CheckedIterations[sourceLoop.ID()] + binding := expansion.Stmts[0].(*ast.LetDecl) + owner := result.module.Bindings.NodeSymbols[binding.Name.ID()] + graph := result.module.CFG.Function(ir.NodeID(fn.ID())) + plan := cleanupPlanForFunction(t, result, fn) + exit := scopeExitSiteID(t, graph, expansion.ID()) + if got := plan.AfterScope[exit]; !slices.Equal(got, []symbols.SymbolID{owner.ID}) { + t.Fatalf("source exit cleanup = %v, want [%d]", got, owner.ID) + } + for site, drops := range plan.AfterScope { + if site != exit && slices.Contains(drops, owner.ID) { + t.Fatalf("source dropped at another scope, including possible backedge: %v", site) + } + } + if body == "return;" { + ret := sourceLoop.Body.Stmts[0].(*ast.ReturnStmt) + if got := plan.BeforeReturn[ir.NodeID(ret.ID())]; !slices.Equal(got, []symbols.SymbolID{owner.ID}) { + t.Fatalf("return cleanup = %v, want [%d]", got, owner.ID) + } + } + }) + } +} + func inspectFunctionAnalysis(t *testing.T, result *ownershipResult, name string) *analyzer { t.Helper() sym, found := result.module.ModuleScope.Lookup(name) diff --git a/internal/semantics/typechecker/check_stmt.go b/internal/semantics/typechecker/check_stmt.go index 1e2bc274..1f2b7f3b 100644 --- a/internal/semantics/typechecker/check_stmt.go +++ b/internal/semantics/typechecker/check_stmt.go @@ -682,10 +682,10 @@ func (c *checker) checkForInStmt(scope *symbols.Scope, node *ast.ForStmt, return c.loopDepth-- } -// checkStructuralIteration publishes an ordinary checked loop rather than -// adding hidden call/borrow semantics in lowering. The first slice accepts -// local concrete cursor places and scalar items; factories, complex places and -// resource-bearing payloads need the lifecycle work tracked in issue #123. +// checkStructuralIteration publishes ordinary checked statements rather than +// adding hidden call/borrow semantics in lowering. Temporary struct sources use +// normal binding cleanup; existing local cursors keep their place identity. +// Complex places and resource-bearing items remain outside this slice (#123). func (c *checker) checkStructuralIteration(scope *symbols.Scope, node *ast.ForStmt, iterableType typeinfo.Type) bool { method, found := c.lookupDeclaredCallableMember(iterableType, "Next") if !found || method.Symbol == nil { @@ -722,10 +722,15 @@ func (c *checker) checkStructuralIteration(scope *symbols.Scope, node *ast.ForSt if cursorSymbol != nil { binding, _ = cursorSymbol.ASTNode.(*ast.LetDecl) } - if !local || binding == nil || binding.IsModuleVar || !concrete { - c.ctx.Diagnostics.Add(invalidExpressionError(node.Iterable, "this iterator must be a struct value stored in a local variable"). - WithNote("iterating directly over function results, fields, or references is not supported yet"). - WithHelp("store the iterator struct in a local `let` binding before the loop; use `let mut` if `Next` changes it")) + temporary := false + switch node.Iterable.(type) { + case *ast.CallExpr, *ast.StructLit: + temporary = concrete + } + if !concrete || (!temporary && (!local || binding == nil || binding.IsModuleVar)) { + c.ctx.Diagnostics.Add(invalidExpressionError(node.Iterable, "this iterator source is not supported yet"). + WithNote("for loops currently accept a local struct variable, a function returning a struct, or a struct literal; fields and references are not supported yet"). + WithHelp("call `Next()` explicitly in a loop for this source and stop when it returns `none`")) return true } switch typeinfo.Underlying(optional.Inner).(type) { @@ -740,11 +745,24 @@ func (c *checker) checkStructuralIteration(scope *symbols.Scope, node *ast.ForSt } location := ast.LocOf(node) + expansion := &ast.BlockStmt{Location: location} + var sourceBinding *ast.LetDecl + var receiver ast.Expr = node.Iterable + if temporary { + // A non-source identifier avoids collisions while keeping ordinary + // ownership diagnostics readable when they name this binding. + sourceBinding = &ast.LetDecl{ + Name: &ast.Ident{Name: "iterator source", Location: ast.LocOf(node.Iterable)}, + IsMutable: true, Value: node.Iterable, Location: ast.LocOf(node.Iterable), + } + expansion.Stmts = append(expansion.Stmts, sourceBinding) + receiver = &ast.Ident{Name: sourceBinding.Name.Name, Location: ast.LocOf(node.Iterable)} + } resultName := fmt.Sprintf("$for.result.%d", node.ID()) result := &ast.LetDecl{ Name: &ast.Ident{Name: resultName, Location: location}, Value: &ast.CallExpr{Callee: &ast.SelectorExpr{ - Expr: node.Iterable, Name: &ast.Ident{Name: "Next", Location: location}, Location: location, + Expr: receiver, Name: &ast.Ident{Name: "Next", Location: location}, Location: location, }, Location: location}, Location: location, } stop := &ast.IfStmt{ @@ -762,7 +780,8 @@ func (c *checker) checkStructuralIteration(scope *symbols.Scope, node *ast.ForSt NodeIDHolder: node.NodeIDHolder, Body: &ast.BlockStmt{Stmts: []ast.Stmt{result, stop, &body}, Location: location}, Location: location, } - ast.Inspect(checked, func(generated ast.Node) bool { + expansion.Stmts = append(expansion.Stmts, checked) + ast.Inspect(expansion, func(generated ast.Node) bool { if generated == nil { return false } @@ -772,7 +791,18 @@ func (c *checker) checkStructuralIteration(scope *symbols.Scope, node *ast.ForSt return true }) bodyScope := c.module.Bindings.BlockScopes[node.Body.ID()] - iterationScope := bodyScope.InsertParent(scope) + sourceScope := symbols.NewScope(scope) + c.module.Bindings.BlockScopes[expansion.ID()] = sourceScope + iterationScope := bodyScope.InsertParent(sourceScope) + if sourceBinding != nil { + sourceSymbol := symbols.New(sourceBinding.Name.Name, symbols.SymbolVar, sourceBinding, ast.LocOf(node.Iterable)) + sourceSymbol.Used = true + if err := sourceScope.Declare(sourceSymbol); err != nil { + panic(err) + } + c.module.Bindings.NodeSymbols[sourceBinding.Name.ID()] = sourceSymbol + c.module.Bindings.NodeSymbols[receiver.ID()] = sourceSymbol + } c.module.Bindings.BlockScopes[checked.Body.ID()] = iterationScope c.module.Bindings.BlockScopes[stop.Then.ID()] = symbols.NewScope(iterationScope) resultSymbol := symbols.New(resultName, symbols.SymbolVar, result, location) @@ -785,7 +815,7 @@ func (c *checker) checkStructuralIteration(scope *symbols.Scope, node *ast.ForSt c.module.Bindings.NodeSymbols[item.Value.ID()] = resultSymbol itemSymbol := c.module.Bindings.NodeSymbols[node.Value.ID()] itemSymbol.ASTNode = item - c.module.Typechecking.CheckedIterations[node.ID()] = checked + c.module.Typechecking.CheckedIterations[node.ID()] = expansion return true } diff --git a/internal/semantics/typechecker/flow_test.go b/internal/semantics/typechecker/flow_test.go index 74067f3f..41dbae16 100644 --- a/internal/semantics/typechecker/flow_test.go +++ b/internal/semantics/typechecker/flow_test.go @@ -68,7 +68,11 @@ fn main() { if err := module.CFG.Validate(); err != nil { t.Fatal(err) } - for id, loop := range module.Typechecking.CheckedIterations { + for id, expansion := range module.Typechecking.CheckedIterations { + loop := expansion.Stmts[len(expansion.Stmts)-1].(*ast.ForStmt) + if module.TypedASTNodes[expansion.ID()] != expansion { + t.Fatal("source scope not indexed") + } if module.TypedASTNodes[id] != loop || loop.Iterable != nil || loop.Cond != nil { t.Fatalf("checked loop not indexed: %#v", loop) } @@ -86,7 +90,7 @@ fn main() { if got := typeinfo.TypeText(module.Bindings.NodeSymbols[item.Name.ID()].Type); got != "i32" { t.Fatalf("item type = %s", got) } - ast.Inspect(loop, func(node ast.Node) bool { + ast.Inspect(expansion, func(node ast.Node) bool { if node != nil && module.TypedASTNodes[node.ID()] == nil { t.Errorf("generated node %T/%d not indexed", node, node.ID()) } diff --git a/internal/semantics/typechecker/for_in_test.go b/internal/semantics/typechecker/for_in_test.go index e855558f..64de3ce6 100644 --- a/internal/semantics/typechecker/for_in_test.go +++ b/internal/semantics/typechecker/for_in_test.go @@ -22,7 +22,9 @@ func TestStructuralIterationRecognition(t *testing.T) { {name: "extra default", method: "fn (self: &Cursor) Next(value: i32 = 0) -> ?i32 { return value; }", diagnostic: "cannot take arguments in a for loop", hint: "parameters with defaults are not supported either"}, {name: "index", method: "fn (self: &Cursor) Next() -> ?i32 { return none; }", header: "index, item in cursor", diagnostic: "provide an item, not an index", hint: "maintain a separate counter"}, {name: "immutable", method: "fn (self: &mut Cursor) Next() -> ?i32 { return none; }", binding: "let cursor = Cursor.{};", diagnostic: "mutable"}, - {name: "temporary deferred", method: "fn (self: &mut Cursor) Next() -> ?i32 { return none; }", header: "item in Cursor.{}", diagnostic: "stored in a local variable", hint: "use `let mut` if `Next` changes it"}, + {name: "temporary literal", method: "fn (self: &mut Cursor) Next() -> ?i32 { return none; }", header: "item in Cursor.{}"}, + {name: "temporary factory", method: "fn (self: &mut Cursor) Next() -> ?i32 { return none; } fn Make() -> Cursor { return Cursor.{}; }", header: "item in Make()"}, + {name: "reference deferred", method: "fn (self: &mut Cursor) Next() -> ?i32 { return none; }", binding: "let mut original = Cursor.{}; let cursor = &mut original;", diagnostic: "source is not supported yet", hint: "call `Next()` explicitly"}, {name: "nested optional deferred", method: "fn (self: &Cursor) Next() -> ? ?i32 { return none; }", diagnostic: "are not supported yet", hint: "call `Next()` explicitly in a loop"}, } { t.Run(test.name, func(t *testing.T) { diff --git a/internal/semantics/typecheckresult/result.go b/internal/semantics/typecheckresult/result.go index 8ee8f9c8..09c37f92 100644 --- a/internal/semantics/typecheckresult/result.go +++ b/internal/semantics/typecheckresult/result.go @@ -150,7 +150,9 @@ type Result struct { // statements before CFG/flow/effects/ownership. These loops have no numeric // cursor and never appear in the builtin ForIterations table. Source syntax // stays unchanged; CFG, typed-node indexing and HIR consume this same tree. - CheckedIterations map[ast.NodeID]*ast.ForStmt + // Each block owns any temporary source binding and ends with the checked + // loop, which retains the source loop's ID. The block has its own scope ID. + CheckedIterations map[ast.NodeID]*ast.BlockStmt ExprTypes map[ast.NodeID]typeinfo.Type // ValueUses classifies every ownership-relevant value use, keyed by the // used expression's node ID. Reference parameters publish UseRead; the @@ -179,7 +181,7 @@ func New() *Result { CaseTests: make(map[ast.NodeID]CaseTest), Matches: make(map[ast.NodeID]Match), ForIterations: make(map[ast.NodeID]ForIteration), - CheckedIterations: make(map[ast.NodeID]*ast.ForStmt), + CheckedIterations: make(map[ast.NodeID]*ast.BlockStmt), ExprTypes: make(map[ast.NodeID]typeinfo.Type), ValueUses: make(map[ast.NodeID]typeinfo.UseKind), ReferenceArguments: make(map[ast.NodeID]bool), diff --git a/x_test/negative_structural_iterator/peeper.toml b/x_test/negative_structural_iterator/peeper.toml index 6be9c24c..86b23ac5 100644 --- a/x_test/negative_structural_iterator/peeper.toml +++ b/x_test/negative_structural_iterator/peeper.toml @@ -4,5 +4,5 @@ build = "program" [test] mode = "check" outcome = "failure" -stderr_contains = ["must return an optional item", "cannot take arguments in a for loop", "provide an item, not an index", "mutable", "stored in a local variable", "iterator items of type Cursor are not supported yet", "cannot iterate over", "cannot be used as bool", "return type is i32", "parameters with defaults are not supported either", "maintain a separate counter", "store the iterator struct in a local", "call `Next()` explicitly in a loop", "iterate over the original struct value before passing it as an interface"] +stderr_contains = ["must return an optional item", "cannot take arguments in a for loop", "provide an item, not an index", "mutable", "this iterator source is not supported yet", "iterator items of type Cursor are not supported yet", "cannot iterate over", "cannot be used as bool", "return type is i32", "parameters with defaults are not supported either", "maintain a separate counter", "call `Next()` explicitly in a loop for this source", "call `Next()` explicitly in a loop", "iterate over the original struct value before passing it as an interface"] stderr_excludes = ["structural iteration", "local concrete cursor", "scalar items", "$for."] diff --git a/x_test/negative_structural_iterator/src/main.peep b/x_test/negative_structural_iterator/src/main.peep index b6507c2c..b5552c93 100644 --- a/x_test/negative_structural_iterator/src/main.peep +++ b/x_test/negative_structural_iterator/src/main.peep @@ -13,7 +13,7 @@ fn (self: &Aggregate) Next() -> ?Cursor { return none; } iface Erased { fn (&mut Self) Next() -> ?i32 } fn RejectErased(cursor: &mut Erased) { for item in cursor {} } -fn Factory() -> Cursor { return Cursor.{}; } +struct Outer { cursor: Cursor, count: i32 } fn main() { let wrong = WrongReturn.{}; @@ -24,7 +24,8 @@ fn main() { for index, item in indexed {} let immutable = Cursor.{}; for item in immutable {} - for item in Factory() {} + let mut outer = Outer.{ cursor = Cursor.{}, count = 0 }; + for item in outer.cursor {} let aggregate = Aggregate.{}; for item in aggregate {} } diff --git a/x_test/negative_structural_iterator_move/src/main.peep b/x_test/negative_structural_iterator_move/src/main.peep index 78fd80e3..a1e98d3c 100644 --- a/x_test/negative_structural_iterator_move/src/main.peep +++ b/x_test/negative_structural_iterator_move/src/main.peep @@ -5,7 +5,10 @@ struct Cursor { value: i32, limit: i32 } fn (self: &mut Cursor) Next() -> ?i32 { return none; } fn Consume(cursor: Cursor) {} +fn Make() -> Consumed { return Consumed.{ value = 0, limit = 2 }; } + fn main() { + for item in Make() {} let consumed = Consumed.{ value = 0, limit = 2 }; for item in consumed {} let mut moved = Cursor.{ value = 0, limit = 2 }; diff --git a/x_test/runtime_iterator_factory/peeper.toml b/x_test/runtime_iterator_factory/peeper.toml new file mode 100644 index 00000000..4ad9d65f --- /dev/null +++ b/x_test/runtime_iterator_factory/peeper.toml @@ -0,0 +1,6 @@ +name = "runtime_iterator_factory" +build = "program" + +[test] +mode = "run" +outcome = "success" diff --git a/x_test/runtime_iterator_factory/src/main.peep b/x_test/runtime_iterator_factory/src/main.peep new file mode 100644 index 00000000..505dbb61 --- /dev/null +++ b/x_test/runtime_iterator_factory/src/main.peep @@ -0,0 +1,73 @@ +struct Cursor { + value: i32, + limit: i32, + held: *i32 +} + +fn (self: &mut Cursor) Next() -> ?i32 { + if self.value >= self.limit { return none; } + let value = self.value; + self.value = self.value + 1; + return value; +} + +struct Counts { made: i32, requested: i32 } + +fn Make(calls: &mut Counts, limit: i32) -> Cursor { + calls.made = calls.made + 1; + calls.requested = calls.requested + limit; + return Cursor.{ value = 0, limit = limit, held = alloc(7) }; +} + +fn First(calls: &mut Counts) -> i32 { + for item in Make(calls, 3) { return item + 10; } + return -1; +} + +fn NestedReturn(calls: &mut Counts) -> i32 { + for left in Make(calls, 2) { + for right in Make(calls, 2) { return left + right; } + } + return -1; +} + +fn main() -> i32 { + let mut calls = Counts.{ made = 0, requested = 0 }; + let mut total: i32 = 0; + for item in Make(&mut calls, 4) { + if calls.made != 1 { return 1; } + if item == 1 { continue; } + total = total + item; + } + if total != 5 || calls.made != 1 { return 2; } + + for item in Make(&mut calls, 0) { return 3; } + if calls.made != 2 { return 4; } + + for item in Make(&mut calls, 4) { break; } + if calls.made != 3 { return 5; } + if First(&mut calls) != 10 || calls.made != 4 { return 6; } + + let mut pairs: i32 = 0; + for left in Make(&mut calls, 2) { + for right in Make(&mut calls, 3) { + if right == 1 { continue; } + pairs = pairs + left + right; + } + } + if pairs != 6 || calls.made != 7 { return 7; } + if NestedReturn(&mut calls) != 0 || calls.made != 9 { return 8; } + + let mut literalTotal: i32 = 0; + for item in Cursor.{ value = 1, limit = 3, held = alloc(8) } { + literalTotal = literalTotal + item; + } + if literalTotal != 3 { return 9; } + + let mut local = Make(&mut calls, 2); + for item in local { break; } + if local.value != 1 { return 10; } + for item in local { total = total + item; } + if local.value != 2 || total != 6 || calls.made != 10 || calls.requested != 25 { return 11; } + return 0; +} From 6c4e39d990da365930993b55a78fd7d590802e3e Mon Sep 17 00:00:00 2001 From: itsfuad Date: Tue, 8 Sep 2026 18:21:59 +0600 Subject: [PATCH 3/5] Generalize structural iterator sources and items Preserve existing place identity, capture dynamic indexed places once, and materialize produced sources through ordinary bindings. Carry exact one-layer optional payload evidence so aggregate, owned, nested-optional, and reference items use canonical flow and ownership machinery. --- docs/language-spec.md | 29 +++++ internal/lsp/server_test.go | 18 +++ internal/semantics/typechecker/check_stmt.go | 73 ++++++------ internal/semantics/typechecker/flow.go | 11 +- internal/semantics/typechecker/for_in_test.go | 97 +++++++++++++++- internal/semantics/typecheckresult/result.go | 7 +- .../negative_structural_iterator/peeper.toml | 2 +- .../src/main.peep | 8 +- x_test/runtime_iterator_general/peeper.toml | 6 + x_test/runtime_iterator_general/src/main.peep | 107 ++++++++++++++++++ 10 files changed, 304 insertions(+), 54 deletions(-) create mode 100644 x_test/runtime_iterator_general/peeper.toml create mode 100644 x_test/runtime_iterator_general/src/main.peep diff --git a/docs/language-spec.md b/docs/language-spec.md index e7adc638..bfa09b2d 100644 --- a/docs/language-spec.md +++ b/docs/language-spec.md @@ -91,6 +91,35 @@ must be ordered, within the byte length, and on UTF-8 codepoint boundaries. Invalid bounds or boundaries trap at runtime. The owner remains responsible for backing storage and is dropped exactly once. +## Iteration + +`for item in source` supports built-in ranges and sequences, plus values with an +accessible `Next()` method. Custom iteration is statically dispatched and does +not require an interface or generic constraint: + +```peep +fn (self: &mut Counter) Next() -> ?i32 { + if self.done { return none; } + // Update iterator state and return next item. +} + +for item in counter { + println(item); +} +``` + +`Next` declares exactly its receiver and returns `?Item`. A present result binds +one `Item`; `none` ends loop. If `Item` is itself optional, only outer result +layer is removed. Custom loops provide one item binding and do not synthesize an +index. + +Source expression is evaluated once. Existing places retain identity; produced +values live for loop scope. Each attempted iteration calls `Next` once. +`continue` starts next attempt, while `break` and `return` do not make another +call. Calls, optional extraction, moves, borrows, reference provenance, and +cleanup follow same rules as equivalent ordinary statements. Interface values +are not custom iterator sources; method target must be statically known. + ## Generic Named Types Structs, enums, interfaces, and transparent type aliases may declare type diff --git a/internal/lsp/server_test.go b/internal/lsp/server_test.go index 1bace6c5..070f835f 100644 --- a/internal/lsp/server_test.go +++ b/internal/lsp/server_test.go @@ -493,6 +493,24 @@ fn inspect(value: result::Alias) { } } +func TestHoverShowsStructuralIteratorItemType(t *testing.T) { + root := t.TempDir() + filePath := filepath.Join(root, "main"+peeper.SourceExt) + src := `struct Item { value: i32 } +struct Cursor {} +fn (self: &Cursor) Next() -> ?Item { return none; } +fn main() { + let cursor = Cursor.{}; + for item in cursor { return __CURSOR__item.value; } +}` + state := NewServerState() + state.RootDir = root + hover := hoverAtSource(t, state, filePath, src) + if hover == nil || !strings.Contains(hover.Contents.Value, "(var) item: Item") { + t.Fatalf("iterator item hover = %#v, want Item", hover) + } +} + func TestHoverShowsExactCaseFieldType(t *testing.T) { root := t.TempDir() filePath := filepath.Join(root, "main"+peeper.SourceExt) diff --git a/internal/semantics/typechecker/check_stmt.go b/internal/semantics/typechecker/check_stmt.go index 1f2b7f3b..bf6da28e 100644 --- a/internal/semantics/typechecker/check_stmt.go +++ b/internal/semantics/typechecker/check_stmt.go @@ -632,7 +632,7 @@ func (c *checker) checkForInStmt(scope *symbols.Scope, node *ast.ForStmt, return d.WithNote("for loops do not support interface values, even when the interface declares `Next`"). WithHelp("iterate over the original struct value before passing it as an interface") } else { - d.WithHelp("use a range, array, slice, or a struct value with a `Next()` method available here"). + d.WithHelp("use a range, array, slice, or a value with a `Next()` method available here"). WithNote("`Next()` must return an optional item, such as `?i32`; method names are case-sensitive") } c.ctx.Diagnostics.Add(d) @@ -683,9 +683,8 @@ func (c *checker) checkForInStmt(scope *symbols.Scope, node *ast.ForStmt, return } // checkStructuralIteration publishes ordinary checked statements rather than -// adding hidden call/borrow semantics in lowering. Temporary struct sources use -// normal binding cleanup; existing local cursors keep their place identity. -// Complex places and resource-bearing items remain outside this slice (#123). +// adding hidden call/borrow semantics in lowering. Existing places retain their +// storage identity; produced values use normal binding cleanup. func (c *checker) checkStructuralIteration(scope *symbols.Scope, node *ast.ForStmt, iterableType typeinfo.Type) bool { method, found := c.lookupDeclaredCallableMember(iterableType, "Next") if !found || method.Symbol == nil { @@ -712,48 +711,45 @@ func (c *checker) checkStructuralIteration(scope *symbols.Scope, node *ast.ForSt WithHelp("return an optional type, such as `?i32`: return an item to continue, or `none` to end the loop")) return true } - cursor, local := node.Iterable.(*ast.Ident) - var cursorSymbol *symbols.Symbol - if local { - cursorSymbol = c.module.Bindings.NodeSymbols[cursor.ID()] - } - _, concrete := typeinfo.Underlying(iterableType).(*typeinfo.StructType) - var binding *ast.LetDecl - if cursorSymbol != nil { - binding, _ = cursorSymbol.ASTNode.(*ast.LetDecl) - } - temporary := false - switch node.Iterable.(type) { - case *ast.CallExpr, *ast.StructLit: - temporary = concrete - } - if !concrete || (!temporary && (!local || binding == nil || binding.IsModuleVar)) { - c.ctx.Diagnostics.Add(invalidExpressionError(node.Iterable, "this iterator source is not supported yet"). - WithNote("for loops currently accept a local struct variable, a function returning a struct, or a struct literal; fields and references are not supported yet"). - WithHelp("call `Next()` explicitly in a loop for this source and stop when it returns `none`")) - return true - } - switch typeinfo.Underlying(optional.Inner).(type) { - case *typeinfo.IntegerType, *typeinfo.FloatType, *typeinfo.BoolType, *typeinfo.ByteType, *typeinfo.CharType: - default: - c.ctx.Diagnostics.Add(invalidExpressionError(node.Iterable, - "iterator items of type "+typeinfo.TypeText(optional.Inner)+" are not supported yet"). - WithSecondaryLabel(ast.LocOf(decl.ReturnType), "the item type comes from this optional return type"). - WithNote("for loops currently support iterator items of integer, floating-point, bool, byte, and char types"). - WithHelp("call `Next()` explicitly in a loop and stop when it returns `none`")) - return true + placeSource := place.IsPlaceExpr(node.Iterable) + capturePlace := false + for current := node.Iterable; placeSource; { + projection, projected := place.Project(current) + if !projected { + break + } + if projection.Index != nil { + indexType := c.module.BaseExprType(projection.Index.ID()) + if _, constant := consteval.EvaluateExpr(c.ctx, c.module, scope, projection.Index, indexType); !constant { + capturePlace = true + break + } + } + current = projection.Base } location := ast.LocOf(node) expansion := &ast.BlockStmt{Location: location} var sourceBinding *ast.LetDecl var receiver ast.Expr = node.Iterable - if temporary { + if !placeSource || capturePlace { + sourceValue := node.Iterable + mutableSource := true + if capturePlace { + // Dynamic indexes must select storage once. Capturing its reference keeps + // place identity without moving or copying existing cursor storage. + mode := ast.AddressShared + if _, mutable, reference := typeinfo.ReferenceTarget(typeinfo.Underlying(fnType.Params[0])); reference && mutable { + mode = ast.AddressMutable + } + sourceValue = &ast.AddressExpr{Mode: mode, Expr: node.Iterable, Location: ast.LocOf(node.Iterable)} + mutableSource = false + } // A non-source identifier avoids collisions while keeping ordinary // ownership diagnostics readable when they name this binding. sourceBinding = &ast.LetDecl{ Name: &ast.Ident{Name: "iterator source", Location: ast.LocOf(node.Iterable)}, - IsMutable: true, Value: node.Iterable, Location: ast.LocOf(node.Iterable), + IsMutable: mutableSource, Value: sourceValue, Location: ast.LocOf(node.Iterable), } expansion.Stmts = append(expansion.Stmts, sourceBinding) receiver = &ast.Ident{Name: sourceBinding.Name.Name, Location: ast.LocOf(node.Iterable)} @@ -770,9 +766,7 @@ func (c *checker) checkStructuralIteration(scope *symbols.Scope, node *ast.ForSt Then: &ast.BlockStmt{Stmts: []ast.Stmt{&ast.BreakStmt{Location: location}}, Location: location}, Location: location, } item := &ast.LetDecl{ - Name: node.Value, - Type: &ast.NamedType{Name: typeinfo.TypeText(optional.Inner), Location: location}, - Value: &ast.Ident{Name: resultName, Location: location}, Location: location, + Name: node.Value, Value: &ast.Ident{Name: resultName, Location: location}, Location: location, } body := *node.Body body.Stmts = append([]ast.Stmt{item}, node.Body.Stmts...) @@ -813,6 +807,7 @@ func (c *checker) checkStructuralIteration(scope *symbols.Scope, node *ast.ForSt c.module.Bindings.NodeSymbols[result.Name.ID()] = resultSymbol c.module.Bindings.NodeSymbols[stop.Cond.(*ast.BinaryExpr).Left.ID()] = resultSymbol c.module.Bindings.NodeSymbols[item.Value.ID()] = resultSymbol + c.module.Typechecking.PayloadDepths[item.Value.ID()] = 1 itemSymbol := c.module.Bindings.NodeSymbols[node.Value.ID()] itemSymbol.ASTNode = item c.module.Typechecking.CheckedIterations[node.ID()] = expansion diff --git a/internal/semantics/typechecker/flow.go b/internal/semantics/typechecker/flow.go index 961947f3..8bc281a3 100644 --- a/internal/semantics/typechecker/flow.go +++ b/internal/semantics/typechecker/flow.go @@ -144,12 +144,15 @@ func (c *checker) effectiveExpressionType(scope *symbols.Scope, expr ast.Expr, b return base } _, explicitCarrier := typeinfo.Underlying(expected).(*typeinfo.OptionalType) - required := payloadDepthForExpected(base, expected) - if c.payloadContext > 0 && required == 0 && !explicitCarrier { + required, exactPayloadDepth := c.module.Typechecking.PayloadDepths[expr.ID()] + if !exactPayloadDepth { + required = payloadDepthForExpected(base, expected) + } + if c.payloadContext > 0 && required == 0 && !explicitCarrier && !exactPayloadDepth { required = optionalLayerCount(base) } if c.flow == nil { - if c.optionalTestContext > 0 || explicitCarrier || required == 0 { + if c.optionalTestContext > 0 || (explicitCarrier && !exactPayloadDepth) || required == 0 { return base } return unwrapOptionalLayers(base, required) @@ -159,7 +162,7 @@ func (c *checker) effectiveExpressionType(scope *symbols.Scope, expr ast.Expr, b resolved := unwrapOptionalLayers(base, len(payloadCases)) applied := optionalLayerCount(base) - optionalLayerCount(resolved) payloadCases = payloadCases[:applied] - if c.optionalTestContext == 0 && explicitCarrier { + if c.optionalTestContext == 0 && explicitCarrier && !exactPayloadDepth { c.recordFlowResolution(expr, resolution) return base } diff --git a/internal/semantics/typechecker/for_in_test.go b/internal/semantics/typechecker/for_in_test.go index 64de3ce6..04d28fb0 100644 --- a/internal/semantics/typechecker/for_in_test.go +++ b/internal/semantics/typechecker/for_in_test.go @@ -24,8 +24,8 @@ func TestStructuralIterationRecognition(t *testing.T) { {name: "immutable", method: "fn (self: &mut Cursor) Next() -> ?i32 { return none; }", binding: "let cursor = Cursor.{};", diagnostic: "mutable"}, {name: "temporary literal", method: "fn (self: &mut Cursor) Next() -> ?i32 { return none; }", header: "item in Cursor.{}"}, {name: "temporary factory", method: "fn (self: &mut Cursor) Next() -> ?i32 { return none; } fn Make() -> Cursor { return Cursor.{}; }", header: "item in Make()"}, - {name: "reference deferred", method: "fn (self: &mut Cursor) Next() -> ?i32 { return none; }", binding: "let mut original = Cursor.{}; let cursor = &mut original;", diagnostic: "source is not supported yet", hint: "call `Next()` explicitly"}, - {name: "nested optional deferred", method: "fn (self: &Cursor) Next() -> ? ?i32 { return none; }", diagnostic: "are not supported yet", hint: "call `Next()` explicitly in a loop"}, + {name: "reference source", method: "fn (self: &mut Cursor) Next() -> ?i32 { return none; }", binding: "let mut original = Cursor.{}; let cursor = &mut original;"}, + {name: "nested optional item", method: "fn (self: &Cursor) Next() -> ? ?i32 { return none; }"}, } { t.Run(test.name, func(t *testing.T) { binding, header := test.binding, test.header @@ -50,6 +50,99 @@ func TestStructuralIterationRecognition(t *testing.T) { } } +func TestStructuralIterationMatchesExplicitOperations(t *testing.T) { + for _, test := range []struct { + name, source, implicit, explicit string + }{ + { + name: "field source", + source: `struct Cursor {} +fn (self: &mut Cursor) Next() -> ?i32 { return none; } +struct Holder { cursor: Cursor } +fn main() { + let mut holder = Holder.{ cursor = Cursor.{} }; + __LOOP__ +}`, + implicit: "for item in holder.cursor { let value: i32 = item; }", + explicit: "for { let result = holder.cursor.Next(); if result == none { break; } let item: i32 = result; let value: i32 = item; }", + }, + { + name: "dynamic indexed source", + source: `struct Cursor {} +fn (self: &mut Cursor) Next() -> ?i32 { return none; } +fn main() { + let mut cursors = [2]Cursor{Cursor.{}, Cursor.{}}; + let index: i32 = 0; + __LOOP__ +}`, + implicit: "for item in cursors[index] { let value: i32 = item; }", + explicit: "let source = &mut cursors[index]; for { let result = source.Next(); if result == none { break; } let item: i32 = result; let value: i32 = item; }", + }, + { + name: "reference parameter source", + source: `struct Cursor {} +fn (self: &mut Cursor) Next() -> ?i32 { return none; } +fn Walk(cursor: &mut Cursor) { __LOOP__ } +fn main() { let mut cursor = Cursor.{}; Walk(&mut cursor); }`, + implicit: "for item in cursor { let value: i32 = item; }", + explicit: "for { let result = cursor.Next(); if result == none { break; } let item: i32 = result; let value: i32 = item; }", + }, + { + name: "aggregate item", + source: `struct Item { value: i32 } +struct Cursor {} +fn (self: &Cursor) Next() -> ?Item { return none; } +fn main() { let cursor = Cursor.{}; __LOOP__ }`, + implicit: "for item in cursor { let value: i32 = item.value; }", + explicit: "for { let result = cursor.Next(); if result == none { break; } let item: Item = result; let value: i32 = item.value; }", + }, + { + name: "owned item", + source: `struct Cursor {} +fn (self: &Cursor) Next() -> ?*i32 { return none; } +fn main() { let cursor = Cursor.{}; __LOOP__ }`, + implicit: "for item in cursor { free(item); }", + explicit: "for { let result = cursor.Next(); if result == none { break; } let item: *i32 = result; free(item); }", + }, + { + name: "nested optional item", + source: `struct Cursor {} +fn (self: &Cursor) Next() -> ? ?i32 { return none; } +fn main() { let cursor = Cursor.{}; __LOOP__ }`, + implicit: "for item in cursor { if item != none { let value: i32 = item; } }", + explicit: "for { let result = cursor.Next(); if result == none { break; } if result != none { let value: i32 = result; } }", + }, + { + name: "reference item", + source: `struct Cursor { value: i32 } +fn (self: &mut Cursor) Next() -> ?&i32 from self { return none; } +fn main() { let mut cursor = Cursor.{ value = 1 }; __LOOP__ }`, + implicit: "for item in cursor { let value: &i32 = item; }", + explicit: "for { let result = cursor.Next(); if result == none { break; } let item: &i32 = result; let value: &i32 = item; }", + }, + } { + t.Run(test.name, func(t *testing.T) { + for _, implicit := range []bool{false, true} { + loop := test.explicit + if implicit { + loop = test.implicit + } + module, diag := checkTypeModule(t, strings.Replace(test.source, "__LOOP__", loop, 1)) + if diag.HasErrors() { + t.Fatalf("implicit=%v unexpected diagnostics:\n%s", implicit, diag.EmitAllToString()) + } + expectedExpansions := 0 + if implicit { + expectedExpansions = 1 + } + if got := len(module.Typechecking.CheckedIterations); got != expectedExpansions { + t.Fatalf("implicit=%v checked expansions = %d", implicit, got) + } + } + }) + } +} + func TestRejectedStructuralIterationStillChecksBody(t *testing.T) { _, diag := checkTypeModule(t, `struct Cursor {} fn (self: &Cursor) Next() -> i32 { return 0; } diff --git a/internal/semantics/typecheckresult/result.go b/internal/semantics/typecheckresult/result.go index 09c37f92..751d8f56 100644 --- a/internal/semantics/typecheckresult/result.go +++ b/internal/semantics/typecheckresult/result.go @@ -153,7 +153,11 @@ type Result struct { // Each block owns any temporary source binding and ends with the checked // loop, which retains the source loop's ID. The block has its own scope ID. CheckedIterations map[ast.NodeID]*ast.BlockStmt - ExprTypes map[ast.NodeID]typeinfo.Type + // PayloadDepths requests an exact number of proven variant payload projections + // for an expression. Most source expressions derive this from expected types; + // generated checked operations use it when the expected type is itself optional. + PayloadDepths map[ast.NodeID]int + ExprTypes map[ast.NodeID]typeinfo.Type // ValueUses classifies every ownership-relevant value use, keyed by the // used expression's node ID. Reference parameters publish UseRead; the // borrow machinery in ownership still governs them. @@ -182,6 +186,7 @@ func New() *Result { Matches: make(map[ast.NodeID]Match), ForIterations: make(map[ast.NodeID]ForIteration), CheckedIterations: make(map[ast.NodeID]*ast.BlockStmt), + PayloadDepths: make(map[ast.NodeID]int), ExprTypes: make(map[ast.NodeID]typeinfo.Type), ValueUses: make(map[ast.NodeID]typeinfo.UseKind), ReferenceArguments: make(map[ast.NodeID]bool), diff --git a/x_test/negative_structural_iterator/peeper.toml b/x_test/negative_structural_iterator/peeper.toml index 86b23ac5..2b1695e1 100644 --- a/x_test/negative_structural_iterator/peeper.toml +++ b/x_test/negative_structural_iterator/peeper.toml @@ -4,5 +4,5 @@ build = "program" [test] mode = "check" outcome = "failure" -stderr_contains = ["must return an optional item", "cannot take arguments in a for loop", "provide an item, not an index", "mutable", "this iterator source is not supported yet", "iterator items of type Cursor are not supported yet", "cannot iterate over", "cannot be used as bool", "return type is i32", "parameters with defaults are not supported either", "maintain a separate counter", "call `Next()` explicitly in a loop for this source", "call `Next()` explicitly in a loop", "iterate over the original struct value before passing it as an interface"] +stderr_contains = ["must return an optional item", "cannot take arguments in a for loop", "provide an item, not an index", "mutable", "cannot iterate over", "cannot be used as bool", "return type is i32", "parameters with defaults are not supported either", "maintain a separate counter", "iterate over the original struct value before passing it as an interface"] stderr_excludes = ["structural iteration", "local concrete cursor", "scalar items", "$for."] diff --git a/x_test/negative_structural_iterator/src/main.peep b/x_test/negative_structural_iterator/src/main.peep index b5552c93..35e27576 100644 --- a/x_test/negative_structural_iterator/src/main.peep +++ b/x_test/negative_structural_iterator/src/main.peep @@ -7,13 +7,10 @@ fn (self: &Extra) Next(value: i32 = 1) -> ?i32 { return value; } struct Cursor {} fn (self: &mut Cursor) Next() -> ?i32 { return none; } -struct Aggregate {} -fn (self: &Aggregate) Next() -> ?Cursor { return none; } iface Erased { fn (&mut Self) Next() -> ?i32 } fn RejectErased(cursor: &mut Erased) { for item in cursor {} } -struct Outer { cursor: Cursor, count: i32 } fn main() { let wrong = WrongReturn.{}; @@ -24,8 +21,5 @@ fn main() { for index, item in indexed {} let immutable = Cursor.{}; for item in immutable {} - let mut outer = Outer.{ cursor = Cursor.{}, count = 0 }; - for item in outer.cursor {} - let aggregate = Aggregate.{}; - for item in aggregate {} + } diff --git a/x_test/runtime_iterator_general/peeper.toml b/x_test/runtime_iterator_general/peeper.toml new file mode 100644 index 00000000..3a8deb79 --- /dev/null +++ b/x_test/runtime_iterator_general/peeper.toml @@ -0,0 +1,6 @@ +name = "runtime_iterator_general" +build = "program" + +[test] +mode = "run" +outcome = "success" diff --git a/x_test/runtime_iterator_general/src/main.peep b/x_test/runtime_iterator_general/src/main.peep new file mode 100644 index 00000000..bea0fea9 --- /dev/null +++ b/x_test/runtime_iterator_general/src/main.peep @@ -0,0 +1,107 @@ +struct Cursor { value: i32, limit: i32 } +fn (self: &mut Cursor) Next() -> ?i32 { + if self.value >= self.limit { return none; } + let value = self.value; + self.value = self.value + 1; + return value; +} + +struct Holder { cursor: Cursor, marker: i32 } + +fn Sum(cursor: &mut Cursor) -> i32 { + let mut total: i32 = 0; + for item in cursor { total = total + item; } + return total; +} + +struct Pair { left: i32, right: i32 } +struct PairCursor { value: i32, limit: i32 } +fn (self: &mut PairCursor) Next() -> ?Pair { + if self.value >= self.limit { return none; } + let value = self.value; + self.value = self.value + 1; + return Pair.{ left = value, right = value + 10 }; +} + +struct OwnerCursor { value: i32, limit: i32 } +fn (self: &mut OwnerCursor) Next() -> ?*i32 { + if self.value >= self.limit { return none; } + let value = self.value; + self.value = self.value + 1; + return alloc(value); +} + +struct MaybeCursor { state: i32 } +fn (self: &mut MaybeCursor) Next() -> ? ?i32 { + if self.state == 0 { + self.state = 1; + let absent: ?i32 = none; + return absent; + } + if self.state == 1 { + self.state = 2; + let present: ?i32 = 7; + return present; + } + return none; +} + +struct RefCursor { value: i32, done: bool } +fn (self: &mut RefCursor) Next() -> ?&i32 from self { + if self.done { return none; } + self.done = true; + return &self.value; +} + +fn main() -> i32 { + let mut holder = Holder.{ cursor = Cursor.{ value = 0, limit = 3 }, marker = 9 }; + let mut fieldTotal: i32 = 0; + for item in holder.cursor { fieldTotal = fieldTotal + item; } + if fieldTotal != 3 || holder.cursor.value != 3 || holder.marker != 9 { return 1; } + + let mut cursors = [2]Cursor{ + Cursor.{ value = 0, limit = 2 }, + Cursor.{ value = 10, limit = 12 } + }; + let mut selected: i32 = 0; + let mut index: i32 = 0; + for item in cursors[index] { + selected = selected + item; + index = 1; + } + if selected != 1 || cursors[0].value != 2 || cursors[1].value != 10 { return 2; } + + let mut parameter = Cursor.{ value = 2, limit = 5 }; + if Sum(&mut parameter) != 9 || parameter.value != 5 { return 3; } + + let mut pairs = PairCursor.{ value = 0, limit = 2 }; + let mut pairTotal: i32 = 0; + for pair in pairs { pairTotal = pairTotal + pair.left + pair.right; } + if pairTotal != 22 { return 4; } + + let mut owners = OwnerCursor.{ value = 0, limit = 3 }; + let mut ownerCount: i32 = 0; + for owned in owners { + ownerCount = ownerCount + 1; + free(owned); + } + if ownerCount != 3 { return 5; } + + let mut maybes = MaybeCursor.{ state = 0 }; + let mut absent: i32 = 0; + let mut maybeTotal: i32 = 0; + for maybe in maybes { + if maybe == none { absent = absent + 1; } + else { maybeTotal = maybeTotal + maybe; } + } + if absent != 1 || maybeTotal != 7 { return 6; } + + let mut refs = RefCursor.{ value = 42, done = false }; + let mut referenced: i32 = 0; + for reference in refs { + let item: &i32 = reference; + referenced = referenced + 1; + } + if referenced != 1 { return 7; } + return 0; +} From 11b74171728af2587b58eca6fe3c77438cc90179 Mon Sep 17 00:00:00 2001 From: itsfuad Date: Thu, 10 Sep 2026 00:05:04 +0600 Subject: [PATCH 4/5] Implement explicit call iteration and canonical optionals Order type declarations by completion dependencies and refresh legal recursive generic instances in place. Reuse checked producer-call evidence and keep redundant optional syntax diagnostics in parser. Shared typeInstanceUnderlying helper keeps initial and completion construction identical while preserving cached type identity. Tests: go test ./... -count=1 Tests: CCACHE_DISABLE=1 go run ./scripts/bundle.go Tests: bundled peeper x_test suite --- docs/language-spec.md | 54 +++-- internal/diagnostics/codes.go | 1 + internal/frontend/parser/parse_types.go | 33 ++- internal/frontend/parser/parser_test.go | 93 +++++++++ internal/ir/hir/lower/lower_types.go | 2 +- internal/ir/hir/lower/module_lower_test.go | 63 ++++-- internal/lsp/server_test.go | 4 +- internal/pipeline/pipeline_test.go | 38 ++-- internal/project/generic_types.go | 71 ++++++- internal/semantics/binder/binder.go | 26 ++- internal/semantics/binder/binder_test.go | 62 ++++++ internal/semantics/binder/type_decl_cycles.go | 145 ++++++++----- .../definiteinit/initialization_test.go | 4 +- internal/semantics/effect/build_test.go | 4 +- .../semantics/ownership/ownership_test.go | 68 +++--- internal/semantics/typechecker/check_call.go | 5 + internal/semantics/typechecker/check_stmt.go | 193 +++++++----------- internal/semantics/typechecker/flow.go | 11 +- internal/semantics/typechecker/flow_test.go | 6 +- internal/semantics/typechecker/for_in_test.go | 110 +++++++--- .../typechecker/optional_redundancy_test.go | 107 ++++++++++ internal/semantics/typechecker/typechecker.go | 3 + internal/semantics/typecheckresult/result.go | 13 +- internal/semantics/typeinfo/relations.go | 2 +- internal/semantics/typeinfo/syntax.go | 2 +- internal/semantics/typeinfo/types.go | 20 ++ .../negative_structural_iterator/peeper.toml | 2 +- .../src/main.peep | 24 +-- .../src/main.peep | 7 +- x_test/runtime_iterator_factory/src/main.peep | 132 +++++++----- x_test/runtime_iterator_general/src/main.peep | 38 +--- .../runtime_optional_narrowing/src/main.peep | 11 +- .../runtime_optional_redundancy/peeper.toml | 6 + .../runtime_optional_redundancy/src/main.peep | 51 +++++ .../runtime_structural_iterator/src/main.peep | 18 +- 35 files changed, 987 insertions(+), 442 deletions(-) create mode 100644 internal/semantics/typechecker/optional_redundancy_test.go create mode 100644 x_test/runtime_optional_redundancy/peeper.toml create mode 100644 x_test/runtime_optional_redundancy/src/main.peep diff --git a/docs/language-spec.md b/docs/language-spec.md index bfa09b2d..20b3eadb 100644 --- a/docs/language-spec.md +++ b/docs/language-spec.md @@ -93,32 +93,41 @@ for backing storage and is dropped exactly once. ## Iteration -`for item in source` supports built-in ranges and sequences, plus values with an -accessible `Next()` method. Custom iteration is statically dispatched and does -not require an interface or generic constraint: +`for item in source` supports built-in ranges and sequences. Their source is +evaluated once, including when a call produces a sequence. + +Custom iteration uses an explicit call returning `?T`: ```peep -fn (self: &mut Counter) Next() -> ?i32 { - if self.done { return none; } - // Update iterator state and return next item. +fn Produce(counter: &mut Counter, limit: i32) -> ?i32 { + if counter.value >= limit { return none; } + let value = counter.value; + counter.value = counter.value + 1; + return value; } -for item in counter { +for item in Produce(&mut counter, 10) { println(item); } ``` -`Next` declares exactly its receiver and returns `?Item`. A present result binds -one `Item`; `none` ends loop. If `Item` is itself optional, only outer result -layer is removed. Custom loops provide one item binding and do not synthesize an -index. +Every attempt evaluates the entire call, including its callee, receiver, and +arguments, using ordinary call evaluation order. A present result binds an item +of type `T`; `none` terminates the loop. The terminating attempt also evaluates +all arguments. `continue` starts another attempt; `break` and `return` do not. +No source or argument is implicitly captured for the lifetime of the loop. + +Free functions, explicit method calls of any name (for example +`counter.Take(10)`), and pipe calls (`counter |> Produce(10)`) use canonical call +checking. There is no implicit `Next` method protocol or added runtime interface +protocol. Explicit calls retain ordinary dispatch, argument, move, borrow, +reference-provenance, effect, and cleanup semantics. -Source expression is evaluated once. Existing places retain identity; produced -values live for loop scope. Each attempted iteration calls `Next` once. -`continue` starts next attempt, while `break` and `return` do not make another -call. Calls, optional extraction, moves, borrows, reference provenance, and -cleanup follow same rules as equivalent ordinary statements. Interface values -are not custom iterator sources; method target must be statically known. +Custom loops provide one item binding, not an index. Maintain a separate counter +when needed. Bare optional values, function values without a call, and objects +with a `Next` method are not producers. Write the call explicitly. Optionals are +idempotent (`??T` is `?T`), so an optional result does not encode a separate +optional item layer. ## Generic Named Types @@ -145,16 +154,19 @@ and monomorphization are not part of current language surface. ## Optional Values And Flow Narrowing `?T` contains either one `T` value or `none`. `none` is valid only where an -optional type is expected. A `T` value promotes to `?T`; this permits one-layer -promotion such as `?T` to `??T` when the outer optional is expected. Assigning +optional type is expected. A `T` value promotes to `?T`. Optionals are +idempotent: `??T` and `? ?T` mean `?T`, with no extra absence state. Explicit +syntactic nesting emits informational diagnostic `S0006`, asking to remove each +redundant `?`. Nesting revealed through aliases, generic substitution, or +wrapping an optional function result silently canonicalizes to `?T`. Assigning or passing a whole optional to an explicit optional destination preserves its carrier type instead of reading its payload. Comparing a stable optional place with `none` establishes presence on one CFG edge. `x != none` proves presence on the true edge; `x == none` proves presence on the false edge. Reversed operands have identical meaning. Each proof unwraps -one optional layer, so nested optionals require one proof per layer. A proven -ordinary value use has payload type `T`; an unproven use retains `?T` and cannot +the optional carrier; redundant optional markers do not require extra proofs. +A proven ordinary value use has payload type `T`; an unproven use retains `?T` and cannot stand in for `T`. Stable places are variables, field and nested-field projections, constant-folded diff --git a/internal/diagnostics/codes.go b/internal/diagnostics/codes.go index 2bf5df35..e9a38490 100644 --- a/internal/diagnostics/codes.go +++ b/internal/diagnostics/codes.go @@ -95,6 +95,7 @@ const ( InfoRedundantComma = "S0003" InfoRedundantPreludeImport = "S0004" InfoRedundantGlobalQualifier = "S0005" + InfoRedundantOptional = "S0006" // Warnings (W prefix) WarnUnreachableCode = "W0001" diff --git a/internal/frontend/parser/parse_types.go b/internal/frontend/parser/parse_types.go index ad2ee7fb..804e2a0c 100644 --- a/internal/frontend/parser/parse_types.go +++ b/internal/frontend/parser/parse_types.go @@ -20,7 +20,7 @@ func (p *Parser) parseTypeExpr() ast.TypeExpr { switch tok.Kind { case token.AMP: return p.parseRefTypeExpr() - case token.QUESTION: + case token.QUESTION, token.QQ: return p.parseOptionalTypeExpr() case token.ASTERISK: return p.parseOwnedPtrTypeExpr() @@ -147,18 +147,35 @@ func (p *Parser) parseRefTypeExpr() ast.TypeExpr { } func (p *Parser) parseOptionalTypeExpr() ast.TypeExpr { - start := p.consume(token.QUESTION, "expected '?' in optional type") - if start == nil { - return nil + markers := make([]source.Position, 0, 2) + for p.at(token.QUESTION) || p.at(token.QQ) { + marker := p.advance() + markers = append(markers, marker.Start) + if marker.Kind == token.QQ { + second := marker.Start + second.Advance("?") + markers = append(markers, second) + } } inner := p.parseTypeExpr() if inner == nil { return nil } - return reg(p, &ast.OptionalType{ - Inner: inner, - Location: source.NewLocation(p.filePath, start.Start, ast.EndOf(inner)), - }) + for index := len(markers) - 1; index >= 0; index-- { + inner = reg(p, &ast.OptionalType{ + Inner: inner, + Location: source.NewLocation(p.filePath, markers[index], ast.EndOf(inner)), + }) + } + for _, start := range markers[:len(markers)-1] { + end := start + end.Advance("?") + p.diag.Add(diagnostics.NewInfo("redundant optional marker"). + WithCode(diagnostics.InfoRedundantOptional). + WithPrimaryLabel(source.NewLocation(p.filePath, start, end), "remove redundant `?`"). + WithNote("nested optional types are the same as a single optional type")) + } + return inner } func (p *Parser) parseOwnedPtrTypeExpr() ast.TypeExpr { diff --git a/internal/frontend/parser/parser_test.go b/internal/frontend/parser/parser_test.go index 4d4a9f35..9b096e8d 100644 --- a/internal/frontend/parser/parser_test.go +++ b/internal/frontend/parser/parser_test.go @@ -2,6 +2,7 @@ package parser import ( "fmt" + "reflect" "strings" "testing" @@ -2640,3 +2641,95 @@ func TestEmitterNewFormatNoSeverityPrefix(t *testing.T) { t.Fatalf("expected location in output:\n%s", out) } } + +func TestParseOptionalMarkersPreservesSyntaxAndTokens(t *testing.T) { + for _, spelling := range []string{"?i32", "??i32", "???i32", "????i32", "? ?i32", "?? ?i32"} { + t.Run(spelling, func(t *testing.T) { + src := "fn value() -> " + spelling + " { return none; }" + diag := diagnostics.NewDiagnosticBag() + stream := lexer.New("test.peep", src, diag).Tokenize() + original := append(stream[:0:0], stream...) + mod := New("test.peep", stream, diag).ParseModule() + if diag.HasErrors() { + t.Fatalf("unexpected parser errors: %s", diag.EmitAllToString()) + } + if !reflect.DeepEqual(stream, original) { + t.Fatal("optional parsing mutated lexer tokens") + } + typ := mod.Stmts[0].(*ast.FnDecl).ReturnType + for index, char := range src { + if char != '?' { + continue + } + optional, ok := typ.(*ast.OptionalType) + if !ok || ast.StartOf(optional).Index != index { + t.Fatalf("missing source optional at %d: %#v", index, typ) + } + typ = optional.Inner + } + if named, ok := typ.(*ast.NamedType); !ok || named.Name != "i32" { + t.Fatalf("unexpected payload syntax: %#v", typ) + } + }) + } +} + +func TestParseRedundantOptionalSyntax(t *testing.T) { + for _, test := range []struct { + name string + source string + notes int + }{ + {"double", "type Value = ??i32;", 1}, + {"triple", "type Value = ???i32;", 2}, + {"spaced", "type Value = ? ?i32;", 1}, + {"spaced triple", "type Value = ? ? ?i32;", 2}, + {"single", "type Value = ?i32;", 0}, + {"alias", "type Maybe = ?i32; type Value = ?Maybe;", 0}, + {"forward alias", "type Value = ?Maybe; type Maybe = ?i32;", 0}, + {"generic", "type Maybe = ?T; type Value = Maybe; type Again = ?Value;", 0}, + {"generic source once", "type Maybe = ??T; type A = Maybe; type B = Maybe;", 1}, + {"array boundary", "type Value = ?[2]?i32;", 0}, + {"reference boundary", "type Value = ?&?i32;", 0}, + {"field", "struct Box { value: ??i32 }", 1}, + {"parameter and return", "fn Read(value: ??i32) -> ? ?i32 { return value; }", 2}, + {"local", "fn main() { let value: ???i32 = none; }", 2}, + {"function type", "type Callback = fn(value: ??i32) -> ??i32;", 2}, + {"coalescing", "fn Read(value: ?i32) { let result = value ?? 7; }", 0}, + } { + t.Run(test.name, func(t *testing.T) { + _, diag := parseTestModule(test.source) + if diag.HasErrors() != (test.name == "coalescing") { + t.Fatalf("unexpected error state:\n%s", diag.EmitAllToString()) + } + notes := 0 + positions := make(map[int]bool) + for _, item := range diag.Diagnostics() { + if item.Code != diagnostics.InfoRedundantOptional { + continue + } + notes++ + if item.Severity != diagnostics.Info { + t.Fatalf("severity = %v, want info", item.Severity) + } + if len(item.Labels) != 1 || !strings.Contains(item.Labels[0].Message, "remove redundant `?`") { + t.Fatalf("missing removal advice: %#v", item) + } + loc := item.Labels[0].Location + if loc == nil || loc.Start == nil || loc.End == nil { + t.Fatal("missing source span") + } + if loc.End.Index != loc.Start.Index+1 || test.source[loc.Start.Index:loc.End.Index] != "?" { + t.Fatalf("span must select one redundant question mark: %v", loc) + } + if positions[loc.Start.Index] { + t.Fatalf("duplicate diagnostic at %v", loc) + } + positions[loc.Start.Index] = true + } + if notes != test.notes { + t.Fatalf("notes = %d, want %d:\n%s", notes, test.notes, diag.EmitAllToString()) + } + }) + } +} diff --git a/internal/ir/hir/lower/lower_types.go b/internal/ir/hir/lower/lower_types.go index e362c803..0c9ace91 100644 --- a/internal/ir/hir/lower/lower_types.go +++ b/internal/ir/hir/lower/lower_types.go @@ -322,7 +322,7 @@ func loweredRuntimeType(module *project.Module, t typeinfo.Type, seen map[*typei if typ == nil { return nil } - return &typeinfo.OptionalType{Inner: loweredRuntimeType(module, typ.Inner, seen)} + return typeinfo.NewOptional(loweredRuntimeType(module, typ.Inner, seen)) case *typeinfo.ArrayType: if typ == nil { return nil diff --git a/internal/ir/hir/lower/module_lower_test.go b/internal/ir/hir/lower/module_lower_test.go index 19d06059..165b94be 100644 --- a/internal/ir/hir/lower/module_lower_test.go +++ b/internal/ir/hir/lower/module_lower_test.go @@ -61,12 +61,12 @@ func generateTestHIR(t *testing.T, filePath, importPath, src string, beforeLower return out } -func TestGenerateHIRConsumesStructuralIteratorEvidence(t *testing.T) { +func TestGenerateHIRConsumesCallIteratorEvidence(t *testing.T) { out := generateTestHIR(t, "hir_iterator_test"+peeper.SourceExt, "hir_iterator_test", `struct Cursor {} fn (self: &mut Cursor) Next() -> ?i32 { return none; } fn main() { let mut cursor = Cursor.{}; - for item in cursor { if item == 1 { continue; } } + for item in cursor.Next() { if item == 1 { continue; } } }`, func(module *project.Module) { module.Bindings.MethodsByReceiver = nil }) var loop *hir.For for _, fn := range out.Funcs { @@ -88,6 +88,44 @@ fn main() { } } +func TestGenerateHIRCallIteratorUsesOrdinaryInterfaceDispatch(t *testing.T) { + out := generateTestHIR(t, "hir_iterator_interface_test"+peeper.SourceExt, "hir_iterator_interface_test", `iface Producer { fn (&mut Self) Take(value: i32) -> ?i32 } +fn Iterate(producer: &mut Producer) { for item in producer.Take(1) {} }`) + fn := out.Funcs[len(out.Funcs)-1] + expansion := fn.Body.Stmts[0].(*hir.Block) + loop := expansion.Stmts[0].(*hir.For) + call := loop.Body.Stmts[0].(*hir.Binding).Value + if _, ok := call.(*ir.InterfaceCall); !ok { + t.Fatalf("explicit method call = %T, want ordinary interface dispatch", call) + } +} + +func TestGenerateHIRKeepsIteratorArgumentsInsideLoop(t *testing.T) { + for _, call := range []string{"Produce(Value())", "Value() |> Produce()"} { + t.Run(call, func(t *testing.T) { + out := generateTestHIR(t, "hir_iterator_arguments_test"+peeper.SourceExt, "hir_iterator_arguments_test", `fn Value() -> i32 { return 1; } +fn Produce(value: i32) -> ?i32 { return none; } +fn main() { for item in `+call+` {} }`) + main := out.Funcs[len(out.Funcs)-1] + if len(main.Body.Stmts) != 1 { + t.Fatalf("producer escaped loop: %#v", main.Body.Stmts) + } + expansion := main.Body.Stmts[0].(*hir.Block) + if len(expansion.Stmts) != 1 { + t.Fatalf("argument captured before loop: %#v", expansion.Stmts) + } + loop := expansion.Stmts[0].(*hir.For) + producer := loop.Body.Stmts[0].(*hir.Binding).Value.(*ir.Call) + if len(producer.Args) != 1 { + t.Fatalf("producer arguments = %#v", producer.Args) + } + if _, ok := producer.Args[0].(*ir.Call); !ok { + t.Fatalf("argument evaluation = %T, want nested call", producer.Args[0]) + } + }) + } +} + 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 {} @@ -509,18 +547,19 @@ func TestGenerateHIRPreservesExplicitOptionalCarrierInsideProof(t *testing.T) { } } -func TestGenerateHIRPromotesOneOptionalLayer(t *testing.T) { - out := generateTestHIR(t, "hir_optional_promotion_test"+peeper.SourceExt, "hir_optional_promotion_test", `fn promote(inner: ?i32) -> ? ?i32 { +func TestGenerateHIRPreservesRedundantOptionalCarrierIdentity(t *testing.T) { + out := generateTestHIR(t, "hir_optional_identity_test"+peeper.SourceExt, "hir_optional_identity_test", `fn keep(inner: ?i32) -> ? ?i32 { return inner; }`) - ret := out.Funcs[0].Body.Stmts[0].(*hir.Return) - outer, ok := ret.Value.(*ir.VariantMake) - if !ok || out.Types.Text(outer.TypeID()) != "??i32" { - t.Fatalf("promotion = %#v, want outer ??i32 VariantMake", ret.Value) - } - payload, ok := outer.Payload.(*ir.Ident) - if !ok || out.Types.Text(payload.TypeID()) != "?i32" { - t.Fatalf("promotion payload = %#v, want intact ?i32 carrier", outer.Payload) + fn := out.Funcs[0] + ret := fn.Body.Stmts[0].(*hir.Return) + carrier, ok := ret.Value.(*ir.Ident) + if !ok || carrier.SymbolID != fn.Params[0].SymbolID { + t.Fatalf("return = %#v, want unchanged parameter carrier, not VariantMake", ret.Value) + } + if carrier.TypeID() != fn.Params[0].Type || carrier.TypeID() != fn.ReturnType || out.Types.Text(carrier.TypeID()) != "?i32" { + t.Fatalf("carrier type = %s (%d), parameter = %d, return = %d; want one shared ?i32 type", + out.Types.Text(carrier.TypeID()), carrier.TypeID(), fn.Params[0].Type, fn.ReturnType) } } diff --git a/internal/lsp/server_test.go b/internal/lsp/server_test.go index 070f835f..8e326e40 100644 --- a/internal/lsp/server_test.go +++ b/internal/lsp/server_test.go @@ -493,7 +493,7 @@ fn inspect(value: result::Alias) { } } -func TestHoverShowsStructuralIteratorItemType(t *testing.T) { +func TestHoverShowsCallIteratorItemType(t *testing.T) { root := t.TempDir() filePath := filepath.Join(root, "main"+peeper.SourceExt) src := `struct Item { value: i32 } @@ -501,7 +501,7 @@ struct Cursor {} fn (self: &Cursor) Next() -> ?Item { return none; } fn main() { let cursor = Cursor.{}; - for item in cursor { return __CURSOR__item.value; } + for item in cursor.Next() { return __CURSOR__item.value; } }` state := NewServerState() state.RootDir = root diff --git a/internal/pipeline/pipeline_test.go b/internal/pipeline/pipeline_test.go index 49f2008f..b72a9fdb 100644 --- a/internal/pipeline/pipeline_test.go +++ b/internal/pipeline/pipeline_test.go @@ -2604,8 +2604,9 @@ fn main() -> i32 { func TestPipelineAcceptsOptionalNarrowingAcrossCFGAndStablePlaces(t *testing.T) { tests := []struct { - name string - src string + name string + src string + redundantInfo bool }{ { name: "polarity and reversed operands", @@ -2668,31 +2669,33 @@ fn fields(outer: Outer, holder: Holder, index: usize) -> i32 { }`, }, { - name: "nested optional proofs", + name: "redundant optional needs one proof", + redundantInfo: true, src: `fn nested(value: ? ?i32) -> i32 { if value != none { - if value != none { - return value; - } + return value; } return 0; }`, }, { - name: "nested optional test in eager boolean", + name: "redundant optional test in eager boolean", + redundantInfo: true, src: `fn nested(value: ? ?i32, enabled: bool) -> bool { return value != none && enabled; }`, }, { name: "nested inferred carrier and shadowed identity", - src: `fn inferred(value: ? ?i32) -> i32 { + src: `struct Holder { field: ?i32, fallback: i32 } + +fn inferred(value: ?Holder) -> i32 { if value == none { return 0; } - let inner = value; + let inner = value.field; if inner == none { - return 0; + return value.fallback; } return inner; } @@ -2919,6 +2922,9 @@ fn valid(owner: ?*Holder) -> i32 { if diag.HasErrors() { t.Fatalf("optional narrowing failed:\n%s", diag.EmitAllToString()) } + if got := hasDiagnosticCode(diag, diagnostics.InfoRedundantOptional); got != tt.redundantInfo { + t.Fatalf("redundant optional info = %v, want %v:\n%s", got, tt.redundantInfo, diag.EmitAllToString()) + } }) } } @@ -2949,18 +2955,20 @@ fn invalid(value: ?Holder) -> i32 { return value.Get(); }`, }`, }, { - name: "join recheck clears stale nested payload evidence", + name: "join recheck clears stale field payload evidence", code: "T0041", - src: `fn invalid(value: ? ?i32) -> i32 { - if value != none { + src: `struct Holder { field: ?i32, other: ?i32 } + +fn invalid(value: Holder) -> i32 { + if value.field != none { } else { print(0); print(1); } - if value == none { + if value.other == none { return 0; } - return value; + return value.field; }`, }, { diff --git a/internal/project/generic_types.go b/internal/project/generic_types.go index 5dc8a7dd..243ddfa7 100644 --- a/internal/project/generic_types.go +++ b/internal/project/generic_types.go @@ -1,6 +1,7 @@ package project import ( + "slices" "strconv" "strings" @@ -17,6 +18,7 @@ type namedTypeDeclaration struct { type namedTypeInstance struct { ownerModuleID moduleid.ID + base *typeinfo.DefinedType typ *typeinfo.DefinedType ready chan struct{} complete bool @@ -138,6 +140,7 @@ func (ctx *CompilerContext) instantiateType(base *typeinfo.DefinedType, argument // applications resolve back to this exact object. ctx.typeInstances[identity] = namedTypeInstance{ ownerModuleID: declarationModule.ID, + base: base, typ: instance, ready: make(chan struct{}), } @@ -149,12 +152,7 @@ func (ctx *CompilerContext) instantiateType(base *typeinfo.DefinedType, argument applicationText: applicationText, node: node, }) - opts := TypeSyntaxOptions(ctx, declarationModule, nil, true) - opts.TypeParameters = typeinfo.TypeParameterBindings(base.TypeParameters, canonicalArguments) - opts.Instantiate = func(nestedBase *typeinfo.DefinedType, nestedArguments []typeinfo.Type, nestedNode ast.TypeExpr) typeinfo.Type { - return ctx.instantiateType(nestedBase, nestedArguments, nestedNode, chain) - } - instance.Underlying = typeinfo.TypeFromSyntax(declaration.syntax.UnderlyingType(), opts) + instance.Underlying = ctx.typeInstanceUnderlying(declarationModule, declaration, instance, chain) valid := !typeinfo.ContainsInvalid(instance.Underlying) ctx.finishTypeInstance(identity, instance, valid) if !valid { @@ -163,6 +161,67 @@ func (ctx *CompilerContext) instantiateType(base *typeinfo.DefinedType, argument return instance } +func (ctx *CompilerContext) typeInstanceUnderlying(declarationModule *Module, declaration namedTypeDeclaration, instance *typeinfo.DefinedType, chain []typeInstantiationFrame) typeinfo.Type { + opts := TypeSyntaxOptions(ctx, declarationModule, nil, true) + opts.TypeParameters = typeinfo.TypeParameterBindings(declaration.base.TypeParameters, instance.TypeArguments) + opts.Instantiate = func(nestedBase *typeinfo.DefinedType, nestedArguments []typeinfo.Type, nestedNode ast.TypeExpr) typeinfo.Type { + return ctx.instantiateType(nestedBase, nestedArguments, nestedNode, chain) + } + return typeinfo.TypeFromSyntax(declaration.syntax.UnderlyingType(), opts) +} + +// CompleteTypeInstances rebuilds cached instances in place after binder fills +// every shell in a legal declaration cycle. Pointer identity remains stable for +// recursive references and existing cache consumers. +func (ctx *CompilerContext) CompleteTypeInstances(bases []*typeinfo.DefinedType) { + if ctx == nil || len(bases) == 0 { + return + } + selected := make(map[*typeinfo.DefinedType]bool, len(bases)) + for _, base := range bases { + if base != nil && len(base.TypeParameters) > 0 { + selected[base] = true + } + } + if len(selected) == 0 { + return + } + + ctx.mu.RLock() + identities := make([]string, 0) + for identity, cached := range ctx.typeInstances { + if cached.complete && cached.typ != nil && selected[cached.base] { + identities = append(identities, identity) + } + } + ctx.mu.RUnlock() + slices.Sort(identities) + + for _, identity := range identities { + ctx.mu.RLock() + cached := ctx.typeInstances[identity] + declarationModule := ctx.typeDeclarations[cached.base.Identity] + declaration := namedTypeDeclaration{} + if declarationModule != nil { + declaration = declarationModule.namedTypeDeclarations[cached.base.Identity] + } + ctx.mu.RUnlock() + if declarationModule == nil || declaration.syntax == nil || declaration.base != cached.base { + continue + } + chain := []typeInstantiationFrame{{ + declarationIdentity: cached.base.Identity, + applicationIdentity: identity, + applicationText: cached.typ.Text(), + node: declaration.syntax.UnderlyingType(), + }} + underlying := ctx.typeInstanceUnderlying(declarationModule, declaration, cached.typ, chain) + if !typeinfo.ContainsInvalid(underlying) { + cached.typ.Underlying = underlying + } + } +} + // finishTypeInstance publishes or removes one provisional cache entry and // wakes any concurrent application waiting on the same semantic identity. func (ctx *CompilerContext) finishTypeInstance(identity string, instance *typeinfo.DefinedType, valid bool) { diff --git a/internal/semantics/binder/binder.go b/internal/semantics/binder/binder.go index d8e68efd..d09513d4 100644 --- a/internal/semantics/binder/binder.go +++ b/internal/semantics/binder/binder.go @@ -24,11 +24,18 @@ func Bind(ctx *project.CompilerContext, module *project.Module) { } func (b *binder) bindModule() { - ast.ForEachDecl(b.module.AST, func(decl ast.Decl) bool { - if typeDecl, ok := decl.(ast.TypeDecl); ok { - b.bindTypeDecl(typeDecl) - return true + ordered, completionCycle := b.typeDeclarationOrder() + for _, decl := range ordered { + b.bindTypeDecl(decl) + } + completed := make([]*typeinfo.DefinedType, 0, len(completionCycle)) + for _, decl := range completionCycle { + if defined := b.bindTypeDecl(decl); defined != nil { + completed = append(completed, defined) } + } + b.ctx.CompleteTypeInstances(completed) + ast.ForEachDecl(b.module.AST, func(decl ast.Decl) bool { switch node := decl.(type) { case *ast.FnDecl: b.bindFunctionDecl(node) @@ -42,7 +49,6 @@ func (b *binder) bindModule() { slices.SortFunc(b.module.Bindings.OperationFunctions, func(left, right *symbols.Symbol) int { return cmp.Compare(left.Name, right.Name) }) - b.validateTypeDeclCycles() } // Bind function and top-level declaration signatures into module scope. @@ -84,18 +90,18 @@ func (b *binder) bindModuleBinding(name *ast.Ident, typ ast.TypeExpr) { // Bind named type declarations using one stable shell per symbol. // Recursive self-references must see same DefinedType object. -func (b *binder) bindTypeDecl(decl ast.TypeDecl) { +func (b *binder) bindTypeDecl(decl ast.TypeDecl) *typeinfo.DefinedType { if b == nil || b.module == nil || decl == nil { - return + return nil } name := decl.DeclName() typ := decl.UnderlyingType() if name == nil || name.Name == "" { - return + return nil } sym := b.moduleScopeSymbol(name.Name) if sym == nil { - return + return nil } defined, ok := sym.Type.(*typeinfo.DefinedType) if ok && defined != nil { @@ -112,7 +118,7 @@ func (b *binder) bindTypeDecl(decl ast.TypeDecl) { opts := project.TypeSyntaxOptions(b.ctx, b.module, nil, true) opts.TypeParameters = typeinfo.TypeParameterBindings(defined.TypeParameters, nil) defined.Underlying = typeinfo.TypeFromSyntax(typ, opts) - b.registerTypeDecl(name.Name, typ) + return defined } func (b *binder) moduleScopeSymbol(name string) *symbols.Symbol { diff --git a/internal/semantics/binder/binder_test.go b/internal/semantics/binder/binder_test.go index d1223c7e..1d35925f 100644 --- a/internal/semantics/binder/binder_test.go +++ b/internal/semantics/binder/binder_test.go @@ -233,6 +233,68 @@ fn Use(alias: Choice, canonical: Choice) {}` } } +func TestBindCompletesGenericArgumentDependencies(t *testing.T) { + for _, source := range []string{ + `type Early = Box; struct Box { value: T } type Maybe = ?Later; type Later = ?i32;`, + `type Early = Box; struct Box { value: T } type Maybe = Optional; type Optional = ?T; type Later = ?i32;`, + `type Early = Box; type Maybe = ?Later; struct Box { value: Later } type Later = ?i32;`, + `struct Node { link: ?Link } type Link = ?*Node; type Early = Box; struct Box { value: T } type Maybe = ?i32;`, + `type Link = ?*Node; struct Node { link: ?Link } type Early = Box; struct Box { value: T } type Maybe = ?i32;`, + `type Linked = Node; struct Node { link: ?Link } type Link = ?*Node; type Early = Box; struct Box { value: T } type Maybe = ?i32;`, + } { + t.Run(source, func(t *testing.T) { + const filePath = "binder_forward_optional_test" + peeper.SourceExt + source += ` fn Use(early: Early, canonical: Box) {}` + diag := diagnostics.NewDiagnosticBag() + ctx := project.New(".", peeper.SourceExt, diag) + module := &project.Module{ + ID: moduleid.ID{Origin: string(project.ModuleOriginLocal), ImportPath: strings.TrimSuffix(filePath, peeper.SourceExt)}, + FilePath: filePath, Content: source, + AST: parser.New(filePath, lexer.New(filePath, source, diag).Tokenize(), diag).ParseModule(), + Imports: make(map[string]project.ResolvedImport), + } + collector.Collect(ctx, module) + Bind(ctx, module) + if diag.HasErrors() { + t.Fatalf("unexpected diagnostics:\n%s", diag.EmitAllToString()) + } + use, ok := module.ModuleScope.LookupLocal("Use") + if !ok { + t.Fatal("missing Use function") + } + fn := use.Type.(*typeinfo.FuncType) + if typeinfo.Unalias(fn.Params[0]) != fn.Params[1] { + t.Fatal("forward alias split canonical generic instance") + } + field := typeinfo.Underlying(fn.Params[0]).(*typeinfo.StructType).Fields[0].Type + optional, ok := typeinfo.Unalias(field).(*typeinfo.OptionalType) + if !ok || typeinfo.TypeText(optional.Inner) != "i32" { + t.Fatalf("field = %s, want one optional i32", typeinfo.TypeText(field)) + } + seen := make(map[typeinfo.Type]bool) + var check func(typeinfo.Type) + check = func(typ typeinfo.Type) { + if typ == nil || seen[typ] { + return + } + seen[typ] = true + if optional, ok := typ.(*typeinfo.OptionalType); ok { + if _, nested := typeinfo.Unalias(optional.Inner).(*typeinfo.OptionalType); nested { + t.Error("completed type retains nested optional carriers") + } + } + typeinfo.ForEachChild(typ, func(child typeinfo.TypeChild) bool { + check(child.Type) + return true + }) + } + for _, sym := range module.ModuleScope.Symbols() { + check(sym.Type) + } + }) + } +} + func TestBindRejectsExpandingGenericRecursion(t *testing.T) { if os.Getenv("PEEPER_TEST_EXPANDING_GENERIC_RECURSION") == "1" { tests := []struct { diff --git a/internal/semantics/binder/type_decl_cycles.go b/internal/semantics/binder/type_decl_cycles.go index 4d58246c..993b208d 100644 --- a/internal/semantics/binder/type_decl_cycles.go +++ b/internal/semantics/binder/type_decl_cycles.go @@ -2,6 +2,7 @@ package binder import ( "fmt" + "slices" "strings" "compiler/internal/diagnostics" @@ -10,42 +11,50 @@ import ( "compiler/internal/moduleid" "compiler/internal/project" "compiler/internal/semantics/symbols" + "compiler/internal/semantics/typeinfo" ) const ( - graphEdgeTypeValueRef graph.EdgeKind = "type_value_ref" - graphEdgeTypeIndirectRef graph.EdgeKind = "type_indirect_ref" + graphEdgeTypeValueRef graph.EdgeKind = "type_value_ref" + graphEdgeTypeIndirectRef graph.EdgeKind = "type_indirect_ref" + graphEdgeTypeCompletionRef graph.EdgeKind = "type_completion_ref" ) -func (b *binder) registerTypeDecl(name string, typ ast.TypeExpr) { - if b == nil || b.ctx == nil || b.ctx.Graph == nil || b.module == nil || name == "" { - return - } - owner := typeDeclNodeID(b.module.ID, name) - // Value edges require full layout; indirect references do not force target expansion. - b.addTypeDeclEdges(owner, typ, false) -} - -func (b *binder) validateTypeDeclCycles() { +// typeDeclarationOrder registers dependencies once and orders construction +// before any generic applications can cache incomplete alias representations. +// Legal completion cycles get one bounded completion pass after every shell is +// populated. Only value edges are illegal cycles. +func (b *binder) typeDeclarationOrder() ([]ast.TypeDecl, []ast.TypeDecl) { if b == nil || b.ctx == nil || b.ctx.Graph == nil || b.ctx.Diagnostics == nil || b.module == nil || b.module.ModuleScope == nil { - return - } - nodeIDs := make([]graph.NodeID, 0) - for _, sym := range b.module.ModuleScope.Symbols() { - if sym == nil || sym.Kind != symbols.SymbolType { - continue + return nil, nil + } + var nodeIDs []graph.NodeID + declarations := make(map[graph.NodeID]ast.TypeDecl) + ast.ForEachDecl(b.module.AST, func(decl ast.Decl) bool { + typeDecl, ok := decl.(ast.TypeDecl) + if !ok || typeDecl.DeclName() == nil { + return true } - nodeIDs = append(nodeIDs, typeDeclNodeID(b.module.ID, sym.Name)) - } - if len(nodeIDs) == 0 { - return - } + sym := b.moduleScopeSymbol(typeDecl.DeclName().Name) + if sym == nil || sym.ASTNode != decl { + return true + } + id := typeDeclNodeID(b.module.ID, sym.Name) + nodeIDs = append(nodeIDs, id) + declarations[id] = typeDecl + b.addTypeDeclEdges(id, typeDecl.UnderlyingType(), false, typeDecl.DeclarationTypeParams()) + return true + }) // Only value-layout edges participate in illegal cycle detection. _, cycles := b.ctx.Graph.TopoSort(nodeIDs, graphEdgeTypeValueRef) + illegal := make(map[graph.NodeID]bool) for _, cycle := range cycles { if len(cycle) == 0 { continue } + for _, id := range cycle { + illegal[id] = true + } firstName := typeDeclNameFromNodeID(cycle[0]) firstSym, ok := b.module.ModuleScope.LookupLocal(firstName) if !ok || firstSym == nil { @@ -68,6 +77,23 @@ func (b *binder) validateTypeDeclCycles() { "break the cycle with indirection such as a pointer", ) } + order, completionCycles := b.ctx.Graph.TopoSort(nodeIDs, graphEdgeTypeCompletionRef) + ordered := make([]ast.TypeDecl, 0, len(order)) + for _, id := range order { + ordered = append(ordered, declarations[id]) + } + seen := make(map[graph.NodeID]bool) + completion := make([]ast.TypeDecl, 0) + for _, cycle := range completionCycles { + for _, id := range cycle { + if illegal[id] || seen[id] || declarations[id] == nil { + continue + } + seen[id] = true + completion = append(completion, declarations[id]) + } + } + return ordered, completion } func typeDeclNodeID(moduleID moduleid.ID, name string) graph.NodeID { @@ -77,57 +103,70 @@ func typeDeclNodeID(moduleID moduleid.ID, name string) graph.NodeID { return graph.NodeID("type:" + moduleID.String() + ":" + name) } -func (b *binder) addTypeDeclEdges(owner graph.NodeID, typ ast.TypeExpr, indirect bool) { +func (b *binder) addTypeDeclEdges(owner graph.NodeID, typ ast.TypeExpr, indirect bool, parameters []ast.TypeParam) { if b == nil || b.ctx == nil || b.ctx.Graph == nil || b.module == nil || owner == "" || typ == nil { return } switch node := typ.(type) { case *ast.NamedType: - b.addTypeDeclEdge(owner, b.lookupTypeDeclNodeID(node.Name), indirect) + target, alias := b.lookupTypeDeclNodeID(node.Name, parameters) + b.addTypeDeclEdge(owner, target, indirect, alias) case *ast.AppliedType: if node.Name != nil { - b.addTypeDeclEdge(owner, b.lookupTypeDeclNodeID(node.Name.Name), indirect) + target, _ := b.lookupTypeDeclNodeID(node.Name.Name, parameters) + b.addTypeDeclEdge(owner, target, indirect, true) + } + // Arguments must be canonical before instance keys are computed. An + // argument occurrence alone does not establish an inline layout edge. + for _, argument := range node.TypeArgs { + b.addTypeDeclEdges(owner, argument, true, parameters) } case *ast.ScopeResolution: - b.addTypeDeclEdge(owner, b.lookupQualifiedTypeDeclNodeID(node), indirect) + target, alias := b.lookupQualifiedTypeDeclNodeID(node) + b.addTypeDeclEdge(owner, target, indirect, alias || len(node.Segments[len(node.Segments)-1].TypeArgs) > 0) + for _, segment := range node.Segments { + for _, argument := range segment.TypeArgs { + b.addTypeDeclEdges(owner, argument, true, parameters) + } + } case *ast.RawPtrType: // Raw pointers carry no pointee layout dependency. case *ast.EnumType: for _, variant := range node.Variants { - b.addTypeDeclEdges(owner, variant.Payload, indirect) + b.addTypeDeclEdges(owner, variant.Payload, indirect, parameters) } case *ast.OwnedPtrType: // Pointer target is not a layout dependency. - b.addTypeDeclEdges(owner, node.Target, true) + b.addTypeDeclEdges(owner, node.Target, true, parameters) case *ast.RefType: // Reference target is not owned inline storage. - b.addTypeDeclEdges(owner, node.Target, true) + b.addTypeDeclEdges(owner, node.Target, true, parameters) case *ast.OptionalType: - b.addTypeDeclEdges(owner, node.Inner, indirect) + b.addTypeDeclEdges(owner, node.Inner, indirect, parameters) case *ast.ArrayType: - b.addTypeDeclEdges(owner, node.Elem, indirect || node.Shape != ast.ArrayFixed || node.Len == nil) + b.addTypeDeclEdges(owner, node.Elem, indirect || node.Shape != ast.ArrayFixed || node.Len == nil, parameters) case *ast.StructType: for _, field := range node.Fields { - b.addTypeDeclEdges(owner, field.Type, indirect) + b.addTypeDeclEdges(owner, field.Type, indirect, parameters) } case *ast.FuncType: for _, param := range node.Params { - b.addTypeDeclEdges(owner, param.Type, true) + b.addTypeDeclEdges(owner, param.Type, true, parameters) } - b.addTypeDeclEdges(owner, node.Return, true) + b.addTypeDeclEdges(owner, node.Return, true, parameters) case *ast.InterfaceType: for _, method := range node.Methods { for _, param := range method.Params { - b.addTypeDeclEdges(owner, param.Type, true) + b.addTypeDeclEdges(owner, param.Type, true, parameters) } - b.addTypeDeclEdges(owner, method.ReturnType, true) + b.addTypeDeclEdges(owner, method.ReturnType, true, parameters) } default: panic(fmt.Sprintf("binder type dependencies: unhandled type syntax %T", typ)) } } -func (b *binder) addTypeDeclEdge(owner, target graph.NodeID, indirect bool) { +func (b *binder) addTypeDeclEdge(owner, target graph.NodeID, indirect, complete bool) { if target == "" { return } @@ -136,32 +175,44 @@ func (b *binder) addTypeDeclEdge(owner, target graph.NodeID, indirect bool) { kind = graphEdgeTypeIndirectRef } b.ctx.Graph.AddEdge(owner, target, kind) + // Nominal references only need their collected shell. Aliases and applied + // declarations must finish first, even when used behind an indirection. + if complete && owner != target { + b.ctx.Graph.AddEdge(owner, target, graphEdgeTypeCompletionRef) + } } -func (b *binder) lookupTypeDeclNodeID(name string) graph.NodeID { +func (b *binder) lookupTypeDeclNodeID(name string, parameters []ast.TypeParam) (graph.NodeID, bool) { if b == nil || b.module == nil || b.module.ModuleScope == nil || name == "" { - return "" + return "", false + } + if slices.ContainsFunc(parameters, func(parameter ast.TypeParam) bool { + return parameter.Name != nil && parameter.Name.Name == name + }) { + return "", false } sym, ok := b.module.ModuleScope.Lookup(name) if !ok || sym == nil || sym.Kind != symbols.SymbolType { - return "" + return "", false } - return typeDeclNodeID(b.module.ID, sym.Name) + defined, ok := sym.Type.(*typeinfo.DefinedType) + return typeDeclNodeID(b.module.ID, sym.Name), ok && defined.Kind == typeinfo.DefinedKindAlias } -func (b *binder) lookupQualifiedTypeDeclNodeID(node *ast.ScopeResolution) graph.NodeID { +func (b *binder) lookupQualifiedTypeDeclNodeID(node *ast.ScopeResolution) (graph.NodeID, bool) { if b == nil || b.ctx == nil || b.module == nil || node == nil { - return "" + return "", false } qualifier, member, imported := node.ImportMember() if !imported { - return "" + return "", false } resolved, ok := project.LookupImportedSymbol(b.ctx, b.module, qualifier.Name, member.Name) if !ok || resolved.Module == nil || resolved.Symbol == nil || resolved.Symbol.Kind != symbols.SymbolType { - return "" + return "", false } - return typeDeclNodeID(resolved.Module.ID, resolved.Symbol.Name) + defined, ok := resolved.Symbol.Type.(*typeinfo.DefinedType) + return typeDeclNodeID(resolved.Module.ID, resolved.Symbol.Name), ok && defined.Kind == typeinfo.DefinedKindAlias } func typeDeclNameFromNodeID(id graph.NodeID) string { diff --git a/internal/semantics/definiteinit/initialization_test.go b/internal/semantics/definiteinit/initialization_test.go index 087e2602..9eac6f9b 100644 --- a/internal/semantics/definiteinit/initialization_test.go +++ b/internal/semantics/definiteinit/initialization_test.go @@ -71,13 +71,13 @@ func analyzeInitializationSource(t *testing.T, source string) (*functionResult, return result, diag, module } -func TestStructuralIterationDoesNotGuaranteeEntry(t *testing.T) { +func TestCallIterationDoesNotGuaranteeEntry(t *testing.T) { _, diag, _ := analyzeInitializationSource(t, `struct Cursor {} fn (self: &Cursor) Next() -> ?i32 { return none; } fn choose() -> i32 { let cursor = Cursor.{}; let mut result: i32; - for item in cursor { result = item; } + for item in cursor.Next() { result = item; } return result; }`) if !diag.HasErrors() || !strings.Contains(diag.EmitAllToString(), "used before it's initialized") { diff --git a/internal/semantics/effect/build_test.go b/internal/semantics/effect/build_test.go index ee03d6af..96354029 100644 --- a/internal/semantics/effect/build_test.go +++ b/internal/semantics/effect/build_test.go @@ -137,12 +137,12 @@ func describe(op effect.Op) string { return "unknown" } -func TestStructuralIterationPublishesOrdinaryReceiverCall(t *testing.T) { +func TestCallIterationPublishesOrdinaryReceiverCall(t *testing.T) { result, module := buildEffects(t, `struct Cursor {} fn (self: &mut Cursor) Next() -> ?i32 { return none; } fn probe() { let mut cursor = Cursor.{}; - for item in cursor { if item == 1 { continue; } } + for item in cursor.Next() { if item == 1 { continue; } } }`) calls, ends, borrows := 0, 0, 0 for _, op := range publishedOps(t, result, module, "probe") { diff --git a/internal/semantics/ownership/ownership_test.go b/internal/semantics/ownership/ownership_test.go index 64ada0ea..78fde2df 100644 --- a/internal/semantics/ownership/ownership_test.go +++ b/internal/semantics/ownership/ownership_test.go @@ -71,7 +71,7 @@ func checkOwnershipSource(t *testing.T, src string) *ownershipResult { return &ownershipResult{DiagnosticBag: diag, ctx: ctx, module: module} } -func TestStructuralIterationUsesOrdinaryCallGuards(t *testing.T) { +func TestCallIterationUsesOrdinaryCallGuards(t *testing.T) { for _, test := range []struct { name, receiver, before, body, after, diagnostic string }{ @@ -88,7 +88,7 @@ func TestStructuralIterationUsesOrdinaryCallGuards(t *testing.T) { for _, implicit := range []bool{false, true} { loop := "for { let result = cursor.Next(); if result == none { break; } let item: i32 = result; " + test.body + " }" if implicit { - loop = "for item in cursor { " + test.body + " }" + loop = "for item in cursor.Next() { " + test.body + " }" } result := checkOwnershipSource(t, "struct Cursor { value: i32, limit: i32 }\nfn (self: "+test.receiver+") Next() -> ?i32 { return none; }\nfn Consume(cursor: Cursor) {}\nfn main() { let mut cursor = Cursor.{ value = 0, limit = 3 }; "+test.before+loop+test.after+" }") if test.diagnostic == "" { @@ -103,36 +103,46 @@ func TestStructuralIterationUsesOrdinaryCallGuards(t *testing.T) { } } -func TestIteratorFactorySourceCleanup(t *testing.T) { +func TestIteratorFactoryArgumentCleanup(t *testing.T) { for _, body := range []string{"", "continue;", "break;", "return;"} { t.Run(body, func(t *testing.T) { - result := checkOwnershipSource(t, `struct Cursor { held: *i32, value: i32 } -fn Make() -> Cursor { return Cursor.{ held = alloc(1), value = 0 }; } -fn (self: &mut Cursor) Next() -> ?i32 { return none; } -fn main() { for item in Make() { `+body+` } }`) - if result.HasErrors() { - t.Fatalf("unexpected diagnostics:\n%s", result.EmitAllToString()) - } - fn := result.module.AST.Stmts[3].(*ast.FnDecl) - sourceLoop := fn.Body.Stmts[0].(*ast.ForStmt) - expansion := result.module.Typechecking.CheckedIterations[sourceLoop.ID()] - binding := expansion.Stmts[0].(*ast.LetDecl) - owner := result.module.Bindings.NodeSymbols[binding.Name.ID()] - graph := result.module.CFG.Function(ir.NodeID(fn.ID())) - plan := cleanupPlanForFunction(t, result, fn) - exit := scopeExitSiteID(t, graph, expansion.ID()) - if got := plan.AfterScope[exit]; !slices.Equal(got, []symbols.SymbolID{owner.ID}) { - t.Fatalf("source exit cleanup = %v, want [%d]", got, owner.ID) - } - for site, drops := range plan.AfterScope { - if site != exit && slices.Contains(drops, owner.ID) { - t.Fatalf("source dropped at another scope, including possible backedge: %v", site) + for _, expanded := range []bool{false, true} { + loop := "for { let result = Produce(Make()); if result == none { break; } let item: i32 = result; " + body + " }" + if expanded { + loop = "for item in Produce(Make()) { " + body + " }" } - } - if body == "return;" { - ret := sourceLoop.Body.Stmts[0].(*ast.ReturnStmt) - if got := plan.BeforeReturn[ir.NodeID(ret.ID())]; !slices.Equal(got, []symbols.SymbolID{owner.ID}) { - t.Fatalf("return cleanup = %v, want [%d]", got, owner.ID) + result := checkOwnershipSource(t, `struct Argument { held: *i32, value: i32 } +fn Make() -> Argument { return Argument.{ held = alloc(1), value = 0 }; } +fn Produce(argument: Argument) -> ?i32 { + if argument.value == 0 { return none; } + return argument.value; +} +fn main() { `+loop+` }`) + if result.HasErrors() { + t.Fatalf("expanded=%v unexpected diagnostics:\n%s", expanded, result.EmitAllToString()) + } + producer := result.module.AST.Stmts[2].(*ast.FnDecl) + plan := cleanupPlanForFunction(t, result, producer) + returns := []*ast.ReturnStmt{ + producer.Body.Stmts[0].(*ast.IfStmt).Then.Stmts[0].(*ast.ReturnStmt), + producer.Body.Stmts[1].(*ast.ReturnStmt), + } + for _, ret := range returns { + if got := cleanupSymbolNames(result.module, plan.BeforeReturn[ir.NodeID(ret.ID())]); !slices.Equal(got, []string{"argument"}) { + t.Fatalf("expanded=%v argument cleanup = %v, want [argument]", expanded, got) + } + } + if expanded { + fn := result.module.AST.Stmts[3].(*ast.FnDecl) + sourceLoop := fn.Body.Stmts[0].(*ast.ForStmt) + expansion := result.module.Typechecking.CheckedIterations[sourceLoop.ID()] + if len(expansion.Stmts) != 1 { + t.Fatal("factory argument captured outside repeated call") + } + checked := expansion.Stmts[0].(*ast.ForStmt) + if checked.Body.Stmts[0].(*ast.LetDecl).Value != sourceLoop.Iterable { + t.Fatal("producer call replaced instead of checked unchanged") + } } } }) diff --git a/internal/semantics/typechecker/check_call.go b/internal/semantics/typechecker/check_call.go index ee574f8f..62fb66af 100644 --- a/internal/semantics/typechecker/check_call.go +++ b/internal/semantics/typechecker/check_call.go @@ -56,6 +56,11 @@ func (c *checker) typePrintExpr(scope *symbols.Scope, node *ast.PrintExpr) typei } func (c *checker) typeCallExpr(scope *symbols.Scope, node *ast.CallExpr) typeinfo.Type { + if c.flow == nil && c.reusedCall == node { + if typ, checked := c.module.Typechecking.ExprTypes[node.ID()]; checked { + return typ + } + } effectiveArgs := c.module.Typechecking.CallArgumentsOrSource(node) if c.flow == nil { effectiveArgs = append([]ast.Expr(nil), node.Args...) diff --git a/internal/semantics/typechecker/check_stmt.go b/internal/semantics/typechecker/check_stmt.go index bf6da28e..743cb4d5 100644 --- a/internal/semantics/typechecker/check_stmt.go +++ b/internal/semantics/typechecker/check_stmt.go @@ -508,9 +508,9 @@ 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. +// checkForInStmt types a `for x in iterable` loop over a range, indexable +// sequence, or optional-producing call. Strings require an explicit +// as_bytes/as_chars view. func (c *checker) checkForInStmt(scope *symbols.Scope, node *ast.ForStmt, returnType typeinfo.Type) { indexType := typeinfo.DefaultIntegerType() evidence := typecheckresult.ForIteration{} @@ -626,19 +626,27 @@ func (c *checker) checkForInStmt(scope *symbols.Scope, node *ast.ForStmt, return evidence.ElementType = elem } else { if valid && !c.siteOnly { - if !c.checkStructuralIteration(scope, node, iterableType) { - d := invalidExpressionError(node.Iterable, "cannot iterate over "+typeinfo.TypeText(iterableType)) - if _, isInterface := typeinfo.InterfaceTypeOf(iterableType); isInterface { - d.WithNote("for loops do not support interface values, even when the interface declares `Next`"). - WithHelp("iterate over the original struct value before passing it as an interface") - } else { - d.WithHelp("use a range, array, slice, or a value with a `Next()` method available here"). - WithNote("`Next()` must return an optional item, such as `?i32`; method names are case-sensitive") - } - c.ctx.Diagnostics.Add(d) + call, callExpr := node.Iterable.(*ast.CallExpr) + optional, optionalResult := typeinfo.Underlying(iterableType).(*typeinfo.OptionalType) + switch { + case !callExpr: + c.ctx.Diagnostics.Add(invalidExpressionError(node.Iterable, "cannot iterate over "+typeinfo.TypeText(iterableType)). + WithHelp("use a range, array, slice, or an explicit call returning an optional item, such as `for item in producer()`"). + WithNote("iterator calls, including arguments, are evaluated on every attempt; a bare optional value is not a producer")) + case !optionalResult || optional.Inner == nil: + c.ctx.Diagnostics.Add(invalidExpressionError(node.Iterable, "iterator call must return an optional item"). + WithHelp("return an optional type, such as `?i32`: return an item to continue, or `none` to end the loop")) + case node.Index != nil: + c.ctx.Diagnostics.Add(invalidExpressionError(node.Index, "iterator loops provide an item, not an index"). + WithHelp("use `for item in producer()`; if you need an index, maintain a separate counter")) + default: + c.expandCallIteration(scope, node) } if checked := c.module.Typechecking.CheckedIterations[node.ID()]; checked != nil { + previous := c.reusedCall + c.reusedCall = call c.checkStmt(scope, checked, returnType) + c.reusedCall = previous return } } @@ -682,121 +690,79 @@ func (c *checker) checkForInStmt(scope *symbols.Scope, node *ast.ForStmt, return c.loopDepth-- } -// checkStructuralIteration publishes ordinary checked statements rather than -// adding hidden call/borrow semantics in lowering. Existing places retain their -// storage identity; produced values use normal binding cleanup. -func (c *checker) checkStructuralIteration(scope *symbols.Scope, node *ast.ForStmt, iterableType typeinfo.Type) bool { - method, found := c.lookupDeclaredCallableMember(iterableType, "Next") - if !found || method.Symbol == nil { - return false - } - if node.Index != nil { - c.ctx.Diagnostics.Add(invalidExpressionError(node.Index, "iterator loops provide an item, not an index"). - WithHelp("use `for item in iterator`; if you need an index, maintain a separate counter")) - return true - } - fnType, callable := method.Type.(*typeinfo.FuncType) - decl, declared := method.Symbol.ASTNode.(*ast.FnDecl) - if !callable || !declared || decl.Receiver == nil || len(decl.ParamsWithReceiver()) != 1 || len(fnType.Params) != 1 { - c.ctx.Diagnostics.Add(invalidExpressionError(node.Iterable, "`Next` cannot take arguments in a for loop"). - WithSecondaryLabel(method.Symbol.Location, "this method declares additional parameters"). - WithNote("the loop calls `Next()` without arguments; parameters with defaults are not supported either"). - WithHelp("move iteration settings into fields on your iterator, or call `Next(...)` explicitly in a loop")) - return true - } - optional, optionalResult := typeinfo.Underlying(fnType.Return).(*typeinfo.OptionalType) - if !optionalResult || optional.Inner == nil { - c.ctx.Diagnostics.Add(invalidExpressionError(node.Iterable, "`Next` must return an optional item"). - WithSecondaryLabel(ast.LocOf(decl.ReturnType), "return type is "+typeinfo.TypeText(fnType.Return)). - WithHelp("return an optional type, such as `?i32`: return an item to continue, or `none` to end the loop")) - return true - } - placeSource := place.IsPlaceExpr(node.Iterable) - capturePlace := false - for current := node.Iterable; placeSource; { - projection, projected := place.Project(current) - if !projected { - break - } - if projection.Index != nil { - indexType := c.module.BaseExprType(projection.Index.ID()) - if _, constant := consteval.EvaluateExpr(c.ctx, c.module, scope, projection.Index, indexType); !constant { - capturePlace = true - break - } - } - current = projection.Base - } - +// expandCallIteration publishes ordinary checked statements before flow and +// ownership. Keeping the entire source call inside the loop repeats its receiver +// and arguments on every attempt, including the terminating attempt. +func (c *checker) expandCallIteration(scope *symbols.Scope, node *ast.ForStmt) { location := ast.LocOf(node) - expansion := &ast.BlockStmt{Location: location} - var sourceBinding *ast.LetDecl - var receiver ast.Expr = node.Iterable - if !placeSource || capturePlace { - sourceValue := node.Iterable - mutableSource := true - if capturePlace { - // Dynamic indexes must select storage once. Capturing its reference keeps - // place identity without moving or copying existing cursor storage. - mode := ast.AddressShared - if _, mutable, reference := typeinfo.ReferenceTarget(typeinfo.Underlying(fnType.Params[0])); reference && mutable { - mode = ast.AddressMutable - } - sourceValue = &ast.AddressExpr{Mode: mode, Expr: node.Iterable, Location: ast.LocOf(node.Iterable)} - mutableSource = false - } - // A non-source identifier avoids collisions while keeping ordinary - // ownership diagnostics readable when they name this binding. - sourceBinding = &ast.LetDecl{ - Name: &ast.Ident{Name: "iterator source", Location: ast.LocOf(node.Iterable)}, - IsMutable: mutableSource, Value: sourceValue, Location: ast.LocOf(node.Iterable), - } - expansion.Stmts = append(expansion.Stmts, sourceBinding) - receiver = &ast.Ident{Name: sourceBinding.Name.Name, Location: ast.LocOf(node.Iterable)} + expansion := &ast.BlockStmt{ + NodeIDHolder: ast.NodeIDHolder{NodeID: ast.NewSyntheticNodeID()}, + Location: location, } resultName := fmt.Sprintf("$for.result.%d", node.ID()) result := &ast.LetDecl{ - Name: &ast.Ident{Name: resultName, Location: location}, - Value: &ast.CallExpr{Callee: &ast.SelectorExpr{ - Expr: receiver, Name: &ast.Ident{Name: "Next", Location: location}, Location: location, - }, Location: location}, Location: location, + NodeIDHolder: ast.NodeIDHolder{NodeID: ast.NewSyntheticNodeID()}, + Name: &ast.Ident{ + NodeIDHolder: ast.NodeIDHolder{NodeID: ast.NewSyntheticNodeID()}, + Name: resultName, + Location: location, + }, + Value: node.Iterable, + Location: location, } stop := &ast.IfStmt{ - Cond: &ast.BinaryExpr{Left: &ast.Ident{Name: resultName, Location: location}, Op: "==", Right: &ast.NoneLit{Location: location}, Location: location}, - Then: &ast.BlockStmt{Stmts: []ast.Stmt{&ast.BreakStmt{Location: location}}, Location: location}, Location: location, + NodeIDHolder: ast.NodeIDHolder{NodeID: ast.NewSyntheticNodeID()}, + Cond: &ast.BinaryExpr{ + NodeIDHolder: ast.NodeIDHolder{NodeID: ast.NewSyntheticNodeID()}, + Left: &ast.Ident{ + NodeIDHolder: ast.NodeIDHolder{NodeID: ast.NewSyntheticNodeID()}, + Name: resultName, + Location: location, + }, + Op: "==", + Right: &ast.NoneLit{ + NodeIDHolder: ast.NodeIDHolder{NodeID: ast.NewSyntheticNodeID()}, + Location: location, + }, + Location: location, + }, + Then: &ast.BlockStmt{ + NodeIDHolder: ast.NodeIDHolder{NodeID: ast.NewSyntheticNodeID()}, + Stmts: []ast.Stmt{&ast.BreakStmt{ + NodeIDHolder: ast.NodeIDHolder{NodeID: ast.NewSyntheticNodeID()}, + Location: location, + }}, + Location: location, + }, + Location: location, } item := &ast.LetDecl{ - Name: node.Value, Value: &ast.Ident{Name: resultName, Location: location}, Location: location, + NodeIDHolder: ast.NodeIDHolder{NodeID: ast.NewSyntheticNodeID()}, + Name: node.Value, + Value: &ast.Ident{ + NodeIDHolder: ast.NodeIDHolder{NodeID: ast.NewSyntheticNodeID()}, + Name: resultName, + Location: location, + }, + Location: location, } body := *node.Body body.Stmts = append([]ast.Stmt{item}, node.Body.Stmts...) checked := &ast.ForStmt{ NodeIDHolder: node.NodeIDHolder, - Body: &ast.BlockStmt{Stmts: []ast.Stmt{result, stop, &body}, Location: location}, Location: location, + Body: &ast.BlockStmt{ + NodeIDHolder: ast.NodeIDHolder{NodeID: ast.NewSyntheticNodeID()}, + Stmts: []ast.Stmt{result, stop, &body}, + Location: location, + }, + Location: location, } expansion.Stmts = append(expansion.Stmts, checked) - ast.Inspect(expansion, func(generated ast.Node) bool { - if generated == nil { - return false - } - if generated.ID() == 0 { - generated.SetID(ast.NewSyntheticNodeID()) - } - return true - }) bodyScope := c.module.Bindings.BlockScopes[node.Body.ID()] - sourceScope := symbols.NewScope(scope) - c.module.Bindings.BlockScopes[expansion.ID()] = sourceScope - iterationScope := bodyScope.InsertParent(sourceScope) - if sourceBinding != nil { - sourceSymbol := symbols.New(sourceBinding.Name.Name, symbols.SymbolVar, sourceBinding, ast.LocOf(node.Iterable)) - sourceSymbol.Used = true - if err := sourceScope.Declare(sourceSymbol); err != nil { - panic(err) - } - c.module.Bindings.NodeSymbols[sourceBinding.Name.ID()] = sourceSymbol - c.module.Bindings.NodeSymbols[receiver.ID()] = sourceSymbol - } + expansionScope := symbols.NewScope(scope) + c.module.Bindings.BlockScopes[expansion.ID()] = expansionScope + iterationScope := bodyScope.InsertParent(expansionScope) + c.module.Bindings.BlockScopes[checked.Body.ID()] = iterationScope c.module.Bindings.BlockScopes[stop.Then.ID()] = symbols.NewScope(iterationScope) resultSymbol := symbols.New(resultName, symbols.SymbolVar, result, location) @@ -807,11 +773,10 @@ func (c *checker) checkStructuralIteration(scope *symbols.Scope, node *ast.ForSt c.module.Bindings.NodeSymbols[result.Name.ID()] = resultSymbol c.module.Bindings.NodeSymbols[stop.Cond.(*ast.BinaryExpr).Left.ID()] = resultSymbol c.module.Bindings.NodeSymbols[item.Value.ID()] = resultSymbol - c.module.Typechecking.PayloadDepths[item.Value.ID()] = 1 + itemSymbol := c.module.Bindings.NodeSymbols[node.Value.ID()] itemSymbol.ASTNode = item c.module.Typechecking.CheckedIterations[node.ID()] = expansion - return true } func (c *checker) bindLoopVariable(name *ast.Ident, typ typeinfo.Type) { diff --git a/internal/semantics/typechecker/flow.go b/internal/semantics/typechecker/flow.go index 8bc281a3..961947f3 100644 --- a/internal/semantics/typechecker/flow.go +++ b/internal/semantics/typechecker/flow.go @@ -144,15 +144,12 @@ func (c *checker) effectiveExpressionType(scope *symbols.Scope, expr ast.Expr, b return base } _, explicitCarrier := typeinfo.Underlying(expected).(*typeinfo.OptionalType) - required, exactPayloadDepth := c.module.Typechecking.PayloadDepths[expr.ID()] - if !exactPayloadDepth { - required = payloadDepthForExpected(base, expected) - } - if c.payloadContext > 0 && required == 0 && !explicitCarrier && !exactPayloadDepth { + required := payloadDepthForExpected(base, expected) + if c.payloadContext > 0 && required == 0 && !explicitCarrier { required = optionalLayerCount(base) } if c.flow == nil { - if c.optionalTestContext > 0 || (explicitCarrier && !exactPayloadDepth) || required == 0 { + if c.optionalTestContext > 0 || explicitCarrier || required == 0 { return base } return unwrapOptionalLayers(base, required) @@ -162,7 +159,7 @@ func (c *checker) effectiveExpressionType(scope *symbols.Scope, expr ast.Expr, b resolved := unwrapOptionalLayers(base, len(payloadCases)) applied := optionalLayerCount(base) - optionalLayerCount(resolved) payloadCases = payloadCases[:applied] - if c.optionalTestContext == 0 && explicitCarrier && !exactPayloadDepth { + if c.optionalTestContext == 0 && explicitCarrier { c.recordFlowResolution(expr, resolution) return base } diff --git a/internal/semantics/typechecker/flow_test.go b/internal/semantics/typechecker/flow_test.go index 41dbae16..17a646f9 100644 --- a/internal/semantics/typechecker/flow_test.go +++ b/internal/semantics/typechecker/flow_test.go @@ -48,15 +48,15 @@ func checkFlowSource(t *testing.T, src string) (*project.Module, *diagnostics.Di return module, diag } -func TestStructuralIterationPublishesCheckedOperations(t *testing.T) { +func TestCallIterationPublishesCheckedOperations(t *testing.T) { module, diag := checkFlowSource(t, `struct Cursor { value: i32, limit: i32 } fn (self: &mut Cursor) Next() -> ?i32 { return none; } fn main() { let mut cursor = Cursor.{ value = 0, limit = 3 }; - for cursor in cursor { + for cursor in cursor.Next() { let item: i32 = cursor; let mut inner = Cursor.{ value = item, limit = 3 }; - for value in inner { if value == 1 { continue; } } + for value in inner.Next() { if value == 1 { continue; } } } }`) if diag.HasErrors() { diff --git a/internal/semantics/typechecker/for_in_test.go b/internal/semantics/typechecker/for_in_test.go index 04d28fb0..502e22b1 100644 --- a/internal/semantics/typechecker/for_in_test.go +++ b/internal/semantics/typechecker/for_in_test.go @@ -10,22 +10,27 @@ import ( "compiler/internal/target" ) -func TestStructuralIterationRecognition(t *testing.T) { +func TestCallIterationRecognition(t *testing.T) { for _, test := range []struct { name, method, binding, header, diagnostic, hint string }{ {name: "mutable scalar", method: "fn (self: &mut Cursor) Next() -> ?i32 { return none; }"}, {name: "shared bool", method: "fn (self: &Cursor) Next() -> ?bool { return none; }", binding: "let cursor = Cursor.{};"}, - {name: "no method", diagnostic: "cannot iterate over", hint: "method names are case-sensitive"}, - {name: "lowercase is not protocol", method: "fn (self: &Cursor) next() -> ?i32 { return none; }", diagnostic: "cannot iterate over"}, + {name: "bare object", method: "fn (self: &Cursor) Next() -> ?i32 { return none; }", header: "item in cursor", diagnostic: "cannot iterate over", hint: "explicit call returning an optional item"}, + {name: "bare optional", binding: "let cursor: ?i32 = none;", header: "item in cursor", diagnostic: "cannot iterate over", hint: "a bare optional value is not a producer"}, + {name: "lowercase method", method: "fn (self: &Cursor) next() -> ?i32 { return none; }", header: "item in cursor.next()"}, + {name: "arbitrary method", method: "fn (self: &Cursor) Take(value: i32) -> ?i32 { return value; }", header: "item in cursor.Take(3)"}, + {name: "free call", method: "fn Produce() -> ?i32 { return none; }", header: "item in Produce()"}, + {name: "pipe call", method: "fn Produce(cursor: &Cursor) -> ?i32 { return none; }", header: "item in cursor |> Produce()"}, {name: "wrong return", method: "fn (self: &Cursor) Next() -> i32 { return 0; }", diagnostic: "must return an optional item", hint: "return an item to continue, or `none` to end the loop"}, - {name: "extra default", method: "fn (self: &Cursor) Next(value: i32 = 0) -> ?i32 { return value; }", diagnostic: "cannot take arguments in a for loop", hint: "parameters with defaults are not supported either"}, - {name: "index", method: "fn (self: &Cursor) Next() -> ?i32 { return none; }", header: "index, item in cursor", diagnostic: "provide an item, not an index", hint: "maintain a separate counter"}, + {name: "default argument", method: "fn (self: &Cursor) Next(value: i32 = 0) -> ?i32 { return value; }"}, + {name: "invalid argument", method: "fn (self: &Cursor) Next(value: bool) -> ?i32 { return none; }", header: "item in cursor.Next(1)", diagnostic: "bool"}, + {name: "index", method: "fn (self: &Cursor) Next() -> ?i32 { return none; }", header: "index, item in cursor.Next()", diagnostic: "provide an item, not an index", hint: "maintain a separate counter"}, {name: "immutable", method: "fn (self: &mut Cursor) Next() -> ?i32 { return none; }", binding: "let cursor = Cursor.{};", diagnostic: "mutable"}, - {name: "temporary literal", method: "fn (self: &mut Cursor) Next() -> ?i32 { return none; }", header: "item in Cursor.{}"}, - {name: "temporary factory", method: "fn (self: &mut Cursor) Next() -> ?i32 { return none; } fn Make() -> Cursor { return Cursor.{}; }", header: "item in Make()"}, + {name: "bare literal", header: "item in Cursor.{}", diagnostic: "cannot iterate over"}, + {name: "object factory", method: "fn (self: &mut Cursor) Next() -> ?i32 { return none; } fn Make() -> Cursor { return Cursor.{}; }", header: "item in Make()", diagnostic: "must return an optional item"}, {name: "reference source", method: "fn (self: &mut Cursor) Next() -> ?i32 { return none; }", binding: "let mut original = Cursor.{}; let cursor = &mut original;"}, - {name: "nested optional item", method: "fn (self: &Cursor) Next() -> ? ?i32 { return none; }"}, + {name: "function value is not a call", method: "fn Produce() -> ?i32 { return none; }", header: "item in Produce", diagnostic: "cannot iterate over"}, } { t.Run(test.name, func(t *testing.T) { binding, header := test.binding, test.header @@ -33,7 +38,7 @@ func TestStructuralIterationRecognition(t *testing.T) { binding = "let mut cursor = Cursor.{};" } if header == "" { - header = "item in cursor" + header = "item in cursor.Next()" } module, diag := checkTypeModule(t, "struct Cursor {}\n"+test.method+"\nfn main() { "+binding+" for "+header+" {} }") if test.diagnostic == "" { @@ -50,7 +55,47 @@ func TestStructuralIterationRecognition(t *testing.T) { } } -func TestStructuralIterationMatchesExplicitOperations(t *testing.T) { +func TestCallIterationReusesCheckedProducerEvidence(t *testing.T) { + module, diag := checkTypeModule(t, `iface Reader { fn (&Self) read() -> i32 } +struct Counter { value: i32 } +fn (self: &Counter) read() -> i32 { return self.value; } +fn Produce(value: &Counter, reader: &Reader = value) -> ?i32 { return reader.read(); } +fn main() { + let counter = Counter.{ value = 1 }; + for item in Produce(&counter) {} +}`) + if diag.HasErrors() { + t.Fatalf("unexpected diagnostics:\n%s", diag.EmitAllToString()) + } + var producer *ast.CallExpr + for _, stmt := range module.AST.Stmts { + ast.Inspect(stmt, func(node ast.Node) bool { + call, ok := node.(*ast.CallExpr) + if ok && ast.ExprText(call.Callee) == "Produce" { + producer = call + } + return producer == nil + }) + if producer != nil { + break + } + } + if producer == nil { + t.Fatal("producer call not found") + } + effective := module.Typechecking.EffectiveCallArguments[producer.ID()] + if len(effective) != 2 { + t.Fatalf("effective arguments = %d, want 2", len(effective)) + } + if got := len(module.Typechecking.InterfaceImplementations); got != 2 { + t.Fatalf("interface evidence entries = %d, want declaration default plus one call expansion", got) + } + if implementations := module.Typechecking.InterfaceImplementations[effective[1].ID()]; len(implementations) != 1 { + t.Fatalf("effective default evidence = %#v, want one implementation", implementations) + } +} + +func TestCallIterationMatchesExplicitOperations(t *testing.T) { for _, test := range []struct { name, source, implicit, explicit string }{ @@ -63,7 +108,7 @@ fn main() { let mut holder = Holder.{ cursor = Cursor.{} }; __LOOP__ }`, - implicit: "for item in holder.cursor { let value: i32 = item; }", + implicit: "for item in holder.cursor.Next() { let value: i32 = item; }", explicit: "for { let result = holder.cursor.Next(); if result == none { break; } let item: i32 = result; let value: i32 = item; }", }, { @@ -75,8 +120,8 @@ fn main() { let index: i32 = 0; __LOOP__ }`, - implicit: "for item in cursors[index] { let value: i32 = item; }", - explicit: "let source = &mut cursors[index]; for { let result = source.Next(); if result == none { break; } let item: i32 = result; let value: i32 = item; }", + implicit: "for item in cursors[index].Next() { let value: i32 = item; }", + explicit: "for { let result = cursors[index].Next(); if result == none { break; } let item: i32 = result; let value: i32 = item; }", }, { name: "reference parameter source", @@ -84,7 +129,7 @@ fn main() { fn (self: &mut Cursor) Next() -> ?i32 { return none; } fn Walk(cursor: &mut Cursor) { __LOOP__ } fn main() { let mut cursor = Cursor.{}; Walk(&mut cursor); }`, - implicit: "for item in cursor { let value: i32 = item; }", + implicit: "for item in cursor.Next() { let value: i32 = item; }", explicit: "for { let result = cursor.Next(); if result == none { break; } let item: i32 = result; let value: i32 = item; }", }, { @@ -93,7 +138,7 @@ fn main() { let mut cursor = Cursor.{}; Walk(&mut cursor); }`, struct Cursor {} fn (self: &Cursor) Next() -> ?Item { return none; } fn main() { let cursor = Cursor.{}; __LOOP__ }`, - implicit: "for item in cursor { let value: i32 = item.value; }", + implicit: "for item in cursor.Next() { let value: i32 = item.value; }", explicit: "for { let result = cursor.Next(); if result == none { break; } let item: Item = result; let value: i32 = item.value; }", }, { @@ -101,23 +146,15 @@ fn main() { let cursor = Cursor.{}; __LOOP__ }`, source: `struct Cursor {} fn (self: &Cursor) Next() -> ?*i32 { return none; } fn main() { let cursor = Cursor.{}; __LOOP__ }`, - implicit: "for item in cursor { free(item); }", + implicit: "for item in cursor.Next() { free(item); }", explicit: "for { let result = cursor.Next(); if result == none { break; } let item: *i32 = result; free(item); }", }, - { - name: "nested optional item", - source: `struct Cursor {} -fn (self: &Cursor) Next() -> ? ?i32 { return none; } -fn main() { let cursor = Cursor.{}; __LOOP__ }`, - implicit: "for item in cursor { if item != none { let value: i32 = item; } }", - explicit: "for { let result = cursor.Next(); if result == none { break; } if result != none { let value: i32 = result; } }", - }, { name: "reference item", source: `struct Cursor { value: i32 } fn (self: &mut Cursor) Next() -> ?&i32 from self { return none; } fn main() { let mut cursor = Cursor.{ value = 1 }; __LOOP__ }`, - implicit: "for item in cursor { let value: &i32 = item; }", + implicit: "for item in cursor.Next() { let value: &i32 = item; }", explicit: "for { let result = cursor.Next(); if result == none { break; } let item: &i32 = result; let value: &i32 = item; }", }, } { @@ -143,16 +180,27 @@ fn main() { let mut cursor = Cursor.{ value = 1 }; __LOOP__ }`, } } -func TestRejectedStructuralIterationStillChecksBody(t *testing.T) { - _, diag := checkTypeModule(t, `struct Cursor {} +func TestRejectedCallIterationStillChecksBody(t *testing.T) { + for _, test := range []struct{ header, diagnostic string }{ + {"item in cursor.Next()", "must return an optional item"}, + {"item in cursor", "cannot iterate over"}, + {"index, item in Produce(1)", "provide an item, not an index"}, + {"item in Produce(true)", "cannot implicitly convert bool to i32"}, + {"item in Missing()", "Missing"}, + } { + t.Run(test.header, func(t *testing.T) { + _, diag := checkTypeModule(t, `struct Cursor {} fn (self: &Cursor) Next() -> i32 { return 0; } +fn Produce(value: i32) -> ?i32 { return none; } fn main() { let cursor = Cursor.{}; - for item in cursor { let invalid: bool = 1; } + for `+test.header+` { let invalid: bool = 1; } }`) - text := diag.EmitAllToString() - if !strings.Contains(text, "must return an optional item") || !strings.Contains(text, "cannot be used as bool") { - t.Fatalf("expected header and body diagnostics:\n%s", text) + text := diag.EmitAllToString() + if !strings.Contains(text, test.diagnostic) || !strings.Contains(text, "cannot be used as bool") { + t.Fatalf("expected header and body diagnostics:\n%s", text) + } + }) } } diff --git a/internal/semantics/typechecker/optional_redundancy_test.go b/internal/semantics/typechecker/optional_redundancy_test.go new file mode 100644 index 00000000..03ab7a31 --- /dev/null +++ b/internal/semantics/typechecker/optional_redundancy_test.go @@ -0,0 +1,107 @@ +package typechecker + +import ( + "fmt" + "os" + "strings" + "testing" + + "compiler/internal/diagnostics" + "compiler/internal/semantics/typeinfo" +) + +func TestOptionalForwardGenericField(t *testing.T) { + declarations := []string{ + "type Early = Box;", + "struct Box { value: ?Later }", + "type Later = ?i32;", + } + for first := range len(declarations) { + for second := range len(declarations) { + if first == second { + continue + } + t.Run(fmt.Sprintf("order_%d%d", first, second), func(t *testing.T) { + source := strings.Join([]string{declarations[first], declarations[second], declarations[3-first-second]}, "\n") + _, diag := checkFlowSource(t, source+` +fn Read(box: &Early) -> i32 { + if box.value == none { return 0; } + return box.value; +}`) + if diag.HasErrors() { + t.Fatalf("forward generic field must need one proof:\n%s", diag.EmitAllToString()) + } + }) + } + } +} + +func TestOptionalRedundancyCanonicalTypes(t *testing.T) { + for _, test := range []struct { + name string + declarations string + spelling string + notes int + }{ + {"explicit double", "", "??i32", 1}, + {"explicit triple", "", "???i32", 2}, + {"explicit spaced", "", "? ?i32", 1}, + {"alias", "type Maybe = ?i32;", "?Maybe", 0}, + {"forward alias", "type Maybe = ?Later; type Later = ?i32;", "Maybe", 0}, + {"generic", "type Maybe = ?T;", "Maybe", 0}, + {"wrapped generic", "type Maybe = ?T;", "?Maybe", 0}, + } { + t.Run(test.name, func(t *testing.T) { + module, diag := checkFlowSource(t, test.declarations+` +fn Some() -> ?i32 { return 42; } +fn Wrap() -> `+test.spelling+` { return Some(); } +fn Read() -> i32 { + let value = Wrap(); + if value == none { return 0; } + return value; +}`) + if diag.HasErrors() { + t.Fatalf("unexpected errors:\n%s", diag.EmitAllToString()) + } + notes := 0 + for _, item := range diag.Diagnostics() { + if item.Code == diagnostics.InfoRedundantOptional { + notes++ + if item.Severity != diagnostics.Info { + t.Fatalf("severity = %v", item.Severity) + } + } + } + if notes != test.notes { + t.Fatalf("notes = %d, want %d:\n%s", notes, test.notes, diag.EmitAllToString()) + } + wrap, ok := module.ModuleScope.LookupLocal("Wrap") + if !ok { + t.Fatal("missing Wrap symbol") + } + fn, ok := typeinfo.Unalias(wrap.Type).(*typeinfo.FuncType) + if !ok { + t.Fatalf("Wrap type = %T", wrap.Type) + } + optional, ok := typeinfo.Unalias(fn.Return).(*typeinfo.OptionalType) + if !ok || typeinfo.TypeText(optional.Inner) != "i32" { + t.Fatalf("return type = %s, want one optional i32 carrier", typeinfo.TypeText(fn.Return)) + } + descriptor, ok := typeinfo.VariantDescriptorOf(fn.Return) + if !ok || len(descriptor.Cases) != 2 || typeinfo.TypeText(descriptor.Cases[1].Payload) != "i32" { + t.Fatalf("optional representation = %#v", descriptor) + } + }) + } +} + +func TestOptionalRedundancyFixtureSemantics(t *testing.T) { + source, err := os.ReadFile("../../../x_test/runtime_optional_redundancy/src/main.peep") + if err != nil { + t.Fatal(err) + } + _, diag := checkFlowSource(t, string(source)) + if diag.HasErrors() { + t.Fatalf("fixture semantic errors:\n%s", diag.EmitAllToString()) + } +} diff --git a/internal/semantics/typechecker/typechecker.go b/internal/semantics/typechecker/typechecker.go index 26e42209..49b34d53 100644 --- a/internal/semantics/typechecker/typechecker.go +++ b/internal/semantics/typechecker/typechecker.go @@ -17,6 +17,9 @@ type checker struct { optionalTestContext int wholeCarrierExpr ast.Expr loopDepth int + // reusedCall is the already-checked source call embedded at the root of a + // generated producer loop. Nested calls and flow visits still check normally. + reusedCall *ast.CallExpr } // Concrete references convert to satisfied interface borrows, while owned diff --git a/internal/semantics/typecheckresult/result.go b/internal/semantics/typecheckresult/result.go index 751d8f56..e46cbdae 100644 --- a/internal/semantics/typecheckresult/result.go +++ b/internal/semantics/typecheckresult/result.go @@ -146,18 +146,14 @@ type Result struct { CaseTests map[ast.NodeID]CaseTest Matches map[ast.NodeID]Match ForIterations map[ast.NodeID]ForIteration - // CheckedIterations contains structural loops expanded into ordinary checked + // CheckedIterations contains optional-producing calls expanded into ordinary checked // statements before CFG/flow/effects/ownership. These loops have no numeric // cursor and never appear in the builtin ForIterations table. Source syntax // stays unchanged; CFG, typed-node indexing and HIR consume this same tree. - // Each block owns any temporary source binding and ends with the checked - // loop, which retains the source loop's ID. The block has its own scope ID. + // Each block contains the checked loop, which retains the source loop's ID. + // The block has its own scope ID; the whole call runs inside the loop. CheckedIterations map[ast.NodeID]*ast.BlockStmt - // PayloadDepths requests an exact number of proven variant payload projections - // for an expression. Most source expressions derive this from expected types; - // generated checked operations use it when the expected type is itself optional. - PayloadDepths map[ast.NodeID]int - ExprTypes map[ast.NodeID]typeinfo.Type + ExprTypes map[ast.NodeID]typeinfo.Type // ValueUses classifies every ownership-relevant value use, keyed by the // used expression's node ID. Reference parameters publish UseRead; the // borrow machinery in ownership still governs them. @@ -186,7 +182,6 @@ func New() *Result { Matches: make(map[ast.NodeID]Match), ForIterations: make(map[ast.NodeID]ForIteration), CheckedIterations: make(map[ast.NodeID]*ast.BlockStmt), - PayloadDepths: make(map[ast.NodeID]int), ExprTypes: make(map[ast.NodeID]typeinfo.Type), ValueUses: make(map[ast.NodeID]typeinfo.UseKind), ReferenceArguments: make(map[ast.NodeID]bool), diff --git a/internal/semantics/typeinfo/relations.go b/internal/semantics/typeinfo/relations.go index 04fdd19f..62f8dcad 100644 --- a/internal/semantics/typeinfo/relations.go +++ b/internal/semantics/typeinfo/relations.go @@ -350,7 +350,7 @@ func ReplaceAbstractSelf(t Type, ownerType Type) Type { if typ == nil { return nil } - return &OptionalType{Inner: ReplaceAbstractSelf(typ.Inner, ownerType)} + return NewOptional(ReplaceAbstractSelf(typ.Inner, ownerType)) case *ArrayType: if typ == nil { return nil diff --git a/internal/semantics/typeinfo/syntax.go b/internal/semantics/typeinfo/syntax.go index 2ee431b2..e69b0cb9 100644 --- a/internal/semantics/typeinfo/syntax.go +++ b/internal/semantics/typeinfo/syntax.go @@ -94,7 +94,7 @@ func TypeFromSyntax(node ast.TypeExpr, opts SyntaxOptions) Type { if typ == nil { return nil } - return &OptionalType{Inner: TypeFromSyntax(typ.Inner, opts)} + return NewOptional(TypeFromSyntax(typ.Inner, opts)) case *ast.ArrayType: if typ == nil { return nil diff --git a/internal/semantics/typeinfo/types.go b/internal/semantics/typeinfo/types.go index 6da45288..3f895d5d 100644 --- a/internal/semantics/typeinfo/types.go +++ b/internal/semantics/typeinfo/types.go @@ -277,6 +277,26 @@ func TypeParameterBindings(parameters []*TypeParameterType, arguments []Type) ma return bindings } +// NewOptional collapses consecutive optional layers, including transparent +// aliases, without crossing nominal or pointer/array boundaries. Callers must +// resolve alias dependencies before construction; nominal recursive shells may +// remain incomplete. +func NewOptional(inner Type) Type { + seen := make(map[Type]bool) + for { + inner = Unalias(inner) + optional, ok := inner.(*OptionalType) + if !ok || optional == nil { + return &OptionalType{Inner: inner} + } + if seen[optional] { + return &InvalidType{} + } + seen[optional] = true + inner = optional.Inner + } +} + // Unalias returns canonical transparent-alias storage without erasing nominal // structs, interfaces, or enums. Invalid alias cycles terminate as invalid. func Unalias(t Type) Type { diff --git a/x_test/negative_structural_iterator/peeper.toml b/x_test/negative_structural_iterator/peeper.toml index 2b1695e1..6a5d6760 100644 --- a/x_test/negative_structural_iterator/peeper.toml +++ b/x_test/negative_structural_iterator/peeper.toml @@ -4,5 +4,5 @@ build = "program" [test] mode = "check" outcome = "failure" -stderr_contains = ["must return an optional item", "cannot take arguments in a for loop", "provide an item, not an index", "mutable", "cannot iterate over", "cannot be used as bool", "return type is i32", "parameters with defaults are not supported either", "maintain a separate counter", "iterate over the original struct value before passing it as an interface"] +stderr_contains = ["must return an optional item", "provide an item, not an index", "mutable", "cannot iterate over", "cannot be used as bool", "maintain a separate counter", "explicit call returning an optional item", "a bare optional value is not a producer"] stderr_excludes = ["structural iteration", "local concrete cursor", "scalar items", "$for."] diff --git a/x_test/negative_structural_iterator/src/main.peep b/x_test/negative_structural_iterator/src/main.peep index 35e27576..f8ee813a 100644 --- a/x_test/negative_structural_iterator/src/main.peep +++ b/x_test/negative_structural_iterator/src/main.peep @@ -1,25 +1,23 @@ struct WrongReturn {} fn (self: &WrongReturn) Next() -> i32 { return 1; } -struct Extra {} -fn (self: &Extra) Next(value: i32 = 1) -> ?i32 { return value; } - struct Cursor {} fn (self: &mut Cursor) Next() -> ?i32 { return none; } - +fn Produce(value: bool) -> ?i32 { return none; } iface Erased { fn (&mut Self) Next() -> ?i32 } -fn RejectErased(cursor: &mut Erased) { for item in cursor {} } - +fn RejectBareErased(cursor: &mut Erased) { for item in cursor {} } fn main() { let wrong = WrongReturn.{}; - for item in wrong { let invalid: bool = 1; } - let extra = Extra.{}; - for item in extra {} - let mut indexed = Cursor.{}; - for index, item in indexed {} + for item in wrong.Next() { let invalid: bool = 1; } + let mut cursor = Cursor.{}; + for item in cursor {} + for index, item in cursor.Next() {} let immutable = Cursor.{}; - for item in immutable {} - + for item in immutable.Next() {} + let optional: ?i32 = none; + for item in optional {} + for item in Produce {} + for item in Produce(1) {} } diff --git a/x_test/negative_structural_iterator_move/src/main.peep b/x_test/negative_structural_iterator_move/src/main.peep index a1e98d3c..c5106c7c 100644 --- a/x_test/negative_structural_iterator_move/src/main.peep +++ b/x_test/negative_structural_iterator_move/src/main.peep @@ -5,13 +5,10 @@ struct Cursor { value: i32, limit: i32 } fn (self: &mut Cursor) Next() -> ?i32 { return none; } fn Consume(cursor: Cursor) {} -fn Make() -> Consumed { return Consumed.{ value = 0, limit = 2 }; } - fn main() { - for item in Make() {} let consumed = Consumed.{ value = 0, limit = 2 }; - for item in consumed {} + for item in consumed.Next() {} let mut moved = Cursor.{ value = 0, limit = 2 }; Consume(moved); - for item in moved {} + for item in moved.Next() {} } diff --git a/x_test/runtime_iterator_factory/src/main.peep b/x_test/runtime_iterator_factory/src/main.peep index 505dbb61..8be52b8f 100644 --- a/x_test/runtime_iterator_factory/src/main.peep +++ b/x_test/runtime_iterator_factory/src/main.peep @@ -1,73 +1,93 @@ -struct Cursor { - value: i32, - limit: i32, - held: *i32 -} +struct Argument { held: *i32, serial: i32 } +struct Counts { made: i32, ticks: i32, phase: i32, errors: i32 } +struct Cursor { value: i32, limit: i32, calls: i32 } -fn (self: &mut Cursor) Next() -> ?i32 { - if self.value >= self.limit { return none; } - let value = self.value; - self.value = self.value + 1; - return value; +fn Make(counts: &mut Counts) -> Argument { + if counts.phase == 1 { counts.errors = counts.errors + 1; } + counts.phase = 1; + counts.made = counts.made + 1; + return Argument.{ held = alloc(7), serial = counts.made }; } -struct Counts { made: i32, requested: i32 } +fn Tick(counts: &mut Counts) -> i32 { + if counts.phase != 1 { counts.errors = counts.errors + 1; } + counts.phase = 2; + counts.ticks = counts.ticks + 1; + return counts.made; +} -fn Make(calls: &mut Counts, limit: i32) -> Cursor { - calls.made = calls.made + 1; - calls.requested = calls.requested + limit; - return Cursor.{ value = 0, limit = limit, held = alloc(7) }; +// The owned argument uses ordinary parameter cleanup on present and none returns. +fn Produce(argument: Argument, tick: i32, cursor: &mut Cursor, offset: i32 = 0) -> ?i32 { + cursor.calls = cursor.calls + 1; + if argument.serial != tick { return 1000; } + if cursor.value >= cursor.limit { return none; } + let value = cursor.value; + cursor.value = cursor.value + 1; + return value + offset; } -fn First(calls: &mut Counts) -> i32 { - for item in Make(calls, 3) { return item + 10; } - return -1; +// Separate loop spellings are deliberate: compare normal calls against expansion. +fn Run(expanded: bool, mode: i32, limit: i32, counts: &mut Counts) -> i32 { + let mut cursor = Cursor.{ value = 0, limit = limit, calls = 0 }; + let mut total: i32 = 0; + if expanded { + for item in Produce(Make(counts), Tick(counts), &mut cursor) { + if item == 1000 || cursor.calls > 10 { return -100; } + let owned = alloc(item); + if mode == 1 { return item + 10; } + if mode == 2 { break; } + if item == 1 { continue; } + total = total + item; + } + } else { + for { + let result = Produce(Make(counts), Tick(counts), &mut cursor); + if result == none { break; } + let item: i32 = result; + if item == 1000 || cursor.calls > 10 { return -100; } + let owned = alloc(item); + if mode == 1 { return item + 10; } + if mode == 2 { break; } + if item == 1 { continue; } + total = total + item; + } + } + return total * 100 + cursor.calls; } -fn NestedReturn(calls: &mut Counts) -> i32 { - for left in Make(calls, 2) { - for right in Make(calls, 2) { return left + right; } +fn NestedReturn(counts: &mut Counts) -> i32 { + let mut outer = Cursor.{ value = 0, limit = 2, calls = 0 }; + for left in Produce(Make(counts), Tick(counts), &mut outer) { + let mut inner = Cursor.{ value = 0, limit = 3, calls = 0 }; + for right in Produce(Make(counts), Tick(counts), &mut inner) { + return left + right; + } } return -1; } fn main() -> i32 { - let mut calls = Counts.{ made = 0, requested = 0 }; - let mut total: i32 = 0; - for item in Make(&mut calls, 4) { - if calls.made != 1 { return 1; } - if item == 1 { continue; } - total = total + item; - } - if total != 5 || calls.made != 1 { return 2; } - - for item in Make(&mut calls, 0) { return 3; } - if calls.made != 2 { return 4; } - - for item in Make(&mut calls, 4) { break; } - if calls.made != 3 { return 5; } - if First(&mut calls) != 10 || calls.made != 4 { return 6; } - - let mut pairs: i32 = 0; - for left in Make(&mut calls, 2) { - for right in Make(&mut calls, 3) { - if right == 1 { continue; } - pairs = pairs + left + right; + for mode in 0..3 { + let mut expanded = Counts.{ made = 0, ticks = 0, phase = 0, errors = 0 }; + let mut explicit = Counts.{ made = 0, ticks = 0, phase = 0, errors = 0 }; + let actual = Run(true, mode, 4, &mut expanded); + let expected = Run(false, mode, 4, &mut explicit); + if actual != expected || actual < 0 { return 1; } + if expanded.made != explicit.made || expanded.ticks != explicit.ticks { return 2; } + if expanded.errors != 0 || explicit.errors != 0 || expanded.phase != 2 { return 3; } + if mode == 0 { + if actual != 505 || expanded.made != 5 || expanded.ticks != 5 { return 4; } + } else { + if expanded.made != 1 || expanded.ticks != 1 { return 5; } + if mode == 1 && actual != 10 { return 6; } + if mode == 2 && actual != 1 { return 7; } } } - if pairs != 6 || calls.made != 7 { return 7; } - if NestedReturn(&mut calls) != 0 || calls.made != 9 { return 8; } - - let mut literalTotal: i32 = 0; - for item in Cursor.{ value = 1, limit = 3, held = alloc(8) } { - literalTotal = literalTotal + item; - } - if literalTotal != 3 { return 9; } - - let mut local = Make(&mut calls, 2); - for item in local { break; } - if local.value != 1 { return 10; } - for item in local { total = total + item; } - if local.value != 2 || total != 6 || calls.made != 10 || calls.requested != 25 { return 11; } + let mut empty = Counts.{ made = 0, ticks = 0, phase = 0, errors = 0 }; + if Run(true, 0, 0, &mut empty) != 1 { return 8; } + if empty.made != 1 || empty.ticks != 1 || empty.errors != 0 { return 9; } + let mut nested = Counts.{ made = 0, ticks = 0, phase = 0, errors = 0 }; + if NestedReturn(&mut nested) != 0 { return 10; } + if nested.made != 2 || nested.ticks != 2 || nested.errors != 0 { return 11; } return 0; } diff --git a/x_test/runtime_iterator_general/src/main.peep b/x_test/runtime_iterator_general/src/main.peep index bea0fea9..a15a3330 100644 --- a/x_test/runtime_iterator_general/src/main.peep +++ b/x_test/runtime_iterator_general/src/main.peep @@ -10,7 +10,7 @@ struct Holder { cursor: Cursor, marker: i32 } fn Sum(cursor: &mut Cursor) -> i32 { let mut total: i32 = 0; - for item in cursor { total = total + item; } + for item in cursor.Next() { total = total + item; } return total; } @@ -31,21 +31,6 @@ fn (self: &mut OwnerCursor) Next() -> ?*i32 { return alloc(value); } -struct MaybeCursor { state: i32 } -fn (self: &mut MaybeCursor) Next() -> ? ?i32 { - if self.state == 0 { - self.state = 1; - let absent: ?i32 = none; - return absent; - } - if self.state == 1 { - self.state = 2; - let present: ?i32 = 7; - return present; - } - return none; -} - struct RefCursor { value: i32, done: bool } fn (self: &mut RefCursor) Next() -> ?&i32 from self { if self.done { return none; } @@ -56,7 +41,7 @@ fn (self: &mut RefCursor) Next() -> ?&i32 from self { fn main() -> i32 { let mut holder = Holder.{ cursor = Cursor.{ value = 0, limit = 3 }, marker = 9 }; let mut fieldTotal: i32 = 0; - for item in holder.cursor { fieldTotal = fieldTotal + item; } + for item in holder.cursor.Next() { fieldTotal = fieldTotal + item; } if fieldTotal != 3 || holder.cursor.value != 3 || holder.marker != 9 { return 1; } let mut cursors = [2]Cursor{ @@ -65,40 +50,31 @@ fn main() -> i32 { }; let mut selected: i32 = 0; let mut index: i32 = 0; - for item in cursors[index] { + for item in cursors[index].Next() { selected = selected + item; index = 1; } - if selected != 1 || cursors[0].value != 2 || cursors[1].value != 10 { return 2; } + if selected != 21 || cursors[0].value != 1 || cursors[1].value != 12 { return 2; } let mut parameter = Cursor.{ value = 2, limit = 5 }; if Sum(&mut parameter) != 9 || parameter.value != 5 { return 3; } let mut pairs = PairCursor.{ value = 0, limit = 2 }; let mut pairTotal: i32 = 0; - for pair in pairs { pairTotal = pairTotal + pair.left + pair.right; } + for pair in pairs.Next() { pairTotal = pairTotal + pair.left + pair.right; } if pairTotal != 22 { return 4; } let mut owners = OwnerCursor.{ value = 0, limit = 3 }; let mut ownerCount: i32 = 0; - for owned in owners { + for owned in owners.Next() { ownerCount = ownerCount + 1; free(owned); } if ownerCount != 3 { return 5; } - let mut maybes = MaybeCursor.{ state = 0 }; - let mut absent: i32 = 0; - let mut maybeTotal: i32 = 0; - for maybe in maybes { - if maybe == none { absent = absent + 1; } - else { maybeTotal = maybeTotal + maybe; } - } - if absent != 1 || maybeTotal != 7 { return 6; } - let mut refs = RefCursor.{ value = 42, done = false }; let mut referenced: i32 = 0; - for reference in refs { + for reference in refs.Next() { let item: &i32 = reference; referenced = referenced + 1; } diff --git a/x_test/runtime_optional_narrowing/src/main.peep b/x_test/runtime_optional_narrowing/src/main.peep index 39d4d1fe..f948bc4d 100644 --- a/x_test/runtime_optional_narrowing/src/main.peep +++ b/x_test/runtime_optional_narrowing/src/main.peep @@ -182,12 +182,11 @@ fn main() -> i32 { return 7; } let outer: ? ?i32 = inner; - if outer != none { - if outer != none { - if outer != 13 { - return 7; - } - } + if outer == none { + return 7; + } + if outer != 13 { + return 7; } let ownerResult = TakeOptional(Token.{value = 23}); diff --git a/x_test/runtime_optional_redundancy/peeper.toml b/x_test/runtime_optional_redundancy/peeper.toml new file mode 100644 index 00000000..5d197223 --- /dev/null +++ b/x_test/runtime_optional_redundancy/peeper.toml @@ -0,0 +1,6 @@ +name = "runtime_optional_redundancy" +build = "program" + +[test] +mode = "run" +outcome = "success" diff --git a/x_test/runtime_optional_redundancy/src/main.peep b/x_test/runtime_optional_redundancy/src/main.peep new file mode 100644 index 00000000..9fd9d55d --- /dev/null +++ b/x_test/runtime_optional_redundancy/src/main.peep @@ -0,0 +1,51 @@ +type Maybe = ?i32; +type Optional = ?T; + +type Early = Box; +struct Box { value: ?Later } +type Later = ?i32; + +fn Read(box: &Early) -> i32 { + if box.value == none { return 0; } + return box.value; +} + +type Linked = Node; +struct Node { link: ?Link } +type Link = ?*Node; + +fn TakeLink(link: ?Link) -> *Node { + if link == none { return alloc(Node.{ link = none }); } + return link; +} + +fn Some() -> ?i32 { return 42; } +fn Wrapped() -> ?Maybe { return Some(); } +fn Absent() -> ?Optional { return none; } + +fn main() -> i32 { + let double: ??i32 = Wrapped(); + if double == none { return 1; } + if double != 42 { return 2; } + + let triple: ???i32 = Some(); + if triple == none { return 3; } + if triple != 42 { return 4; } + + let spaced: ? ?i32 = Some(); + if spaced == none { return 5; } + if spaced != 42 { return 6; } + + let generic: Optional = Wrapped(); + if generic == none { return 7; } + if generic != 42 { return 8; } + + let absent: ??i32 = Absent(); + if absent != none { return 9; } + + let present = Early.{ value = 42 }; + if Read(&present) != 42 { return 10; } + let empty = Early.{ value = none }; + if Read(&empty) != 0 { return 11; } + return 0; +} diff --git a/x_test/runtime_structural_iterator/src/main.peep b/x_test/runtime_structural_iterator/src/main.peep index 54d0ef12..3934f035 100644 --- a/x_test/runtime_structural_iterator/src/main.peep +++ b/x_test/runtime_structural_iterator/src/main.peep @@ -4,7 +4,7 @@ struct Counter { calls: i32 } -fn (self: &mut Counter) Next() -> ?i32 { +fn Advance(self: &mut Counter) -> ?i32 { self.calls = self.calls + 1; if self.value >= self.limit { return none; } let value = self.value; @@ -17,40 +17,40 @@ fn (self: &Constant) Next() -> ?i32 { return 9; } fn First() -> i32 { let mut cursor = Counter.{ value = 4, limit = 8, calls = 0 }; - for item in cursor { return item; } + for item in Advance(&mut cursor) { return item; } return -1; } fn main() -> i32 { let mut cursor = Counter.{ value = 0, limit = 5, calls = 0 }; let mut total: i32 = 0; - for item in cursor { + for item in cursor |> Advance() { let owned = alloc(item); if item == 1 { continue; } total = total + item; if item == 2 { break; } } if total != 2 || cursor.value != 3 || cursor.calls != 3 { return 1; } - for item in cursor { total = total + item; } + for item in Advance(&mut cursor) { total = total + item; } if total != 9 || cursor.value != 5 || cursor.calls != 6 { return 2; } - for item in cursor { return 3; } + for item in Advance(&mut cursor) { return 3; } if cursor.calls != 7 { return 4; } let mut empty = Counter.{ value = 0, limit = 0, calls = 0 }; - for item in empty { return 5; } + for item in empty |> Advance() { return 5; } if empty.calls != 1 { return 6; } let mut outer = Counter.{ value = 0, limit = 3, calls = 0 }; let mut pairs: i32 = 0; - for left in outer { + for left in Advance(&mut outer) { let mut inner = Counter.{ value = 0, limit = 2, calls = 0 }; - for right in inner { pairs = pairs + left + right; } + for right in inner |> Advance() { pairs = pairs + left + right; } if inner.calls != 3 { return 7; } } if pairs != 9 || outer.calls != 4 { return 8; } let constant = Constant.{}; - for item in constant { + for item in constant.Next() { if item != 9 { return 9; } break; } From e34ed7031f9e988d43487edc6ffb5a4fa714f077 Mon Sep 17 00:00:00 2001 From: itsfuad Date: Fri, 11 Sep 2026 23:23:12 +0600 Subject: [PATCH 5/5] test(lsp): allow race-instrumented diagnostics to settle --- internal/lsp/server_test.go | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/internal/lsp/server_test.go b/internal/lsp/server_test.go index 8e326e40..5931eb5a 100644 --- a/internal/lsp/server_test.go +++ b/internal/lsp/server_test.go @@ -97,7 +97,8 @@ func runTimedLSPChanges(t *testing.T, root, filePath, initial string, changes [] }, ContentChanges: []TextDocumentContentChangeEvent{{Text: text}}, }) - time.Sleep(diagnosticsDebounceDelay + 25*time.Millisecond) + // Race-instrumented compilation can outlast the debounce interval. + time.Sleep(diagnosticsDebounceDelay + 250*time.Millisecond) } if err := inputWriter.Close(); err != nil { t.Fatalf("close LSP input: %v", err)