diff --git a/COMPILER_FRAMEWORK_REPORT.md b/COMPILER_FRAMEWORK_REPORT.md new file mode 100644 index 00000000..740f9408 --- /dev/null +++ b/COMPILER_FRAMEWORK_REPORT.md @@ -0,0 +1,156 @@ +# Compiler Framework Final Report + +## Problem + +Peeper previously repeated the same structural and semantic work in several +phases. A feature could compile while one resolver/type/flow/ownership/cleanup +path was forgotten, then fail much later. Fixing one missed path often created a +new mismatch because phases independently walked the same syntax or type shape. + +`ast.Inspect` already demonstrated the preferred solution: one package owns +structure; many consumers reuse it. + +## Final decision + +Framework is **not** a compiler-wide visitor pattern and **not** an effect stream +used for everything. Final design combines: + +- canonical structural kernels; +- phase-owned semantic evidence; +- generic graph/worklist mechanics; +- explicit exhaustive decisions only at genuine semantic extension points. + +Detailed contract: [`docs/compiler-architecture.md`](docs/compiler-architecture.md). + +## Implemented architecture + +This describes current source, including local correctness follow-up; it does not +assert that uncommitted work is released or that roadmap milestones are complete. + +### Semantic type structure + +`typeinfo.Type` is sealed by canonical child and ownership-composition methods. +`typeinfo.ForEachChild` exposes immediate semantic children with relation metadata. +Recursive containment and copy/drop capability queries reuse this structure. + +Result: adding a container around an existing subtype declares its relation once; +nested reference/ownership/drop behavior is derived instead of added to several +walks. + +### Shared graph topology + +`graph.Directed` owns ordered outgoing/incoming indexes. Existing graph users and +CFG use this kernel while CFG retains typed branch/case semantics. + +Result: adjacency/reverse-adjacency mechanics have one owner. CFG terminators and +ordered sites remain canonical topology; block/site indexes are derived, frozen by +consumer convention, and rebuilt together for topology changes. Validators inspect +all stored edges, including foreign components, and exact site-edge metadata. + +### Shared fixed-point scheduling + +`graph.Worklist` owns queueing, pending deduplication, and rescheduling. Flow, +definite-init, ownership, and liveness keep their own lattices and transfers. + +Result: repeated scheduler mechanics are centralized without creating a generic +pass framework that hides semantics. + +### Canonical places + +`place.Project` and `place.Decompose` own selector/index place grammar. Effect and +ownership code no longer maintain private selector/index peeling logic. + +### Semantic effects as downstream boundary + +Effects now carry enough identity for generic consumers: + +- definitions carry initializer identity; +- writes carry source owner/value identity; +- borrows carry exact borrowed operand identity; +- sequence loops publish `Iterate` with loop/place/carrier identity. + +Ownership consumes `Define` and `Write` directly instead of separate `let`, +`const`, and assignment statement handlers. `effect.Visitor` is the compile-time +consumer contract: introducing a genuinely new semantic operation breaks every +exhaustive consumer until it explicitly implements that operation. Sequence-loop borrow lifetime is +published as an effect instead of rediscovered from `ForStmt` and typechecker plan +shape. Definite initialization and ownership/liveness consume the same ordered +effect stream. + +### Bounded ownership provenance and lexical usage + +Ownership still captures accepted reference-bearing value shapes before effects +can move them, using existing loans, flow origins, reference types and semantic +variant constructions. Holder-relative loan paths distinguish stored slots from +borrowed origins and loan IDs. Exact projected direct/optional enum reference-field +replacement preserves siblings and copied holders; carrier-level liveness remains +conservative. This is not a general aggregate provenance framework, nor support for +nested stored-reference aggregates. New reference-bearing shapes require an audit. + +Usage warnings remain lexical: `semantics/usage` consumes symbol usage/mutability +flags from resolution, type/import lookup and typechecking, not reachable runtime +effects. Migrating them would require a separate warning-policy decision. + +### Contract cleanup + +Contracts that only verified duplicated downstream AST switches were removed. +Remaining contracts guard real closed extension points such as semantic type +identity/lowering/fingerprinting. Artifact validators remain primary guards for +cross-phase evidence shape/identity. They do not prove that a handled syntax case +published every required operation: producer ordering tests and executable source +fixtures cover semantic publication. Sealed type methods enforce presence, not +correct child enumeration. Empty effect artifacts can be valid. + +Leaf type traversal methods return directly; exported fingerprints use their +existing `Type` parameter without a redundant assertion. Bounded pointer reflection +in `isNilType` remains to preserve typed-nil capability answers. No nil-only +interface or exhaustive replacement dispatcher is introduced. + +### Go baseline + +Repository now targets Go 1.23.2. Post-1.23 convenience APIs used by tests/runtime +were replaced with equivalent 1.23 code; no compiler semantics required Go 1.26. + +## Extension result + +For a new syntax construct expressed using existing semantic actions, expected +work is concentrated in syntax-aware owners, provided existing provenance shapes +also suffice: + +```text +AST/parser -> resolver/typechecker as needed -> CFG/effects as needed -> HIR + | + v + existing definite-init/ownership/liveness/cleanup +``` + +For a new composite semantic type: + +```text +declare type -> child relations + ownership shape + | + v + generic containment/copy/drop propagation +``` + +Representation-specific decisions such as equality, ABI lowering, exported +fingerprint, or genuinely special sizing remain explicit by design. + +## Verification standard + +Final handoff requires: + +```text +GOTOOLCHAIN=local go test ./... +GOTOOLCHAIN=local go vet ./... +GOTOOLCHAIN=local go test -race +``` + +plus a fresh `go run ./scripts/bundle.go` followed by full `x_test` with explicit +absolute `PEEPER_BIN`. Run full tests, bundle and executable fixtures sequentially: +they share build artifacts. Without `PEEPER_BIN`, fixture execution is skipped. +See architecture verification commands for focused graph/project/pipeline races. + +Passing tests alone +is not enough; architecture audit must also confirm canonical kernels have not +been bypassed. diff --git a/COMPILER_GUIDELINES.md b/COMPILER_GUIDELINES.md index 7c3de41a..b478d67a 100644 --- a/COMPILER_GUIDELINES.md +++ b/COMPILER_GUIDELINES.md @@ -19,7 +19,12 @@ design, do not follow it silently. Report conflict and evidence so maintainer ca decide whether guideline or design must change. Current ownership, pointer, copy, and optional design lives in -[docs/ownership-pointer-model.md](docs/ownership-pointer-model.md). +[docs/ownership-pointer-model.md](docs/ownership-pointer-model.md). The implemented +compiler architecture, canonical structural mechanisms, semantic evidence boundaries, +and contributor extension rules live in +[docs/compiler-architecture.md](docs/compiler-architecture.md). Migration history and +supporting analysis live under +[docs/compiler-framework/](docs/compiler-framework/README.md). ## 1. Priorities @@ -140,6 +145,12 @@ fail clearly rather than be skipped silently. Semantic switches remain appropriate when each node kind requires distinct behavior. Centralize structural recursion, not semantic decisions. +Current canonical mechanisms are `ast.Inspect` for AST recursion, +`typeinfo.ForEachChild` for semantic-type structure, `place.Project`/`Decompose` +for storage projections, `graph.Directed` for topology, and `graph.Worklist` for +fixed-point scheduling. Analyses should extend these owners instead of creating a +parallel walk, adjacency store, or queue/queued-set implementation. + Before adding a walker or lookup: 1. search all existing implementations; diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index f941b907..b086c575 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -38,6 +38,12 @@ These files are canonical; do not copy their rules into new documents: - [`COMPILER_GUIDELINES.md`](COMPILER_GUIDELINES.md): compiler phase, representation, traversal, and incremental-analysis guidance. +For a change that touches the compiler pipeline or semantic model, read +[`docs/compiler-architecture.md`](docs/compiler-architecture.md) first. It defines +the canonical mechanisms, representation boundaries, and extension paths. +[`docs/compiler-framework/change-paths.md`](docs/compiler-framework/change-paths.md) +is the concrete file-by-file companion for common changes. + `AGENTS.md` contains automation workflow, not additional human-facing code policy. diff --git a/Code-tour.md b/Code-tour.md new file mode 100644 index 00000000..524b0989 --- /dev/null +++ b/Code-tour.md @@ -0,0 +1,632 @@ +# Code tour: from `.peep` to an executable + +A guided walk through the Peeper compiler, following one source file all the way to a +native binary. Read it top to bottom the first time; after that, jump to the phase you +need. + +Every code sample here is **simplified** — real signatures carry more parameters and more +error handling. Each one names the file it came from so you can read the real thing. + +Related reading: [`RULES.md`](RULES.md) for what code is acceptable, +[`COMPILER_GUIDELINES.md`](COMPILER_GUIDELINES.md) for phase discipline, +[`docs/compiler-architecture.md`](docs/compiler-architecture.md) for the canonical +architecture, and [`docs/compiler-framework/change-paths.md`](docs/compiler-framework/change-paths.md) +for the file-by-file walk when you are *changing* something rather than learning it. + +--- + +## 1. The thirty-second version + +```mermaid +flowchart LR + SRC[".peep source"] --> LEX[lexer] + LEX --> PAR[parser] + PAR --> AST[AST] + AST --> SEM[semantic analysis] + SEM --> CFG[control-flow graph] + CFG --> ANA[flow · effects · ownership] + ANA --> HIR[HIR] + HIR --> MIR[MIR] + MIR --> LL["LLVM IR text"] + LL --> CLANG["clang"] + CLANG --> OBJ["object files"] + OBJ --> LINK["linker"] + LINK --> EXE["executable"] +``` + +The compiler itself stops at **LLVM IR text**. Turning that into a binary is `clang`, +invoked as an external tool. Peeper does not link anything by hand. + +--- + +## 2. Package map + +| Package | What lives there | +| --- | --- | +| `cmd/` | CLI commands: `build`, `run`, `check`, `dump`, `doctor` | +| `internal/driver` | Thin wrapper that builds a `CompilerContext` and compiles one file | +| `internal/pipeline` | Module loading and the phase ladder that drives everything | +| `internal/project` | `Module`, `CompilerContext` — where every phase artifact is stored | +| `internal/frontend` | `token`, `lexer`, `parser`, `ast` | +| `internal/semantics` | Collector, binder, resolver, const eval, typechecker, effects, definite init, ownership, usage, plus the artifact packages | +| `internal/ir` | `cfg`, `hir`, `mir`, and the shared `ir` node/type model | +| `internal/backend/llvm` | MIR → LLVM IR text | +| `internal/contracts` | Test-tier contracts that force a decision for every node kind | +| `internal/toolchain` | Finds `clang` and the sysroot; builds its command lines | +| `internal/diagnostics` | Errors, warnings, source rendering, phase attribution | +| `runtime/` | `peeper_rt.c` — the C runtime linked into every binary | +| `_builtin_library/core` | The `core` standard library, written in Peeper | + +--- + +## 3. Entry: command → driver → pipeline + +```mermaid +sequenceDiagram + participant U as user + participant C as cmd/build.go + participant D as driver + participant P as pipeline + participant T as toolchain + + U->>C: peeper build main.peep + C->>D: CompileFile(ctx, path) + D->>P: pipeline.Run(ctx, entry) + P-->>D: every module at phase.Backend + D-->>C: modules carrying LLVMIR + C->>T: Resolve(clang, sysroot) + C->>T: clang -c mod_0.ll -o mod_0.o + C->>T: clang @objects.rsp -o app + C-->>U: executable +``` + +`driver.CompileFile` is deliberately small — it exists so the CLI, the LSP and the tests +all enter the compiler the same way. + +--- + +## 4. Module loading + +Before any phase runs, the loader walks imports and builds a dependency graph. + +```go +// internal/pipeline/loader.go (simplified) +func (l *moduleLoader) Load(entry *project.Module) error { + l.enqueue(entry) + for len(l.queue) > 0 { + module := l.pop() + l.loadModule(module) // read the file, lex, parse + l.resolveImports(module) // each import becomes a graph edge + a queued module + } + return nil +} +``` + +Modules are identified by `moduleid.ID` — origin, namespace, dependency and import path — +never by file path, so a module keeps its identity if the project moves on disk. + +The dependency graph is the shared `internal/graph` package. `pipeline.Run` topologically +sorts it so a module is only advanced once everything it imports has reached the phase it +needs. + +```mermaid +flowchart TD + E["entry: main.peep"] --> A["import core/io"] + E --> B["import ./util"] + B --> C["import core/mem"] + P["prelude"]:::pre + E -.always depends on.-> P + A -.-> P + B -.-> P + classDef pre fill:#eef,stroke:#88a +``` + +Every module is given an edge to the **prelude**, which is how the prelude is guaranteed +to be first in topological order without any special case in the scheduler. + +--- + +## 5. The phase ladder + +This is the spine of the compiler. Each module carries a `Phase`, and +`advanceModulePhase` moves it forward **exactly one step per call**. + +```mermaid +flowchart TD + Setup --> Load --> Parsed --> Collected --> Bound --> Resolved + Resolved --> ConstEval --> Typechecked --> CFG --> FlowTyped + FlowTyped --> Effects --> DefiniteInit --> Ownership --> Usage + Usage --> HIR --> MIR --> Backend --> Finalize +``` + +```go +// internal/pipeline/pipeline.go (heavily simplified) +func advanceModulePhase(ctx *project.CompilerContext, module *project.Module) bool { + if module.Phase < phase.Collected { collector.Collect(ctx, module); module.Phase = phase.Collected; return true } + if module.Phase < phase.Bound { binder.Bind(ctx, module); module.Phase = phase.Bound; return true } + if module.Phase < phase.Resolved { resolver.Resolve(ctx, module); module.Phase = phase.Resolved; return true } + // ... one block per phase, in order ... +} +``` + +Why one phase per call? Because modules advance **in lockstep across the whole project**. +A module cannot be typechecked until every module it imports is typechecked, and the +scheduler enforces that by advancing everyone one rung at a time. + +### What each phase produces + +| Phase | Produces | Stored on `Module` as | +| --- | --- | --- | +| `Parsed` | syntax tree | `AST` | +| `Collected` | top-level symbols, method sets | `Bindings`, `ModuleScope` | +| `Bound` | operator/interface bindings | `Bindings` | +| `Resolved` | every identifier → symbol | `Bindings.NodeSymbols` | +| `ConstEval` | compile-time constants | `Constants` | +| `Typechecked` | types and typing decisions | `Typechecking`, `TypedASTNodes` | +| `CFG` | blocks, sites, edges | `CFG` | +| `FlowTyped` | per-use narrowing | `Flow` | +| `Effects` | ordered semantic effects | `Effects` | +| `DefiniteInit` | *diagnostics only* | — | +| `Ownership` | drop plan | `Ownership` | +| `Usage` | *warnings only* | — | +| `HIR` | typed high-level IR | `HIR` | +| `MIR` | flat, block-structured IR | `MIR` | +| `Backend` | LLVM IR text | `LLVMIR` | + +**Phase artifacts are the central design idea.** Each fact is produced by exactly one +phase, stored in exactly one place, and read by later phases. No phase reaches backwards +to recompute something an earlier one already decided. + +`Module.resetToPhase` clears artifacts in phase order, so incremental rebuilds cannot +leave stale evidence behind: + +```go +// internal/project/modules.go (simplified) +func (m *Module) resetToPhase(retained phase.Phase) { + if retained < phase.Typechecked { m.Typechecking = nil; m.TypedASTNodes = nil } + if retained < phase.CFG { m.CFG = nil } + if retained < phase.Effects { m.Effects = nil } + if retained < phase.Ownership { m.Ownership = nil } + // ... +} +``` + +--- + +## 6. Front end: text → AST + +```go +// internal/frontend — the whole front end, in three lines +tokens := lexer.New(path, source, diag).Tokenize() +module := parser.New(path, tokens, diag).ParseModule() +``` + +The parser is recursive descent and **error-recovering**: on a syntax error it emits a +diagnostic and produces a `BadStmt` / `BadExpr` node rather than bailing out. Later phases +treat recovery nodes as inert, which is why the LSP can still offer completion in a file +that does not parse cleanly. + +AST nodes carry: + +- a **`NodeID`** — stable identity used as the key for every later fact about that node; +- a **`Location`** — for diagnostics; +- `forEachChild` — the one canonical child walk. + +```go +// internal/frontend/ast (simplified) +type ForStmt struct { + Index, Value *Ident + Iterable Expr + Cond Expr + Body *BlockStmt +} + +func (s *ForStmt) forEachChild(visit func(Node)) { + visit(s.Index); visit(s.Value); visit(s.Iterable); visit(s.Cond); visit(s.Body) +} +``` + +Forgetting a field in `forEachChild` makes it invisible to `ast.Inspect`. A contract test +parses this package and fails naming the field you missed — see §13. + +--- + +## 7. Semantic analysis + +```mermaid +flowchart LR + C["collector
declare top-level names"] --> B["binder
operators, interfaces"] + B --> R["resolver
ident → symbol, scopes"] + R --> K["const eval
compile-time values"] + K --> T["typechecker
types + decisions"] +``` + +**Collector** walks top-level declarations and puts them in `ModuleScope`, plus builds +method sets. **Binder** wires up operator functions and interface members. **Resolver** +creates block scopes and maps every referencing identifier to its symbol: + +```go +// internal/semantics/resolver (simplified) +module.Bindings.NodeSymbols[ident.ID()] = symbol +module.Bindings.BlockScopes[block.ID()] = scope +``` + +> **Gotcha worth knowing.** `NodeSymbols` indexes *references*, not definitions. A +> declaration name and an assignment target are resolved through the block scope instead +> (`scope.LookupNode`, `scope.Lookup`). Two mechanisms, deliberately. + +**The typechecker** does more than check types — it *publishes decisions* later phases +depend on, so nothing has to re-derive them: + +```go +// internal/semantics/typecheckresult/result.go (excerpt) +type Result struct { + ExprTypes map[ast.NodeID]typeinfo.Type // the type of each expression + ValueUses map[ast.NodeID]typeinfo.UseKind // read / copy / move + ReferenceArguments map[ast.NodeID]bool // borrows; value = mutable + ImplicitConversions map[ast.NodeID]typeinfo.Conversion + ImplicitCallArguments map[ast.NodeID]typeinfo.Type // receiver/pipe adaptation + Matches map[ast.NodeID]Match // resolved case evidence + ForIterations map[ast.NodeID]ForIteration // loop lowering plan + // ... +} +``` + +--- + +## 8. Control-flow graph + +CFG turns structured syntax into blocks, **sites** and typed edges. + +```mermaid +flowchart TD + subgraph entry [b0] + S0["site 0: let x = 1"] + S1["site 1: terminator (if x > 0)"] + end + entry -->|EdgeTrue| then["b1: then"] + entry -->|EdgeFalse| els["b2: else"] + then -->|EdgeNormal| join["b3: join"] + els -->|EdgeNormal| join +``` + +A **site** is one ordered program point inside a block: + +```go +// internal/ir/cfg/model.go (simplified) +type SiteID struct{ Block, Index int } // dense and positional + +type Site struct { + ID SiteID + Kind SiteKind // statement | scope exit | terminator | join + NodeID ir.NodeID // the AST node this point stands for + ScopeID ir.NodeID + Successors, Predecessors []Edge +} +``` + +Edges keep their meaning — `EdgeTrue`, `EdgeFalse`, `EdgeVariantCase` with a case index — +so consumers never guess control flow from adjacency order. + +CFG does not import the typechecker. It asks for the two facts it needs through narrow +function types, which the typechecker result happens to satisfy: + +```go +// internal/ir/cfg/build.go +type BuildQueries struct { + MatchCases func(ast.NodeID) ([]int, bool) + LoopGuaranteedEntry func(ast.NodeID) bool +} +``` + +`cfg.Module.Validate()` then checks the topology it produced — block identity, termination, +adjacency in both directions, reachability — and raises an internal-compiler-error +diagnostic rather than a user-facing one, because malformed topology is a compiler bug. + +--- + +## 9. Effects: the semantic operation stream + +This is the newest layer, and the one that makes later analyses construct-agnostic. + +**The problem it solves:** definite initialization and ownership each used to walk the AST +themselves, re-deriving what every construct did to a binding. Two walks that had to agree +— and sometimes did not. + +One producer now translates each CFG site into ordered operations: + +```go +// internal/semantics/effect/model.go (simplified) +type Op interface{ effectOp() } // sealed set + +type Place struct { + Root *symbols.Symbol // the binding … + Temporary ast.NodeID // … or the expression, for a value owning nothing + Projections []place.OriginProjection // .field, [index] +} + +type Define struct{ Symbol *symbols.Symbol; Initialized, OnEntry bool } +type Write struct{ Place Place } +type Use struct{ Place Place; Kind typeinfo.UseKind } +type Borrow struct{ Place Place; Mutable, Argument, Raw bool } +type Discard struct{ Place Place } +type CallBegin struct{ Node ast.NodeID } +type CallEnd struct{ Node ast.NodeID } +``` + +The producer is the only code that reads syntax to decide meaning: + +```go +// internal/semantics/effect/build.go (simplified) +func (b *builder) value(site cfg.SiteID, expr ast.Expr, kind typeinfo.UseKind) { + switch node := expr.(type) { + case *ast.Ident: + if sym := b.queries.Symbols[node.ID()]; sym != nil { + b.emit(site, Use{Place: Place{Root: sym}, Kind: kind}) + } + case *ast.BinaryExpr: + if b.queries.StringConcatenation(node.ID()) { // decided by the typechecker + b.value(site, node.Left, typeinfo.UseMove) // concat consumes its left + b.value(site, node.Right, typeinfo.UseRead) + return + } + b.value(site, node.Left, typeinfo.UseRead) + b.value(site, node.Right, typeinfo.UseRead) + case *ast.CallExpr: + b.emit(site, CallBegin{Node: node.ID()}) + b.value(site, node.Callee, typeinfo.UseRead) + for _, arg := range b.queries.CallArguments(node) { + b.argument(site, arg) // borrows if the parameter is a reference + } + b.emit(site, CallEnd{Node: node.ID()}) + // … one case per expression kind, then: default: panic(…) + } +} +``` + +Three subtleties that are easy to get wrong, all learned the hard way: + +- **`Define.OnEntry`** — a parameter, or a match payload binding, exists *before* its site + runs. It is created by the edge into the site, not by the site. Liveness must not treat + that as a definition within the site or it ends a borrow one step early. +- **`CallBegin`/`CallEnd`** — a call is a *lifetime*, not a position. A temporary created + while computing an argument lives until the call completes. A flat list of uses has + nowhere to hang that. +- **`Place.Temporary`** — `f().field` projects out of a value that lives in no binding. + Ownership treats that differently, so the vocabulary has to be able to say it. + +--- + +## 10. The analyses that consume it + +```mermaid +flowchart LR + E["Effects"] --> DI["definite init
is it initialized?"] + E --> OW["ownership
moves, borrows, drops"] + CFG["CFG"] --> DI + CFG --> OW + OW --> CP["CleanupPlan"] +``` + +They share **evidence, not machinery**. Each keeps its own lattice, join direction and +diagnostics — `COMPILER_GUIDELINES.md` §6 explicitly forbids extracting a shared solver. + +**Definite initialization** is a must-analysis: a symbol is initialized only if it is +initialized on *every* path, so the join is intersection. + +```go +// internal/semantics/definiteinit (simplified) +func apply(current state, op effect.Op) { + switch op := op.(type) { + case effect.Define: if op.Initialized { current[op.Symbol.ID] = struct{}{} } + case effect.Write: current[op.Place.Root.ID] = struct{}{} + case effect.Use: // a read changes nothing + case effect.Borrow: // nor does taking a reference + default: panic("unhandled effect") // a new op fails loudly here + } +} +``` + +It contains **no AST switch at all** and does not import `ast` beyond `NodeID`. + +**Ownership** tracks moves, loans and liveness, then writes the drop plan: + +```go +// internal/semantics/ownership/effects.go (simplified) +for _, op := range a.effects[site.ID] { + switch op := op.(type) { + case effect.CallBegin: + calls = append(calls, callFrame{ // remember where this call's loans start + call: callFor(op), temporary: len(loans.temporary), reserved: len(loans.reserved), + }) + case effect.CallEnd: + frame := pop(&calls) + a.activateCallReservations(frame.call, frame.reserved, loans) // fire at call start + loans.temporary = loans.temporary[:frame.temporary] // argument temporaries die + loans.reserved = loans.reserved[:frame.reserved] + case effect.Use: + a.applyUse(op, st, loans) // move state + storage access + case effect.Borrow: + a.applyBorrow(op, st, loans, calls) // access + loan + } +} +``` + +The result is the `CleanupPlan` — the **single source of drop obligations** over source +values: + +```go +// internal/semantics/ownershipresult/result.go +type CleanupPlan struct { + AfterScope map[cfg.SiteID][]symbols.SymbolID // scope exit + BeforeReturn map[ir.NodeID][]symbols.SymbolID // after the value is computed + BeforeAssign map[ir.NodeID]struct{} // replacing a value drops the old + DiscardedValue map[ir.NodeID]struct{} // a temporary nobody owns + // … +} +``` + +Lowering *reads* this plan. It never decides a drop for itself. + +--- + +## 11. Lowering: HIR → MIR + +**HIR** is typed and still structured — `If`, `For`, `Block` are real nodes. It consumes +published evidence rather than re-deciding anything: + +```go +// internal/ir/hir/lower (simplified) +conversion, converting := module.Typechecking.ImplicitConversions[expr.ID()] +iteration := module.Typechecking.ForIterations[stmt.ID()] // carrier, cursor, bounds +``` + +**MIR** is flat: basic blocks, instructions, terminators — close to what a backend wants. + +```go +// internal/ir/mir/model.go (simplified) +type Instr interface{ instrNode() } // Assign Store Print Drop DynamicArrayOp Call InterfaceCall +type Terminator interface{ termNode() } // Jump Branch SwitchVariant Ret +``` + +Both sets are **sealed** by unexported marker methods, so an instruction can never be used +where a terminator belongs. MIR lowering walks CFG sites and consumes the cleanup plan to +place drops. + +```mermaid +flowchart LR + A["AST
structured, untyped"] --> H["HIR
structured, typed"] + H --> M["MIR
flat blocks + terminators"] + M --> L["LLVM IR text"] +``` + +--- + +## 12. Backend and linking + +```go +// internal/backend/llvm/emitter.go (simplified) +func GenerateLLVMIR(mod *mir.Module, …) string { + for _, fn := range mod.Funcs { + for _, block := range fn.Blocks { + for _, instr := range block.Instrs { + switch typed := instr.(type) { + case *mir.Assign: emitValue(lb, typed.Value) + case *mir.Drop: emitDrop(lb, typed) + // … + } + } + switch term := block.Term.(type) { + case *mir.Jump: lb.branch(target) + case *mir.Branch: lb.condBranch(cond, then, els) + case *mir.Ret: lb.ret(value) + } + } + } + return b.String() +} +``` + +The emitter produces **LLVM IR text**, not bitcode. Then `cmd/build.go` shells out: + +```go +// cmd/build.go (simplified) +for i, module := range modules { + os.WriteFile(fmt.Sprintf("mod_%d.ll", i), []byte(module.LLVMIR), 0o644) + runCompilerTool(profile.ClangPath, profile.ObjectArgs(llPath, objectPath, debug)) +} +profile.WriteResponseFile(responsePath, objectPaths) +runCompilerTool(profile.LinkerPath, profile.LinkArgs(responsePath, stagedPath)) +``` + +`internal/toolchain` finds a *managed* clang and sysroot if one is installed, and falls +back to `clang` on `PATH` with a warning. The C runtime in `runtime/peeper_rt.c` is linked +in to provide allocation and printing. + +Object files go through a **response file** rather than a long command line, which keeps +the link working on platforms with tight argument limits. + +--- + +## 13. Guardrails + +The compiler is built so that *forgetting* something fails loudly. + +```mermaid +flowchart TD + N["you add an AST node kind"] --> C1["contracts: 8 statement sites
fail by name"] + T["you add a typeinfo.Type"] --> C2["contracts: capability, identity,
lowering fail by name"] + I["you add a mir.Instr"] --> C3["contracts: lowering and backend
fail by name"] + A["a phase publishes evidence"] --> V["validators check its shape
at the phase boundary"] +``` + +| Guard | Where | Catches | +| --- | --- | --- | +| Child traversal contract | `internal/contracts` | a node field missing from `forEachChild` | +| Syntax-boundary dispatch contract | `internal/contracts` | a new node kind omitted by a true syntax-aware owner | +| Semantic type contract | Go type system + `internal/contracts` | missing child/ownership structure or a required representation decision | +| Lowered node contract | `internal/contracts` | an HIR/MIR kind nothing lowers or emits | +| `cfg.Validate` | `internal/ir/cfg` | malformed topology | +| `effect.Validate` | `internal/semantics/effect` | operations with no symbol, unbalanced calls | +| `ownershipresult.Validate` | `internal/semantics/ownershipresult` | evidence that contradicts published types | + +A contract failure reads like this: + +``` +publishStmt makes no decision about ast.YieldStmt; add a case or declare why the kind is inert +``` + +At true closed extension points, every omission must be handled or deliberately +classified. Generic downstream analyses should not grow AST classifications at all; +they consume canonical CFG/type/place/effect evidence instead. + +--- + +## 14. Adding a language feature + +For a new syntax construct, in order: + +1. **token** — a keyword or token kind, if the syntax needs one. +2. **AST node** — the struct, its family marker, and `forEachChild`. +3. **parser** — build the node, with recovery. +4. **resolver** — scopes and bindings. +5. **typechecker** — the type rule, and *publish* whatever later phases will need. +6. **CFG** — only if the control-flow shape is genuinely new. +7. **effects** — one case in `publishStmt`/`value` saying what it does to bindings. +8. **HIR/MIR** — only if no existing lowering shape can represent it. + +Steps 1–6 are unavoidable: where a name lives and what types are legal *is* the feature. +Step 7 is what buys you definite initialization, ownership, liveness, drops and usage +**for free** — they consume operations and never learn your construct exists. + +`docs/compiler-architecture.md` defines why these boundaries exist; +`docs/compiler-framework/change-paths.md` gives the concrete edit path and the guard +at each true extension point. + +--- + +## 15. Glossary + +| Term | Meaning | +| --- | --- | +| **NodeID** | Stable identity of one AST node; the key for every fact about it | +| **SymbolID** | Stable identity of one declaration | +| **SiteID** | `{Block, Index}` — one ordered program point in a CFG | +| **Place** | Storage: a root binding (or a temporary) plus projections | +| **Artifact** | A phase's published output, owned by exactly one phase | +| **Prelude** | Implicitly imported module every other module depends on | +| **Contract** | A test that forces an explicit decision for every node kind | +| **Validator** | A boundary check on an artifact's shape, reported as a compiler bug | + +--- + +## 16. Where to look when + +| Question | Start here | +| --- | --- | +| Why is my program rejected? | `internal/diagnostics/codes.go`, then the phase that owns the code | +| How does phase ordering work? | `internal/pipeline/pipeline.go`, `advanceModulePhase` | +| What does the typechecker publish? | `internal/semantics/typecheckresult/result.go` | +| Why is a value moved/dropped here? | `internal/semantics/effect`, then `internal/semantics/ownership` | +| What does the backend emit for X? | `internal/backend/llvm/emitter.go` | +| How do I add a node kind safely? | `docs/compiler-architecture.md`, then the owning syntax boundary | diff --git a/README.md b/README.md index 90b3479c..e1b94293 100644 --- a/README.md +++ b/README.md @@ -18,6 +18,15 @@ server, and executable source fixtures. See the [open roadmap](https://github.com/PeeperLanguage/compiler/issues) for unfinished language and runtime work. +## Learning the compiler + +[`Code-tour.md`](Code-tour.md) walks one `.peep` file through every phase to a native +binary, with diagrams and the real entry points at each stop. + +[`docs/compiler-architecture.md`](docs/compiler-architecture.md) explains the canonical +compiler mechanisms and how to extend syntax, semantic types, graphs, and analyses +without reintroducing duplicated walks or phase logic. + ## Binary installation Linux and macOS: diff --git a/cmd/build.go b/cmd/build.go index e3a13622..bc9601c4 100644 --- a/cmd/build.go +++ b/cmd/build.go @@ -82,14 +82,14 @@ func buildExecutable(ctx *project.CompilerContext, entry *project.Module, output } ir := strings.TrimSpace(module.LLVMIR) if ir == "" { - return fmt.Errorf("empty LLVM IR for module %s", module.ImportPath) + return fmt.Errorf("empty LLVM IR for module %s", module.ID.ImportPath) } llPath := filepath.Join(artifactDir, fmt.Sprintf("mod_%d.ll", i)) if err := os.WriteFile(llPath, []byte(ir), 0o644); err != nil { return fmt.Errorf("write llvm ir: %w", err) } objectPath := filepath.Join(artifactDir, fmt.Sprintf("mod_%d.o", i)) - if err := runCompilerTool(profile.ClangPath, profile.ObjectArgs(llPath, objectPath, ctx.Config.BuildDebug), "compile LLVM module "+module.ImportPath); err != nil { + if err := runCompilerTool(profile.ClangPath, profile.ObjectArgs(llPath, objectPath, ctx.Config.BuildDebug), "compile LLVM module "+module.ID.ImportPath); err != nil { return err } objectPaths = append(objectPaths, objectPath) diff --git a/cmd/cli/get_test.go b/cmd/cli/get_test.go index 56584783..c100eec4 100644 --- a/cmd/cli/get_test.go +++ b/cmd/cli/get_test.go @@ -102,7 +102,7 @@ mock_path = "./mock" mustWriteGetTest(t, filepath.Join(mockPackage, "src", "pkg.peep"), "original") mustWriteGetTest(t, filepath.Join(cachePackage, manifest.FileName), "name = \"stale\"\nbuild = \"lib\"\n") mustWriteGetTest(t, filepath.Join(cachePackage, "src", "pkg.peep"), "unlocked-cache") - t.Chdir(root) + chdirForTest(t, root) if err := installAllDependencies(); err != nil { t.Fatal(err) @@ -188,7 +188,7 @@ build = "lib" child = "github.com/acme/child" `) mustWriteGetTest(t, filepath.Join(root, "mock", "acme", "child-v1.0.0", manifest.FileName), "name = \"child\"\nbuild = \"lib\"\n") - t.Chdir(root) + chdirForTest(t, root) if err := installAllDependencies(); err != nil { t.Fatal(err) @@ -251,7 +251,7 @@ mock_path = "./mock" if err != nil { t.Fatal(err) } - t.Chdir(root) + chdirForTest(t, root) err = installAllDependencies() if test.wantError { diff --git a/cmd/cli/init_test.go b/cmd/cli/init_test.go index a661afa4..8f58c7b4 100644 --- a/cmd/cli/init_test.go +++ b/cmd/cli/init_test.go @@ -14,7 +14,7 @@ func TestInitCommandRejectsInvalidNamesWithoutArtifacts(t *testing.T) { for _, name := range []string{"1app", "bad/name", "_app"} { t.Run(name, func(t *testing.T) { root := t.TempDir() - t.Chdir(root) + chdirForTest(t, root) if err := InitCommand([]string{name}); err == nil { t.Fatalf("InitCommand(%q) succeeded", name) } @@ -67,7 +67,7 @@ func TestInitCommandPreflightsPathConflictsBeforeWriting(t *testing.T) { for _, test := range tests { t.Run(test.name, func(t *testing.T) { root := t.TempDir() - t.Chdir(root) + chdirForTest(t, root) test.setup(t) if err := InitCommand([]string{"app"}); err == nil { t.Fatal("InitCommand succeeded") @@ -81,7 +81,7 @@ func TestInitCommandPreflightsPathConflictsBeforeWriting(t *testing.T) { func TestInitCommandPreservesExistingRegularMain(t *testing.T) { root := t.TempDir() - t.Chdir(root) + chdirForTest(t, root) if err := os.Mkdir(peeper.SourceDirName, 0o755); err != nil { t.Fatal(err) } @@ -112,7 +112,7 @@ func TestInitCommandPreservesExistingRegularMain(t *testing.T) { func TestInitCommandNormalizesWhitespace(t *testing.T) { root := t.TempDir() - t.Chdir(root) + chdirForTest(t, root) if err := InitCommand([]string{"Hello Peeper"}); err != nil { t.Fatalf("InitCommand: %v", err) } diff --git a/cmd/cli/list_test.go b/cmd/cli/list_test.go index ba93770f..33fc4216 100644 --- a/cmd/cli/list_test.go +++ b/cmd/cli/list_test.go @@ -17,7 +17,7 @@ func TestListCommandPropagatesMalformedLockfile(t *testing.T) { t.Fatal(err) } - t.Chdir(root) + chdirForTest(t, root) if err := ListCommand(nil); err == nil { t.Fatal("ListCommand ignored malformed lockfile") @@ -29,7 +29,7 @@ func TestListCommandAllowsMissingLockfile(t *testing.T) { if err := os.WriteFile(filepath.Join(root, manifest.FileName), []byte("name = \"app\"\nbuild = \"program\"\n"), 0o644); err != nil { t.Fatal(err) } - t.Chdir(root) + chdirForTest(t, root) if err := ListCommand(nil); err != nil { t.Fatalf("ListCommand with no lockfile: %v", err) diff --git a/cmd/cli/test_helpers_test.go b/cmd/cli/test_helpers_test.go new file mode 100644 index 00000000..9fb39382 --- /dev/null +++ b/cmd/cli/test_helpers_test.go @@ -0,0 +1,25 @@ +package cli + +import ( + "os" + "testing" +) + +// chdirForTest is the Go 1.23 equivalent of testing.T.Chdir. Keep working +// directory mutation behind one helper so tests restore process state even +// when they fail. +func chdirForTest(t *testing.T, dir string) { + t.Helper() + previous, err := os.Getwd() + if err != nil { + t.Fatalf("get working directory: %v", err) + } + if err := os.Chdir(dir); err != nil { + t.Fatalf("chdir %s: %v", dir, err) + } + t.Cleanup(func() { + if err := os.Chdir(previous); err != nil { + t.Errorf("restore working directory %s: %v", previous, err) + } + }) +} diff --git a/cmd/dispatch.go b/cmd/dispatch.go index beafe62e..b70b5999 100644 --- a/cmd/dispatch.go +++ b/cmd/dispatch.go @@ -36,7 +36,8 @@ func exitOnCommandError(err error) { if errors.Is(err, errAlreadyReported) { os.Exit(exitCodeError) } - if status, ok := errors.AsType[programExitStatus](err); ok { + var status programExitStatus + if errors.As(err, &status) { os.Exit(int(status)) } colors.RED.Fprintln(os.Stderr, err) diff --git a/cmd/dump.go b/cmd/dump.go index 4a991086..6c3b0cf2 100644 --- a/cmd/dump.go +++ b/cmd/dump.go @@ -4,6 +4,7 @@ import ( "fmt" "os" "path/filepath" + "slices" "strings" "compiler/internal/project" @@ -60,20 +61,58 @@ func saveIRs(ctx *project.CompilerContext, dir string) error { return replacePath(stage, target) } +// emptyIdentityComponent marks an absent namespace or dependency so every +// canonical component occupies its own path segment. Dropping empty components +// instead would let distinct identities share one artifact path, for example +// namespace "a" with import path "b/c" against no namespace with "a/b/c". +const emptyIdentityComponent = "_" + func moduleArtifactBase(stage string, module *project.Module) (string, error) { - origin := string(module.Origin) + origin := module.ID.Origin if origin == "" { origin = string(project.ModuleOriginLocal) } - identity := strings.TrimSpace(module.ImportPath) + identity := module.ID.ImportPath if identity == "" { return "", fmt.Errorf("module %q has no import identity", module.FilePath) } - identity = filepath.Clean(filepath.FromSlash(strings.ReplaceAll(identity, ":", "/"))) - if identity == "." || filepath.IsAbs(identity) || identity == ".." || strings.HasPrefix(identity, ".."+string(filepath.Separator)) { - return "", fmt.Errorf("invalid module import identity %q", module.ImportPath) + segments := strings.Split(identity, "/") + if slices.Contains(segments, "") { + return "", fmt.Errorf("invalid module import identity %q", identity) + } + // Every canonical identity component participates, so two identities that + // differ only by namespace or dependency cannot write the same artifacts. + parts := make([]string, 0, len(segments)+4) + parts = append(parts, stage, + identityComponent(origin), + identityComponent(module.ID.Namespace), + identityComponent(module.ID.Dependency)) + for _, segment := range segments { + parts = append(parts, identityComponent(segment)) + } + return filepath.Join(parts...), nil +} + +func identityComponent(value string) string { + if value == "" { + return emptyIdentityComponent + } + var encoded strings.Builder + for i := 0; i < len(value); i++ { + c := value[i] + // Escape a leading '.' so "." and ".." cannot form dot segments, and + // escape a lone "_" so it cannot collide with the empty marker. + if i == 0 && (c == '.' || (c == '_' && len(value) == 1)) { + fmt.Fprintf(&encoded, "%%%02X", c) + continue + } + if (c >= 'a' && c <= 'z') || (c >= 'A' && c <= 'Z') || (c >= '0' && c <= '9') || c == '-' || c == '_' || c == '.' { + encoded.WriteByte(c) + continue + } + fmt.Fprintf(&encoded, "%%%02X", c) } - return filepath.Join(stage, origin, identity), nil + return encoded.String() } func replacePath(stage, target string) error { diff --git a/cmd/dump_test.go b/cmd/dump_test.go index a94b22de..fdef9715 100644 --- a/cmd/dump_test.go +++ b/cmd/dump_test.go @@ -3,15 +3,17 @@ package main import ( "os" "path/filepath" + "strings" "testing" + "compiler/internal/moduleid" "compiler/internal/project" ) func TestSaveIRsKeepsSameBasenameModulesDistinctAndReplacesOldTree(t *testing.T) { ctx := project.NewWithConfig(project.Config{RootDir: t.TempDir()}, nil) - ctx.AddModule(&project.Module{Key: "one", FilePath: "/one/common.peep", ImportPath: "app/one/common", Origin: project.ModuleOriginLocal, LLVMIR: "one"}) - ctx.AddModule(&project.Module{Key: "two", FilePath: "/two/common.peep", ImportPath: "app/two/common", Origin: project.ModuleOriginLocal, LLVMIR: "two"}) + ctx.AddModule(&project.Module{ID: moduleid.ID{Origin: string(project.ModuleOriginLocal), ImportPath: "app/one/common"}, FilePath: "/one/common.peep", LLVMIR: "one"}) + ctx.AddModule(&project.Module{ID: moduleid.ID{Origin: string(project.ModuleOriginLocal), ImportPath: "app/two/common"}, FilePath: "/two/common.peep", LLVMIR: "two"}) target := filepath.Join(t.TempDir(), "_gen") if err := os.MkdirAll(target, 0o755); err != nil { t.Fatal(err) @@ -24,8 +26,8 @@ func TestSaveIRsKeepsSameBasenameModulesDistinctAndReplacesOldTree(t *testing.T) t.Fatalf("saveIRs: %v", err) } for _, artifact := range []string{ - filepath.Join("local", "app", "one", "common.ll"), - filepath.Join("local", "app", "two", "common.ll"), + filepath.Join("local", "_", "_", "app", "one", "common.ll"), + filepath.Join("local", "_", "_", "app", "two", "common.ll"), } { if _, err := os.Stat(filepath.Join(target, artifact)); err != nil { t.Fatalf("missing distinct artifact %s: %v", artifact, err) @@ -35,3 +37,157 @@ func TestSaveIRsKeepsSameBasenameModulesDistinctAndReplacesOldTree(t *testing.T) t.Fatalf("stale artifact survived replacement: %v", err) } } + +func TestSaveIRsSeparatesIdentitiesDifferingOnlyByNamespace(t *testing.T) { + // Namespace and dependency are canonical identity components. Artifacts that + // ignored them collided, and the surviving file depended on map order. + ctx := project.NewWithConfig(project.Config{RootDir: t.TempDir()}, nil) + ctx.AddModule(&project.Module{ + ID: moduleid.ID{Origin: string(project.ModuleOriginStdlib), Namespace: "core", ImportPath: "json"}, + FilePath: "/core/json.peep", LLVMIR: "core", + }) + ctx.AddModule(&project.Module{ + ID: moduleid.ID{Origin: string(project.ModuleOriginStdlib), Namespace: "vendor", ImportPath: "json"}, + FilePath: "/vendor/json.peep", LLVMIR: "vendor", + }) + target := filepath.Join(t.TempDir(), "_gen") + if err := saveIRs(ctx, target); err != nil { + t.Fatalf("saveIRs: %v", err) + } + for namespace, want := range map[string]string{"core": "core", "vendor": "vendor"} { + path := filepath.Join(target, string(project.ModuleOriginStdlib), namespace, "_", "json.ll") + content, err := os.ReadFile(path) + if err != nil { + t.Fatalf("missing artifact for namespace %s: %v", namespace, err) + } + if string(content) != want { + t.Fatalf("artifact %s = %q, want %q", path, content, want) + } + } +} + +func TestModuleArtifactBaseEncodesIdentityComponentsInjectively(t *testing.T) { + stage := t.TempDir() + cases := []struct { + name string + id moduleid.ID + want string + within bool + }{ + { + name: "empty and underscore namespace are distinct", + id: moduleid.ID{Origin: string(project.ModuleOriginLocal), Namespace: "_", ImportPath: "app"}, + want: filepath.Join(stage, "local", "%5F", "_", "app"), + }, + { + name: "dotdot dependency is confined", + id: moduleid.ID{Origin: string(project.ModuleOriginLocal), Dependency: "..", ImportPath: "app"}, + want: filepath.Join(stage, "local", "_", "%2E.", "app"), + within: true, + }, + { + name: "dotdot origin is confined", + id: moduleid.ID{Origin: "..", ImportPath: "app"}, + want: filepath.Join(stage, "%2E.", "_", "_", "app"), + within: true, + }, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + got, err := moduleArtifactBase(stage, &project.Module{ID: tc.id}) + if err != nil { + t.Fatalf("moduleArtifactBase: %v", err) + } + if got != tc.want { + t.Fatalf("moduleArtifactBase = %q, want %q", got, tc.want) + } + if tc.within { + rel, err := filepath.Rel(stage, got) + if err != nil || strings.HasPrefix(rel, "..") { + t.Fatalf("artifact %q escapes stage %q", got, stage) + } + } + }) + } + + empty, err := moduleArtifactBase(stage, &project.Module{ID: moduleid.ID{Origin: string(project.ModuleOriginLocal), ImportPath: "app"}}) + if err != nil { + t.Fatalf("moduleArtifactBase: %v", err) + } + underscore, err := moduleArtifactBase(stage, &project.Module{ID: moduleid.ID{Origin: string(project.ModuleOriginLocal), Namespace: "_", ImportPath: "app"}}) + if err != nil { + t.Fatalf("moduleArtifactBase: %v", err) + } + if empty == underscore { + t.Fatalf("empty and underscore namespaces share artifact path %q", empty) + } + dotdot, err := moduleArtifactBase(stage, &project.Module{ID: moduleid.ID{Origin: string(project.ModuleOriginLocal), Dependency: "..", ImportPath: "app"}}) + if err != nil { + t.Fatalf("moduleArtifactBase: %v", err) + } + if dotdot == empty { + t.Fatalf("dotdot and empty dependencies share artifact path %q", dotdot) + } + + colon, err := moduleArtifactBase(stage, &project.Module{ID: moduleid.ID{Origin: string(project.ModuleOriginLocal), ImportPath: "a:b"}}) + if err != nil { + t.Fatalf("moduleArtifactBase: %v", err) + } + slash, err := moduleArtifactBase(stage, &project.Module{ID: moduleid.ID{Origin: string(project.ModuleOriginLocal), ImportPath: "a/b"}}) + if err != nil { + t.Fatalf("moduleArtifactBase: %v", err) + } + if colon == slash { + t.Fatalf("import paths a:b and a/b share artifact path %q", colon) + } +} + +func TestSaveIRsWritesEncodedIdentityArtifacts(t *testing.T) { + ctx := project.NewWithConfig(project.Config{RootDir: t.TempDir()}, nil) + ctx.AddModule(&project.Module{ + ID: moduleid.ID{Origin: string(project.ModuleOriginLocal), Namespace: "_", ImportPath: "a:b"}, + FilePath: "/colon.peep", LLVMIR: "colon", + }) + ctx.AddModule(&project.Module{ + ID: moduleid.ID{Origin: string(project.ModuleOriginLocal), ImportPath: "a/b"}, + FilePath: "/slash.peep", LLVMIR: "slash", + }) + target := filepath.Join(t.TempDir(), "_gen") + if err := saveIRs(ctx, target); err != nil { + t.Fatalf("saveIRs: %v", err) + } + for _, artifact := range []string{ + filepath.Join("local", "%5F", "_", "a%3Ab.ll"), + filepath.Join("local", "_", "_", "a", "b.ll"), + } { + if _, err := os.Stat(filepath.Join(target, artifact)); err != nil { + t.Fatalf("missing encoded artifact %s: %v", artifact, err) + } + } +} + +func TestSaveIRsSeparatesIdentitiesDifferingOnlyByDependency(t *testing.T) { + ctx := project.NewWithConfig(project.Config{RootDir: t.TempDir()}, nil) + ctx.AddModule(&project.Module{ + ID: moduleid.ID{Origin: string(project.ModuleOriginDependency), Namespace: "vendor", Dependency: "left", ImportPath: "util"}, + FilePath: "/left/util.peep", LLVMIR: "left", + }) + ctx.AddModule(&project.Module{ + ID: moduleid.ID{Origin: string(project.ModuleOriginDependency), Namespace: "vendor", Dependency: "right", ImportPath: "util"}, + FilePath: "/right/util.peep", LLVMIR: "right", + }) + target := filepath.Join(t.TempDir(), "_gen") + if err := saveIRs(ctx, target); err != nil { + t.Fatalf("saveIRs: %v", err) + } + for _, dependency := range []string{"left", "right"} { + path := filepath.Join(target, string(project.ModuleOriginDependency), "vendor", dependency, "util.ll") + content, err := os.ReadFile(path) + if err != nil { + t.Fatalf("missing artifact for dependency %s: %v", dependency, err) + } + if string(content) != dependency { + t.Fatalf("artifact %s = %q, want %q", path, content, dependency) + } + } +} diff --git a/docs/compiler-architecture.md b/docs/compiler-architecture.md new file mode 100644 index 00000000..c44646e3 --- /dev/null +++ b/docs/compiler-architecture.md @@ -0,0 +1,426 @@ +# Peeper Compiler Architecture + +This document describes the compiler architecture that exists in this repository. +Language behavior is defined by [`language-spec.md`](language-spec.md) and +[`ownership-pointer-model.md`](ownership-pointer-model.md); this document defines +where that behavior is implemented and how new work should compose with existing +mechanisms. + +## Design goal + +Peeper's compiler is organized around one rule: + +> Define structure once. Define unique semantics once. Derive everything else. + +The architecture is intended to prevent the failure mode where a feature looks +complete in parsing or typechecking but one forgotten ownership, flow, cleanup, or +lowering walk survives until production. + +Three invariants drive the design: + +1. **One canonical mechanism per concern.** Tree structure, semantic type + structure, place projections, graph adjacency, worklist scheduling, and value + effects each have one owner. +2. **Compositional behavior.** A new container/type/syntax construct that is built + from existing semantic operations and provenance evidence can reuse ownership, + definite-init, liveness, and cleanup behavior. New reference-bearing value shapes + still need an explicit provenance audit; operation coverage alone is insufficient. +3. **Loud true extension points.** If a new construct introduces genuinely new + semantics, syntax-aware owners must make an explicit decision. Unknown sealed + node/effect kinds panic or fail validation instead of being silently skipped. + +This is not a "visitor everywhere" architecture and not an "effects replace every +phase" architecture. Visitors/switches are appropriate at syntax interpretation +boundaries. Generic downstream analyses consume canonical semantic evidence. + +## Pipeline + +`internal/pipeline/pipeline.go` owns scheduling. Current per-module order is: + +```text +parse + -> collect + -> bind + -> resolve + -> constants + -> typecheck + -> CFG + -> flow typing + -> semantic effects + -> definite initialization + -> ownership + cleanup + -> usage + -> HIR + -> MIR + -> backend +``` + +Project/module readiness and incremental checkpoints remain explicit. Do not hide +this scheduler behind a uniform pass interface: phases have different dependency, +barrier, and invalidation rules. + +## Representation boundary + +Syntax remains source shape. Semantic facts remain side tables/results keyed by +stable identity. + +```text + source + | + AST / parser + | + +------------------+------------------+ + | syntax-aware semantic owners | + | resolver, typechecker, CFG builder, | + | effect publisher, HIR lowering | + +------------------+------------------+ + | + canonical semantic evidence + symbols / types / places / CFG / effects + | + +------------------+------------------+ + | syntax-agnostic generic analyses | + | definite-init, most ownership value | + | flow/liveness/worklist mechanics | + +------------------+------------------+ + | + cleanup / IR +``` + +A later phase must not rediscover a decision already published by an earlier +owner. If ownership needs to know that a call argument is borrowed, typechecking +publishes that fact and effects publish `Borrow`; ownership must not inspect call +syntax and infer it again. + +## Canonical structural mechanisms + +### AST: `ast.Inspect` + +AST nodes own child shape through `forEachChild`. Generic consumers use +`ast.Inspect` rather than maintaining private recursive switches. + +A new AST node must declare its children once. Semantic phases may still dispatch +on node kind when behavior genuinely differs. + +### Semantic types: `typeinfo.ForEachChild` + +`typeinfo.Type` is sealed inside `typeinfo` and requires two structural contracts: + +- `forEachChild` — immediate semantic children and their `TypeChildRelation`; +- `ownershipShape` — how copy/drop semantics compose over those children. + +`typeinfo.ForEachChild` is the semantic-type equivalent of `ast.Inspect`. +Containment and ownership capability queries consume this structure. Sizing and +lowerability intentionally keep separate recursion policies because recursive +cycles mean different things to those queries. + +Consequence: adding a new composite semantic type cannot satisfy `typeinfo.Type` +until it declares child structure and ownership composition. Nested ownership/drop +then propagates through generic machinery. This enforces method presence, not +correct child enumeration or ownership policy; behavioral tests remain necessary. + +Typed-nil capability inputs retain explicit-copy/no-drop answers. `isNilType` uses +bounded pointer reflection before ownership dispatch: nil scalar and owned-pointer +receivers otherwise return ordinary non-nil facts. Keep this guard rather than add +an exhaustive type-kind switch or a nil-only interface. Traversal methods separately +handle nil receivers; this is not a compiler-wide typed-nil validation guarantee. + +### Places: `place.Project` / `place.Decompose` + +Selector/index place grammar is owned by `internal/semantics/place`. +Consumers must not peel `SelectorExpr`/`IndexExpr` independently to answer which +storage is touched. + +Canonical place facts carry: + +- root symbol when storage is named; +- ordered field/index projections; +- temporary base identity when expression projects from a temporary. + +Addressability, mutability, effect publication, ownership, and flow origin logic +build on this contract. + +### Graphs: `graph.Directed` + +`internal/graph.Directed` owns directed adjacency and reverse adjacency once. +Domain graphs keep semantic edge data on top: + +- import/type dependency graphs use `graph.Graph`; +- CFG owns `cfg.Edge` kinds/case metadata while storing site/block topology in + `graph.Directed`. + +Do not add another successors/predecessors store to a domain graph. Add domain +metadata to its edge/node type and reuse the topology kernel. + +CFG terminators and ordered block sites define control flow. Block/site edge +indexes are derived at construction and immutable by consumer convention after +publication. Rebuild the CFG for a new topology generation; do not independently +mutate terminators or indexes while downstream evidence still names its sites. +Validators inspect all stored edges, including disconnected foreign endpoints, +and compare site targets, kinds, and case labels against the block topology. + +### Fixed-point scheduling: `graph.Worklist` + +`graph.Worklist` owns FIFO scheduling, pending-node deduplication, and +rescheduling. Flow, definite-init, ownership, and liveness retain their own state, +join, direction, edge transfer, diagnostics, and convergence rules. + +This is deliberately smaller than a generic dataflow framework. Shared mechanics +are centralized; semantic lattices remain visible in their owning packages. + +## Canonical semantic effects + +`internal/semantics/effect` publishes value/storage behavior in source evaluation +order. Current operations are: + +- `Define` — storage becomes a binding, optionally initialized from a value; +- `Write` — existing place is replaced/mutated; +- `Use` — read/copy/move of a place; +- `Borrow` — shared/mutable/raw borrow with exact operand identity; +- `Iterate` — long-lived sequence-loop access and hidden carrier identity; +- `Discard` — produced value is thrown away; +- `CallBegin` / `CallEnd` — call-lifetime brackets for argument loans. + +`effect.Visitor` is the exhaustive consumer boundary. An `effect.Op` is sealed and +must dispatch through that visitor; adding a new semantic operation therefore makes +every exhaustive consumer fail compilation until it implements the new visitor method. +This is where Peeper deliberately uses the visitor pattern: new **semantics** are +introduced to every consumer, while new syntax that reuses existing effects is not. + +The effect publisher is a syntax-aware boundary because evaluation order and use +kind are language semantics. Downstream analyses do not repeat that AST walk. + +Examples of behavior now derived from effects: + +- definite initialization consumes `Define`, `Write`, `Use`, `Borrow`; +- ownership consumes `Define`/`Write` generically rather than special-casing + `let`, `const`, and assignment statements; +- ownership/liveness use the same published `Use`/`Borrow` sequence; +- sequence-loop borrow lifetime arrives as `Iterate`; ownership no longer asks + `ForStmt` or `SequenceIteration` what kind of loop it is. + +A new syntax construct that reuses existing operations and reference-provenance +shapes normally requires no changes to these downstream analyses. The publisher +must still evaluate every base/index/bound exactly once in semantic order; a place +path describes storage, not all evaluated operands. + +A new **semantic operation** is different. It is a real extension point: add the +operation, validate it, and make each consumer explicitly decide what it means. +Unknown effects must not be silently ignored. + +## Phase ownership + +| Concern | Canonical owner | Published evidence / artifact | +| --- | --- | --- | +| Source structure | parser / `frontend/ast` | AST + stable node IDs | +| Declaration catalog | collector | module symbols | +| Type binding | binder | symbol type state / binding result | +| Lexical/import resolution | resolver | `Bindings.NodeSymbols`, scopes | +| Type rules and adaptation | typechecker | `typecheckresult.Result` | +| Control topology | `ir/cfg` | typed blocks/sites/edges | +| Variant/optional path facts | flow typechecker | `flowresult.Result` | +| Evaluation/storage actions | `semantics/effect` | ordered `effect.Result` | +| Definite initialization | `semantics/definiteinit` | diagnostics | +| Move/borrow/drop analysis | `semantics/ownership` | `ownershipresult.Result` | +| Lexical usage warnings | `semantics/usage` | diagnostics from symbol `Used` / `RequiresMutable` flags | +| High-level lowering | `ir/hir/lower` | HIR | +| Mid-level lowering | `ir/mir` | MIR | +| Physical layout/codegen | backend | backend IR | + +When a phase needs a fact owned above it, extend the owner's result/query. Do not +re-detect the fact from AST shape below the owner. + +## What remains syntax-aware, intentionally + +Some phases must understand syntax because syntax introduces semantics: + +- resolver: names/scopes/import/variant paths; +- typechecker: type rules, conversions, calls, loop/match semantics; +- CFG builder: source control constructs -> topology; +- effect publisher: evaluation order and value/storage action; +- HIR lowering: source construct -> executable high-level IR; +- ownership reference capture: bounded value-shape interpretation plus published + type/flow evidence, preserving live loans before effects can move source values. + +Those switches are not architectural duplication by themselves. The smell is two +phases independently deriving the **same fact**. + +Ownership still has return-specific policy because return-origin/pointer-escape +checks straddle returned-value evaluation: provenance must be checked before a +move can erase it, while cleanup runs after evaluation. This is a deliberate +language policy, not a generic child walk. If another control transfer needs the +same semantic policy, publish a control effect rather than adding another parallel +AST reconstruction. + +### Reference provenance and holder-relative loans + +`ownership.referenceValueForExpr` is not a generic aggregate interpreter. It uses +existing holder loans, `Flow.ResolvedValueOrigins`, reference types, struct payload +syntax and `Typechecking.VariantConstructions` for currently accepted carriers. +Pre-evaluation capture preserves loan identity before a move clears source state. +Flow origin sets describe referents; they do not replace ownership's dynamic loan +IDs, mutability, reservations/activation, liveness, joins or cleanup policy. + +Each `referenceLoan.path` locates a slot relative to its holder, independently of +`origins` (borrowed storage) and `id` (loan identity). Copies clone paths; equality +and joins distinguish the same loan in different slots. Projected writes consume +an exact `Flow.ResolvedStorageOrigins` destination and captured RHS loans to replace +one direct/optional enum reference field, including clearing it, while retaining +sibling and copied-holder loans. Partial writes keep the carrier live. This is not +full field-sensitive last-use analysis or support for nested stored-reference +aggregates/arrays. Those storage restrictions remain typechecker-owned. + +Flow typing must retain recorded assignment-operand types and variant payload +proofs for HIR; retyping an already checked operand can erase that evidence. HIR +consumes published payload/projection facts; backend typed-store invariants remain +strict. Single-case enum selectors and optional-array index assignment have known +separate typing limitations, not resolved by this reference-field repair. + +### Lexical usage, not runtime liveness + +`semantics/usage.Analyze` emits unused/import/private/local/parameter and unnecessary +`mut` warnings from symbol flags. Resolver and project type/import lookup mark +`Used`; typechecking marks `RequiresMutable`. Type-only/import references and source +uses outside reachable runtime paths are not equivalent to effect-stream uses. +Ownership liveness remains a separate CFG/effect analysis. Moving usage to reachable +CFG effects would change warning policy and needs explicit design/approval; it is +not an unfinished mechanical migration required by this architecture. + +## Adding a new expression or statement + +Classify the feature before editing downstream code. + +### A. Pure syntax / sugar over existing semantics + +Expected work: + +1. AST + parser; +2. AST child declaration (`forEachChild`); +3. translate at the syntax-aware semantic boundary into existing decisions/effects. + +For already supported value/provenance shapes, ownership, definite-init, liveness, +graph scheduling, and cleanup should require no new node case. + +### B. New typechecking or control semantics, existing value effects + +Expected work: + +1. AST + parser; +2. resolver only if name/scope behavior differs; +3. typechecker decision/evidence; +4. CFG only if topology differs; +5. effect publisher maps construct to existing operations; +6. HIR lowering. + +Generic mechanics stay unchanged; audit reference capture if the feature introduces +a new accepted reference-bearing value shape. + +### C. New semantic action + +Only when existing effects cannot represent behavior: + +1. add sealed `effect.Op` and its `effect.Visitor` method; +2. add producer + validator; +3. implement the new visitor method in every semantic consumer; compile failures + provide the checklist; +4. add focused tests proving evaluation order and analysis behavior; +5. add/update Peeper fixtures when language behavior changes. + +Do not introduce a new effect merely because syntax is new. + +## Adding a semantic type + +A new type must first satisfy `typeinfo.Type`: + +1. `TypeNode` and `Text`; +2. `forEachChild` with correct `TypeChildRelation` for every contained type; +3. `ownershipShape` describing leaf/container ownership policy. + +Once this is done, recursive containment and copy/drop propagation compose through +the canonical structure. + +Then make explicit decisions only where representation semantics genuinely differ: + +- `SameType` / compatibility; +- sizing/lowerability when shape has special rules; +- exported semantic fingerprint; +- HIR/backend type lowering; +- syntax conversion if source has new type syntax. + +`internal/contracts/type_dispatch_test.go` guards these true type-kind extension +points. It should not grow entries for generic containment/ownership traversal. + +## Adding a graph-backed analysis + +- Reuse `graph.Directed` for topology. +- Keep semantic edge metadata in domain edge type. +- Reuse `graph.Worklist` when analysis needs rescheduling. +- Keep state/join/transfer in analysis package. +- Do not infer true/false/case/loop meaning from successor position; use `cfg.Edge` + kind/case and CFG block/site metadata. + +## Validation model + +Different mistakes are caught at different boundaries: + +| Mistake | Guard | +| --- | --- | +| AST child omitted | AST child completeness contracts/tests | +| semantic type child/ownership method omitted | sealed `typeinfo.Type` compile-time contract | +| incorrect child relation or capability composition | structural tests + capability golden/cycle tests | +| semantic type missing representation decision | focused type dispatch contract | +| malformed graph topology | CFG/graph validators and tests | +| malformed effect evidence | `effect.Result.Validate` | +| new effect ignored by an exhaustive consumer | `effect.Visitor` compile-time contract | +| malformed cleanup evidence | `ownershipresult.Validate` | +| malformed HIR/MIR | IR validators | +| wrong language behavior | package tests + `x_test` source fixtures | + +Effect validation checks node membership and expression categories, not whether +an existing syntax case emitted every required operation. Definition, write-owner, +and iteration-owner IDs remain generic source identities; consumers do not need +a particular declaration syntax. Dispatch contracts catch missing kind decisions; +producer ordering tests and source fixtures catch missing or reordered operations. +Empty artifacts remain valid when no operations are needed. + +Source-parsing contract tests are retained only where Go's type system cannot +express a closed extension boundary more directly. They are not the primary +architecture. + +## Forbidden patterns + +Do not add: + +- a second recursive AST walk for a structural query already served by + `ast.Inspect`; +- a second semantic-type child enumeration for generic recursive behavior; +- private selector/index peeling when `place.Project`/`Decompose` answers it; +- private graph adjacency stores; +- private queue + queued-set fixed-point schedulers; +- downstream syntax checks for a fact already published by typechecking/CFG/effects; +- default/no-op handling that makes an unknown sealed node/effect silently succeed; +- pass-through compatibility wrappers around canonical APIs. + +## Verification commands + +Repository currently targets Go 1.23.2. + +```bash +go test -count=1 ./internal/semantics/typeinfo ./internal/project ./internal/contracts +go test -count=1 ./... +go vet ./... +go test -race -count=1 ./internal/graph ./internal/project ./internal/pipeline +go run ./scripts/bundle.go +PEEPER_BIN="$PWD/build/bin/peeper" go test -count=1 ./x_test +git diff --check +``` + +Run commands sequentially: full Go tests and executable fixtures can touch shared +`build/` artifacts. Without `PEEPER_BIN`, fixture tests skip compiler execution; +manifest-only success is not language validation. Race coverage above is focused, +not a claim that every package or target was race-tested. + +For language behavior, use `x_test` and the bundled compiler according to +[`RULES.md`](../RULES.md). Architecture reviews should also search for new private +adjacency stores, queue/queued loops, repeated type-child enumeration, and AST +switches appearing in previously syntax-agnostic analyses. diff --git a/docs/compiler-framework/README.md b/docs/compiler-framework/README.md new file mode 100644 index 00000000..c753da09 --- /dev/null +++ b/docs/compiler-framework/README.md @@ -0,0 +1,66 @@ +# Compiler Framework + +Framework migration is now implemented as a set of canonical compiler kernels, +not a universal pass/visitor framework. + +Read [`../compiler-architecture.md`](../compiler-architecture.md) first. It is the +current architecture contract and extension guide. This directory retains deeper +migration notes, artifact ownership analysis, validation design, and historical +change-path evidence. + +## Final architecture decision + +Peeper uses four complementary mechanisms: + +1. **Canonical structure** — `ast.Inspect`, `typeinfo.ForEachChild`, + `place.Project`/`Decompose`, and `graph.Directed` ensure structural knowledge is + written once. +2. **Canonical semantic evidence** — typechecker results, typed CFG edges/sites, + flow evidence, and ordered semantic effects prevent downstream rediscovery; + `effect.Visitor` makes the semantic operation set exhaustive for consumers. +3. **Generic mechanics** — `graph.Worklist` and shared graph topology remove + repeated scheduling/adjacency code without hiding phase-specific lattices. +4. **Explicit true extension points** — resolver/typechecker/CFG/effect/HIR and + semantic type representation decisions remain exhaustive where behavior really + differs. + +Goal is not "every phase visits every AST node". Goal is stronger: + +> phases that only care what syntax **does** should consume canonical semantic +> evidence and never need to know that syntax kind exists. + +A new ordinary expression can therefore reuse `Use`/`Borrow`/`Write`/`Define` and +inherit definite-init, ownership, liveness, and cleanup behavior. A new composite +semantic type declares child structure and ownership composition once and nested +copy/drop behavior follows automatically. + +## Current canonical owners + +| Concern | API | +| --- | --- | +| AST recursion | `ast.Inspect` / node `forEachChild` | +| semantic type structure | `typeinfo.ForEachChild` / `TypeChildRelation` | +| type ownership composition | sealed `typeinfo.Type.ownershipShape` | +| place/projection grammar | `place.Project`, `place.Decompose` | +| graph adjacency | `graph.Directed` | +| fixed-point scheduling | `graph.Worklist` | +| control topology | `cfg.Graph` + typed `cfg.Edge` | +| value/storage behavior | `effect.Result` + exhaustive `effect.Visitor` | +| cleanup evidence | `ownershipresult.Result` | + +## Contract philosophy + +Old framework experiments tried to make every phase acknowledge every AST kind. +That catches omissions but preserves distributed work. Final architecture narrows +those contracts to actual semantic boundaries. + +- Structural consumers reuse canonical traversal. +- Generic analyses consume semantic operations. +- A new semantic type is sealed until it declares structure + ownership policy. +- Remaining source-inspection contracts guard only closed sets that Go cannot make + exhaustive directly. +- Artifact validators reject malformed evidence at producer boundaries. + +See [`change-paths.md`](change-paths.md) for historical evidence that motivated +migration. Its old change counts are baseline measurements, not the current +recommended extension path. diff --git a/docs/compiler-framework/change-paths.md b/docs/compiler-framework/change-paths.md new file mode 100644 index 00000000..555e0528 --- /dev/null +++ b/docs/compiler-framework/change-paths.md @@ -0,0 +1,163 @@ +# Change paths through the compiler + +This is the concrete companion to [`../compiler-architecture.md`](../compiler-architecture.md). +Read that document first: the goal is **not** to make every phase acknowledge every +syntax node. The goal is to edit the few owners of unique semantics and let canonical +structure/evidence drive the rest. + +Mandatory repository policy remains [`RULES.md`](../../RULES.md); durable compiler +principles remain [`COMPILER_GUIDELINES.md`](../../COMPILER_GUIDELINES.md). + +## The rule for every change + +Before adding a switch or recursive walk, ask what fact you need and who already owns it. + +| Need | Canonical owner/API | +| --- | --- | +| AST children | node `forEachChild` + `ast.Inspect` | +| semantic type children | `typeinfo.ForEachChild` | +| copy/drop composition | sealed `typeinfo.Type.ownershipShape` | +| storage projection | `place.Project` / `place.Decompose` | +| graph adjacency | `graph.Directed` | +| fixed-point scheduling | `graph.Worklist` | +| name identity | resolver/binding results | +| type/use/adaptation decisions | `typecheckresult.Result` | +| control topology | `cfg.Module` / typed `cfg.Edge` | +| evaluation/storage actions | `effect.Result` | +| cleanup/drop evidence | `ownershipresult.Result` | + +If a later phase needs a fact from an earlier owner, extend the owner's published result. +Do not reconstruct the fact from AST shape downstream. + +## Path 1 — add an expression or statement + +First classify the feature. + +### Syntax using existing semantics + +Expected edits: + +1. **AST** — define the node and its stable identity/location. +2. **AST children** — implement `forEachChild` once. Generic `ast.Inspect` users then + see the new children automatically. +3. **Parser** — parse/recover the source syntax. +4. **Resolver/typechecker** — only where name, scope, type, call, or adaptation semantics + differ. +5. **CFG** — only when control topology differs. +6. **Effect publisher** — map evaluation to existing `Define`/`Write`/`Use`/`Borrow`/ + `Iterate`/`Discard`/call-boundary operations. +7. **HIR** — lower the source construct when no existing source lowering covers it. + +Do **not** add a corresponding AST case to definite initialization, ordinary ownership +state transitions, liveness, or cleanup. If one of those needs syntax to understand the +new feature, the semantic boundary is probably missing evidence. + +### Syntax with a genuinely new semantic action + +Only add a new `effect.Op` when the existing operations cannot express the behavior. +Then the effect is a true closed extension point: + +1. add the sealed operation in `internal/semantics/effect`; +2. publish it in evaluation order; +3. validate its required identity/evidence; +4. extend `effect.Visitor`; every exhaustive consumer then fails compilation until it + explicitly decides what the operation means; +5. add focused Go tests and, for language behavior, `x_test` source fixtures. + +A new effect is therefore a compile-time introduction to semantic consumers, not a +search-and-remember exercise. It must never fall through as an accidental no-op. + +## Path 2 — add a semantic type + +A semantic type is not complete until it satisfies the sealed `typeinfo.Type` contract. +The first edits are therefore local to `internal/semantics/typeinfo`: + +1. add the type and its `TypeNode`/`Text` behavior; +2. enumerate immediate contained types in `forEachChild` with correct + `TypeChildRelation` values; +3. declare `ownershipShape` — whether it is a leaf/container and how copy/drop composes. + +That is the structural extension point. After it is correct, recursive containment and +copy/drop propagation use the canonical traversal automatically. + +Then add only representation-specific decisions that truly differ, for example: + +- equality/compatibility; +- sizing or lowerability with special cycle/ABI rules; +- exported semantic fingerprinting; +- HIR/backend lowering; +- source-type conversion if new syntax is involved. + +`internal/contracts/type_dispatch_test.go` guards the remaining closed type-kind sites. +Do not add new private recursive type-child walkers to satisfy one query. + +## Path 3 — add a graph-backed analysis + +1. Store topology in `graph.Directed`; keep domain semantics in typed node/edge metadata. +2. Reuse `graph.Worklist` if the analysis is a rescheduling fixed point. +3. Keep the analysis's state, join, transfer, direction, diagnostics, and edge semantics + in its own package. +4. On CFG, use edge kinds/case metadata. Never infer true/false/case/loop meaning from + successor position. + +A domain graph may wrap the graph kernel; it should not own a second adjacency index. + +## Path 4 — add an ownership/flow rule + +Start from semantic evidence, not syntax. + +- Value is read/copied/moved? Extend the producer of `effect.Use` or its typechecker + decision, not an ownership expression switch. +- Place is borrowed? Publish `effect.Borrow` with exact operand/place identity. +- Storage is introduced/replaced? Use `effect.Define` / `effect.Write`. +- A long-lived sequence iteration access is needed? Use `effect.Iterate` and CFG loop + identity. +- A branch/case fact is needed? Put it on CFG/flow evidence. +- A type recursively contains ownership/reference behavior? Put the relationship on the + semantic type structure. + +Return pointer/reference provenance is currently a deliberate ownership policy that +straddles returned-value evaluation, so ownership retains a return-specific control +hook. If another construct needs the same control semantic, publish a shared control +operation rather than adding parallel syntax reconstruction. + +## What should break when a new thing is added + +The architecture intentionally distinguishes automatic composition from true extension +points: + +| Change | Expected guard | +| --- | --- | +| new AST field/node child | AST traversal completeness test | +| new syntax kind at a syntax-aware closed site | dispatch contract / compiler failure | +| new semantic type | sealed `typeinfo.Type` compile failure until structure/ownership declared | +| new semantic type missing representation decision | type dispatch contract | +| new effect operation | `effect.Visitor` compile failure + artifact validator | +| malformed CFG/effects/ownership/IR | artifact validator | +| changed Peeper behavior | focused tests + `x_test` fixture | + +The ideal result is that a new ordinary syntax node causes **fewer** downstream edit +requirements than before, while genuinely new semantics become **more** explicit. + +## Pre-review audit + +Before considering a compiler architecture change complete, search for accidental +parallel machinery: + +```bash +# private fixed-point schedulers in semantic analyses +rg -n 'queue|queued' internal/semantics + +# syntax knowledge leaking back into generic consumers +rg -n 'case \*ast\.' internal/semantics/definiteinit internal/semantics/ownership + +# selector/index projection reimplementation +rg -n 'SelectorExpr|IndexExpr' internal/semantics/ownership internal/semantics/effect +``` + +Interpret results semantically rather than mechanically. A return-specific ownership +policy or the syntax-aware effect publisher is valid; another generic child/projection +walk is not. + +Then run the verification commands documented in +[`../compiler-architecture.md`](../compiler-architecture.md). diff --git a/docs/compiler-framework/effect-stream-migration.md b/docs/compiler-framework/effect-stream-migration.md new file mode 100644 index 00000000..9329a893 --- /dev/null +++ b/docs/compiler-framework/effect-stream-migration.md @@ -0,0 +1,410 @@ +# Effect stream migration + +Status: **in progress**. Definite initialization is fully migrated. Ownership consumes +published effects for use enumeration, liveness definitions and discarded values; its +expression walk still reads syntax, blocked on two facts nobody publishes yet. + +This document is the executable plan for publishing semantic effects once and migrating +dataflow consumers onto them. It is tracked so that anyone — human or agent — picking the +work up cold has the whole context in the repository. + +Read [`change-paths.md`](change-paths.md) first for how a change travels through the +compiler today, and [`README.md`](README.md) for the framework workstreams this extends. + +## Why + +Adding a language feature should need parser, scope, and type rules, and nothing else. +Today every later phase re-derives the same meaning from AST shape. `change-paths.md` +records nine statement dispatch sites and four expression sites for one new node kind. + +Four of the nine were pure mechanism — they rediscovered read/write meaning the +typechecker already decided: + +``` +ownership.applyStmt · ownership.symbolUseSequence +definiteinit.checkReads · typechecker.applyConditionEdge +``` + +`definiteinit.checkReads` is gone; the producer took its place in the contract. The other +three remain. + +Two concrete failures this causes today: + +- **Silent omission.** `internal/semantics/definiteinit/initialization.go` switches over AST + kinds in three places and none has a `default:`. A new statement kind is dropped without + error or panic; the result is a false "used before initialized" or a missed one, depending + on which switch missed it. Contrast `cfg.buildStmt`, which panics on an unhandled + statement. The producer is total; the consumers silently are not. +- **Two walks that must agree, and don't.** `ownership.checkExpr` and + `ownership.symbolUseSequence` both enumerate value uses. `applyStmt` handles + `ForStmt.Iterable`; `symbolUseSequence` never visits it. Liveness and borrow-ending see a + different program than the effect analysis does. + +## The shape + +One producer translates AST into ordered semantic effects, keyed by CFG site. Consumers read +effects, never syntax. A construct that maps onto existing effects needs no case in any +consumer. + +This shares **evidence**, not a solver. `COMPILER_GUIDELINES.md` §6 forbids extracting a +generic dataflow framework from similar-looking worklists. Each analysis keeps its own +lattice, join, direction, and diagnostics. Only the facts are shared. + +### Package + +`internal/semantics/effect/` — `model.go`, `build.go`, `validate.go`, plus tests. + +Model and builder live together, following `internal/ir/cfg`, not the +`typechecker`→`typecheckresult` split, because this producer is a normalization pass rather +than a phase with its own analysis. + +### Vocabulary + +It started at three operations and grew to seven, each time because a consumer needed the +distinction. `Define` brings a binding into existence; `Write` stores to one that already +exists; `Use` reads a place; `Borrow` takes a reference to one; `Discard` throws a value +away; `CallBegin` and `CallEnd` bracket a call. The section below records what each +addition bought. + +Every operation carries the `ast.NodeID` it came from. `Use`, `Borrow` and `Discard` also +carry a `*source.Location`, so a consumer reports against them without resolving the node +back to syntax. `Define` and `Write` carry none, because no current diagnostic anchors on +them. + +`Op` is sealed by an unexported marker method, the same idiom as `cfg.Terminator` and +`typecheckresult.IterationPlan`. Go cannot make a consumer's type switch exhaustive, so +`internal/contracts` carries that half. + +### What the vocabulary grew, and why + +Adding a channel before a consumer needs it is what commit `7ec06e9` had to delete, so +each of these was held back until a consumer actually asked. All were added by migrating +ownership, and each arrived with the consumer that needed it in the same change: + +| Added | Because | +| --- | --- | +| `Use.Kind` | ownership decided read/copy/move at forty-four hardcoded literals. The kind now comes from the position, and for a call argument from the typechecker's published decision | +| `Borrow` | a reference is not a read, and shared versus mutable is what decides whether a second borrow conflicts | +| `Discard` | a value produced and thrown away dies where it is produced, when nothing owns it | +| `Place` with projections | moving out of `pair.left` is a different decision from moving `pair`, with its own diagnostic | +| `Place.Temporary` | ownership keys two policies on a value that lives in no binding, which a binding-rooted place could not name | +| `CallBegin` / `CallEnd` | a call is a lifetime: argument temporaries die when it completes, receiver reservations activate when it starts | +| `Define.OnEntry` | a parameter and a match payload binding exist before their site runs, and liveness must not treat that as a definition within the site | + +Still absent, with its trigger recorded: + +| Absent | Add when | +| --- | --- | +| `Region` (deferred or repeated body) | a construct exists whose body does not execute at its syntactic position — a lambda. CFG back-edges already give "runs 0..N times" for a loop, so a loop does not justify it | + +### Result and phase + +`Result` is `map[ir.NodeID]SiteOps` and `SiteOps` is `map[cfg.SiteID][]Op` — function +identity outer, site inner, following `flowresult.Result.SiteFacts`. A `cfg.SiteID` is +`{Block, Index}` and is only meaningful relative to one graph, so the outer key is +required. It is a bare map type rather than a struct with one field, matching +`ownershipresult.Result`, because `RULES.md` §1 forbids the single-field wrapper. + +New phase `Effects`, placed after `FlowTyped` and before `DefiniteInit`: + +``` +CFG → FlowTyped → Effects → DefiniteInit → Ownership → Usage +``` + +After `FlowTyped` so the producer may use `module.EffectiveExprType`, as ownership already +does. Any reset clearing `CFG` also clears everything later, so no stale `SiteID` can +outlive its graph. + +## Milestone 1 — pilot on definite initialization + +Ownership is roughly 1900 lines with loans, NLL borrow-ending, and fifteen diagnostics. +The vocabulary is proved against one small consumer first. + +`usage` is **not** part of this and needs no migration: it has no AST switch and no state, +it scans `sym.Used` flags. + +Each step ends with its gate green and one commit. Do not start a step before the previous +gate passes. Prefix every command with +`GOCACHE=/tmp/peeper-maintainability-go-cache CCACHE_DISABLE=1`. + +### Step 1 — vocabulary and artifact slot — **done** + +`effect/model.go` with `Op`, `Define`, `Write`, `Use`, `Result`, `SiteOps` and `At`. Add +`phase.Effects` with its `String` case; add `Module.Effects` with an ownership comment; add +the `resetToPhase` clause in phase order. + +The phase transitions deliberately land in step 2, not here: without a production block a +module advances through `Effects` while actually running definite init, and its diagnostics +get tagged with the wrong phase. + +Gate: `go build ./...`, `go vet ./...`, tests for `internal/project`, `internal/pipeline`, +`internal/phase`. +Commit: `Add semantic effect artifact and phase slot` + +### Step 2 — producer — **done** + +`effect/build.go` with `Build(graphs, nodes, BuildQueries)`. Passing a `*project.Module` +would make `project` and this package import each other; `cfg.BuildQueries` establishes the +alternative, so the producer declares the narrow accessors it needs and the owning artifacts +supply matching methods. `typecheckresult.ArmBindings` was added for that reason, joining +`MatchCases` and `ForLoopGuaranteedEntry`. + +Emit in evaluation order: parameters as initialized defines at the entry site; `LetDecl` and +`ConstDecl` as reads of the value then a define; `AssignStmt` as reads of the value then a +write for an ident target, or reads of both for a projection target; `ExprStmt` and +`ReturnStmt` as reads; a `*cfg.Branch` terminator site as reads of its condition; match arm +bindings as initialized defines at the arm body's entry site. + +**References** resolve through `Bindings.NodeSymbols`. **Definitions do not**: the +resolver indexes references only, so a declaration name and an assignment target are absent +from that map and resolve through the site's scope, exactly as definite initialization did. +Reproducing that split is what keeps the step free of behavior change. Unifying it needs +separate approval, because scope lookup by name walks parents and can bind a shadowed +symbol. + +Intercept `*ast.CallExpr` and walk `Typechecking.CallArgumentsOrSource(call)` so +default-expanded arguments are covered. + +Effects a site inherits on entry — parameters, match payload bindings — are published in a +leading pass, because a site's operations are read in evaluation order and an arm body may +read the payload it binds. + +The producer is now the single place that inspects syntax for this meaning, so it takes +`cfg.buildStmt`'s policy: `default: panic`. + +Gate: full `go test -count=1 ./...`. Nothing consumes the artifact yet, so nothing may change. +Commit: `Publish semantic effects for every CFG site` + +### Step 3 — migrate definite initialization — **done** + +`Check(graphs *cfg.Module, effects effect.Result, diag *diagnostics.DiagnosticBag)`. +Delete the three AST switches. + +Preserve exactly, all pinned by existing tests: the set lattice with **intersection** join +(a must-analysis); optimistic first arrival, intersect afterwards, requeue only on change; +the FIFO worklist; the report loop iterating site order rather than worklist order; +reachability by both `block.Reachable` and non-arrival; `tracked` as the diagnosable +universe; and the single `T0039` diagnostic verbatim, anchored at the reading identifier, +with no deduplication. + +One representation change: effects are replayed in evaluation order within a site rather +than every read being checked against the state before the whole site. That is what lets +parameters and match bindings arrive as ordinary defines instead of being seeded +separately. It agrees with the old behavior everywhere, because reads are always published +before the define or write they precede. The visible consequence is that a match binding +lands in a site's `Out` rather than its `In`. + +Gate: focused tests, then full suite, then `go run ./scripts/bundle.go` and the `x_test` +fixtures. **Zero diagnostic changes.** +Commit: `Consume published effects in definite initialization` + +### Step 4 — contract and validator — **done** + +Add the producer to `statementSites` in `internal/contracts/node_dispatch_test.go` with +`inertDeclarations: true`; remove the `checkReads` entry, whose function no longer exists. +Add `effect/validate.go` following `cfg/validate.go` literally — accumulate, sort, truncate +at ten — and state in its doc comment what it does not re-derive. + +Mutation-prove both: delete a producer case and confirm the named contract failure; corrupt +an op and confirm the validator message. Restore both, and record the outputs in the commit +body. Update the counts in `change-paths.md`. + +Gate: full suite plus race on `internal/project`, `internal/pipeline`, `internal/lsp`. +Commit: `Require a phase decision for every published effect` + +## Behavior changes found, not fixed here + +`RULES.md` §10 forbids mixing a behavior change into a refactor. Both of these are recorded +for separate approval and must **not** be corrected inside this migration. + +- **Match subjects were never read-checked — now closed.** `definiteinit` attached a site + condition only for `*cfg.Branch`, so a match on an uninitialized value was not + diagnosed. Ownership's liveness *did* read the subject, so migrating it onto the shared + producer forced the subject to be published, and publishing it closed the + definite-initialization gap at the same time. Covered by + `TestInitializationChecksMatchSubject`. No existing test, fixture or bundled program + changed, so nothing that compiled before fails now. + + This is the coupling a shared stream creates: evidence published for one consumer + strengthens every other consumer, whether or not that was the intent. Weigh it before + publishing anything new. +- **`ForStmt.Iterable` disagreement — now moot for enumeration.** `symbolUseSequence` + never visited it while `applyStmt` did. Both now read the same published stream, so + they cannot disagree. The iterable's reads are still not published, which preserves + today's behavior; `applyStmt` continues to handle it directly for the sequence-carrier + loan. + +## Success test — met + +`definiteinit/initialization.go` contains no `switch` over AST types and does not import +`ast` at all. Diagnostics are unchanged: the full suite, race on project, pipeline and +lsp, the bundle, and the `x_test` fixtures all pass without modification. + +What a contributor adding a statement kind now sees, in order: + +1. `cfg.buildStmt` panics if the kind reaches CFG construction unplaced. +2. `contracts.TestEveryStatementKindHasAPhaseDecision` fails with + `publishStmt makes no decision about ast.YourStmt`. +3. `effect.Result.Validate` raises `ICE0002` if what is published is malformed. + +Definite initialization needs no change at any point. + +## Picking this up cold + +1. `git status --short --branch`, and record HEAD. +2. `git log --oneline -5`. Steps commit in order with the subjects above, so the last + subject tells you which step finished. +3. Read `AGENTS.md`, `RULES.md`, and `go-style.md` before editing. Re-run the `AGENTS.md` §2 + pre-patch gate before every patch and the §7 post-patch audit before every stop. +4. Re-verify this document against live source. It was accurate when written; line numbers + and counts rot. +5. Never start a step before the current gate is green. +6. Never push, merge, open a pull request, or rewrite history without explicit approval. +7. If a gate fails for a reason this document does not predict, stop and report it. Do not + improvise around a failing gate. + +## Vocabulary as it stands + +``` +Define{Place-less: Symbol, Node, Initialized, OnEntry} +Write{Symbol, Node} +Use{Place, Node, Location, Kind} +Borrow{Node, Location, Mutable} +Discard{Place, Node, Location} +CallBegin{Node, Location} / CallEnd{Node} + +Place{Root *symbols.Symbol | Temporary ast.NodeID, Projections []place.OriginProjection} +``` + +Exactly one of `Place.Root` and `Place.Temporary` is set; the validator enforces it, and +checks that call brackets balance and do not cross. + +## Four boundaries, found by attempting the work + +Each was discovered by trying to migrate a consumer and watching a real test fail, not by +reading. Three are closed. + +1. **Bindings established by an edge, not a site — closed.** A parameter exists before the + entry site runs; a match payload binding is created by its case edge. Definite + initialization never noticed, because it replays a site's operations in order. + Liveness treats a site as a set, so counting those as definitions killed a borrow one + site early. `Define.OnEntry` records the difference. +2. **A call is a lifetime, not a position — closed.** Temporaries created while computing + an argument live until the call completes; a reservation for a receiver activates when + the call starts. `CallBegin`/`CallEnd` bracket it, and nest. +3. **Temporaries are places too — closed.** A value that lives in no binding could not be + named, but ownership keys two policies on exactly that case. `Place.Temporary` names + the producing expression. +4. **Two facts nobody publishes — open.** See below. + +## What blocks the expression walk + +`ownership.checkExpr` cannot yet be replaced, because two decisions it makes are not +recoverable from the stream: + +- **Reference-parameter position.** `Read(reference)` where the parameter is `&i32` takes + a shared-borrow storage access, not a plain read, *because of the parameter type*. The + stream publishes `Use{Kind: UseRead}`, which is also what an implicit-copy argument + publishes, so the two cannot be told apart. Note this is not an implicit borrow: Peeper + rejects `Read(value)` with `cannot implicitly convert i32 to &i32`. Implicit adaptation + happens only for a method receiver or a piped first argument, and the typechecker + already records that in `ImplicitCallArguments` — evidence that is published and unread. +- **Slicing.** `a[0..2]` forces a shared or mutable borrow access, decided by the index + being a `RangeExpr`. A `Place` with an `OriginIndex` projection does not say whether the + index was a range. + +There is a third, found only by attempting the port: `Write` carries a symbol, but an +assignment to a projection needs a place. See the port specification below, which lists all +three together because they land with the port rather than before it. + +## Migrated so far + +| Consumer | State | +| --- | --- | +| definite initialization | fully migrated; no AST switch, does not import `ast` | +| ownership use enumeration | `symbolUseSequence` reads the stream | +| ownership liveness definitions | `symbolUsesAndDefinitions` reads the stream | +| ownership discarded values | reads `Discard.Place`; `IsPlaceExpr` gone from `ownership.go` | +| ownership expression walk | **not migrated** — 20 AST cases, blocked above | +| ownership statement policy | 10 AST cases, and most is policy rather than enumeration | +| usage | needs no migration; it has no AST switch and no state | + +## Behavior this surfaced + +Publishing evidence for one consumer strengthens every other consumer. Two real gaps +closed as a side effect, each covered by a test and neither breaking an existing fixture: + +- a `match` on an uninitialized subject was never diagnosed; +- a loop over an uninitialized bound or sequence was never diagnosed. + +Weigh that before publishing anything new: it is a feature, but it is also a behavior +change that arrives without being asked for. + +## Bugs introduced by this work, and fixed + +Recorded because each was found by probing the real stream rather than by review, and the +same class will recur: + +- match arm bindings were emitted while walking the match terminator, so they could land + after the arm body's own operations at the same site; +- counting edge-established bindings as definitions ended a borrow one site early; +- a method callee was published as a field projection of the receiver, naming a field that + does not exist. + +## Replacing the expression walk + +Every function involved has been read. It is one indivisible change, not a sequence of +small ones, because each piece below is unobservable until the others are in place. Land +it as a single commit or not at all: publishing the facts first leaves channels nobody +reads, which is what commit `7ec06e9` had to delete. + +### Facts to publish, with the port + +- **Reference-parameter position.** A reference parameter makes its argument take a borrow + access rather than a read. `Use{Kind: UseRead}` is published for that *and* for an + implicit-copy argument, so the two are indistinguishable. The typechecker has the + parameter type at `publishValueUse`; publish presence plus mutability. +- **Slicing.** `a[0..2]` takes a borrow access, mutable when the slice's own type is a + mutable reference. The producer can see both — the index being a `RangeExpr` and the + result type — so this needs no typechecker change, only an expression-type query. +- **`Write` must carry a `Place`.** `a.b = x` writes a projection. It is published today as + `Use{Kind: UseRead}`, but ownership gives an assignment target `storageMutate`. Without a + place on `Write`, driving that from operations either loses the mutate or performs a read + access and a mutate access where there was one. + +### Shape of the consumer + +`applyStmt` keeps its statement policy and stops calling `checkExpr`. Policy splits around +evaluation: what must observe state *before* the site's values are computed (an +assignment's reference capture, a return's escape checks) runs first, then the operation +loop, then what follows (target handling, cleanup planning, the loop carrier loan). + +The loop itself: + +- `CallBegin` pushes the temporary and reservation marks; `CallEnd` activates reservations + and truncates both. Calls nest, so the marks are a stack. +- `Use` with no projections drives the move state and the use-after-move and copy + diagnostics; with projections it drives the partial-move diagnostics; rooted at a + temporary it drives projection-base planning. +- `Borrow` drives the storage access. Shared or mutable comes from the operation; a mutable + borrow **inside a call bracket** is a reservation rather than a borrow, which is why the + bracket has to be in the vocabulary. +- `projectionBase` disappears. It existed so a base and its projection did not both take a + storage access; one operation for the projected place replaces both. + +The helpers do not need rewriting. `checkStorageAccess`, `originsForExpr`, `referenceHolder` +and `activateCallReservations` all take an `ast.Expr`, which is recoverable from the +operation's node. + +### What this deletes + +`checkExpr`, `checkIdent`, `checkSelector`, `checkAddressExpr`, `checkLiteralFields`, +`checkCall`, `checkMethodCall` and `checkCallArgument`. Every distinction they draw is +already decided by the typechecker: the intrinsic special cases in `checkCall` exist only +to reach the right parameter types, and the kinds those produce are published already. + +### Before starting + +Read this whole file. The suite is a strong net and caught every mistake this work made, +but ownership is the compiler's most delicate analysis and a half-ported loan model is the +worst possible state to hand over. Do not begin without room to finish. diff --git a/docs/compiler-framework/ownership-vocabulary.md b/docs/compiler-framework/ownership-vocabulary.md new file mode 100644 index 00000000..098b1211 --- /dev/null +++ b/docs/compiler-framework/ownership-vocabulary.md @@ -0,0 +1,302 @@ +# Ownership Vocabulary Design + +Status: **approved; slices 1-4 implemented**. The vocabulary and decisions +D1-D7 in section 5 are settled. Sections 2 and 4 state the design; each carries +an *As implemented* note recording what actually landed and what was deferred. +Trust the notes over the design text where they differ. + +This document defines the ownership decision vocabulary that makes lifetime +handling automatic for future features. Design goal, stated by the maintainer: + +> A new construct (e.g. a Zig-style `catch` block) should auto-handle its +> payload's lifetime — consumed, copied, dropped — without writing ownership +> code, unless it invents a genuinely new lifetime relationship. + +This is the design record for framework workstream 6 and the ownership slice +of `COMPILER_FRAMEWORK_REPORT.md`. Facts below come from inspected code +(paths relative to `compiler/`); baseline findings are recorded for +traceability. + +## 1. Current state + +### What exists and works + +The type level already has a three-axis capability model in +`internal/semantics/typeinfo/capabilities.go`: + +| Query | Meaning | +| --- | --- | +| `IsImplicitCopyType` | value copies implicitly on read use | +| `IsNoCopyType` | contains ownership; copy forbidden | +| `NeedsDrop` | scope cleanup must destroy runtime state | + +Ownership is the enforcement authority: every consumption flows through +`useKind` (`useRead`/`useCopy`/`useConsume`, `ownership/expr.go:17`) and +cleanup lands in `ownershipresult.CleanupPlan` keyed to exact CFG sites. +MIR consumes the plan; the backend emits drops. For-in is the model citizen: +the typechecker publishes `ForIteration.Carrier` and ownership consumes it +without re-deriving. + +### What is broken: decisions are scattered, not published + +The typechecker publishes *shape* evidence; **all use-kind decisions are +re-derived in ownership from AST shapes plus hardcoded per-node rules**: + +| Decision | Where it actually lives today | +| --- | --- | +| Ordinary call argument consume-vs-copy | re-derived in `ownership/expr.go:344-351` from `fn.Params` + `IsImplicitCopyType`; typechecker already matched params in `checkCall` | +| Binding/assignment/return consumption | hardcoded `useConsume` (`ownership.go:501,530,688`); typechecker publishes nothing | +| String-concat operand roles | shape published (`StringConcatenations`), use kinds re-derived (`ownership/expr.go:111-115`) | +| Match-arm carrier move | re-derived from binding types (`ownership.go:664-671`); typechecker had all inputs | +| `alloc` argument consumption | hardcoded (`ownership/expr.go:266-274`), absent from `CompilerCalls` evidence | +| Variant/array-literal/as-cast consumption | hardcoded per-node-type (`ownership/expr.go:96-122`) | +| Drop obligations | `NeedsDrop` checked at six separate ownership sites | + +Capability model gaps (each is a place where "auto" silently breaks): + +| # | Gap | Status | +| --- | --- | --- | +| G1 | `IsNoCopyType` is dead in production; move-only is re-derived as `!IsImplicitCopyType` everywhere — but structs with only scalar fields are neither implicit-copy nor no-copy (explicit-copy middle class), so the negation is not equivalent | **Closed.** All three predicates are deleted. `OwnershipCapability.Copy` names the three classes directly, so there is nothing left to negate | +| G2 | `NeedsDrop` implemented twice: `typeinfo/capabilities.go:356` and backend `drop_emit.go:303-373` over the IR type table — they agree today, drift tomorrow | **Closed as misfiled.** They answer different questions over different type universes: ownership decides *whether* a value is dropped and publishes that as a `mir.Drop`; the backend walk only decides how deep to expand one it was already handed, and can never originate a drop. Stated at `typeNeedsDrop` and in [`type-capabilities.md`](type-capabilities.md) | +| G3 | Owned interface values: source says no-drop, backend raw-frees through a `TypeOwnedPtr`-to-interface special case — drop policy lives only in the backend | **Open.** This one is a real policy decision made in the backend, and unlike G2 it is not expansion | +| G4 | `FuncType` has no ownership character (closure move-only? nothing answers) | Open — waits on lambdas existing | +| G5 | `TypeParameterType` treated move-only even when instantiated with a copyable argument — generics over `T` cannot copy | Open — a language decision, not a refactor | +| G6 | `NoneType` not implicit-copyable — `none` is move-on-use | Open — a language decision | +| G7 | Enum-payload copyability is non-compositional: same struct copyable as variant payload, move-only standalone (intentional, but must be stated as vocabulary, not accident) | **Stated.** Carried explicitly as the `enumPayload` flag through `ownershipCapability`, so it is now vocabulary rather than an accident of two predicates disagreeing | +| G8 | Cycle-guard inconsistency in `IsNoCopyType` (`seen` never deleted on exit) | **Closed.** One walk, one guard | +| G9 | "Dynamic array owns" encoded three times (implicit-copy, no-copy, needs-drop, plus backend `Length == ""`) | **Closed on the source side.** One `*ArrayType` case in `capability_walk.go`. The backend still recognises the shape, which is expansion, per G2 | + +Cleanup machinery fragilities found along the way: + +- **Three drop channels**: planned drops (`CleanupPlan`), embedded flags + (`DropTarget`/`DropRoot`/`DropBase`), and `temporaryDrops` flushes. +- **Return asymmetry**: returns emit no CFG scope-exit sites + (`cfg/build.go:133-139`); `cleanupBeforeReturn` re-walks the scope chain, + duplicating `applyBlockExit` logic. +- **`hir.Return.Cleanup` is vestigial** — never populated by lowering. +- **`MatchCarrierMoves` is published but never consumed.** + +## 2. Proposed vocabulary + +Two closed sets. Everything else is derived. + +### 2.1 Type capability (per `typeinfo.Type`, total — every type answers) + +```go +type OwnershipCapability uint8 + +const ( + CapabilityTrivial OwnershipCapability = iota // copy free, no drop + CapabilityCopy // implicit copy, no drop + CapabilityExplicitCopy // copy via explicit op, no drop + CapabilityMove // move-on-use, needs drop +) + +// One canonical walker; every other query derives from it. +func OwnershipCapabilityOf(typ Type) OwnershipCapability +``` + +Mapping from today's model: + +| Type | Capability | +| --- | --- | +| scalars, `RawPtr`, `CStr`, `Allocator` | `Copy` | +| immutable `Ref` | `Copy` (borrow is a use-kind concern, not type concern) | +| mutable `Ref` | `ExplicitCopy`? — **decision D1** | +| `String`, `OwnedPtr`, owner arrays | `Move` | +| `Optional`, `Array`, `Struct`, `Enum` | compose from inner/fields/cases | +| `Interface` | **decision D2** (source-level `Move` + drop, or backend-owned) | +| `FuncType` | **decision D3** (proposed: `Move`, closures own captures) | +| `TypeParameter` | **decision D4** (proposed: conservative `Move` + copy when instantiated copyable — requires instantiation-aware query) | +| `None` | `Copy` (**decision D5**, fixes G6) | + +Rules: + +- `IsImplicitCopyType`, `IsNoCopyType`, `NeedsDrop` become **derivations** of + `OwnershipCapabilityOf` (or are deleted — **decision D6** on `IsNoCopyType`, + which is dead today). +- The backend's `typeNeedsDrop` is **deleted**; MIR carries drop obligations + published by ownership, and the backend trusts them. One implementation of + "what needs drop" (fixes G2/G3/G9). +- Cycle guards are uniform (fixes G8). + +**As implemented (slice 1).** The dependency runs the other way round from the +design text. `OwnershipCapabilityOf` (`typeinfo/capabilities.go:437`) *composes* +`NeedsDrop`, `IsImplicitCopyType` and `noCopyType` rather than being the walker +they derive from. That was deliberate — it makes the consolidation behavior- +preserving by construction, so the existing suite proves it — but it means the +single-walker half of the design is still outstanding. New code consumes +`OwnershipCapabilityOf`; the three predicates remain the behavioral source. + +`IsNoCopyType` is unexported to `noCopyType`, not deleted, so D6 is half done: +the capability struct is the only public spelling, but a second implementation +of the copy question still exists behind it. + +Landed as written: G6 (`NoneType` joins the implicit-copy set, +`capabilities.go:104`) and G8 (the cycle guard releases with `defer delete`, +`capabilities.go:100`). + +### 2.2 Use kind (per value use, published once by the typechecker) + +The `UseKind` vocabulary lives in `typeinfo` beside `OwnershipCapability` +(the per-use counterpart of the per-type capability); the published map +lives in the typecheck result: + +```go +type UseKind uint8 + +const ( + UseRead UseKind = iota // borrow-ish observation, value unchanged + UseCopy // value copied; source unchanged + UseMove // value consumed; source dead +) + +// Published in typecheckresult.Result: +ValueUses map[ast.NodeID]UseKind +``` + +Plus two published facts that remove hardcoded per-node rules: + +```go +// consumption of binding/assign/return initializers is derivable from +// UseKind on the initializer expression — no separate map needed. +// Match-arm carrier moves: +MatchArmMoves map[ast.NodeID]UseKind // per arm body: UseMove or UseCopy +// String-concat operand roles: +// left UseMove, right UseRead — publish alongside StringConcatenations +``` + +What becomes deletable in ownership: `checkCallArgument`'s param re-derivation +(typechecker publishes argument `UseKind` during `checkCall`), hardcoded +`useConsume` at binding/assign/return, concat operand re-derivation, +`matchArmMovesCarrier`, `alloc` special case, and the per-node consumption +switch in `ownership/expr.go:96-122`. + +What ownership **keeps**: flow-sensitive enforcement — use-after-move, use +while borrowed, loan conflicts, destroy-while-borrowed. Those are *state +machines over published classifications*, which is ownership's real job. + +### 2.3 The rule new features follow + +```text +new syntax node + → children use standard field types (Expr, *BlockStmt, …) → traversal free + → typechecker classifies each value use with UseKind during normal checking + → ownership consumes ValueUses + type capabilities → cleanup plan automatic + → MIR lowers plan; backend trusts it + → zero ownership code, unless the construct invents a new lifetime relation +``` + +For `catch err { body }`: payload binding is an ordinary `UseMove` (or +`UseCopy` per capability) into the arm scope; cleanup at scope exit is already +universal. Only the variant construction is catch-specific. + +## 3. Validator contract + +One canonical validator at the ownership boundary: + +```go +// ownershipresult.Validate checks published evidence and plan shape only. +func (r Result) Validate(types *typecheckresult.Result, cfg *cfg.Module) error +``` + +Invariants: + +1. Every ownership-relevant expression (capability ≠ Trivial) has a `UseKind` + entry — missing entry is an internal error, not silent fallback. +2. Every `UseKind` is legal for the expression type's capability (no `UseCopy` + of a `Move` type without explicit-copy context). +3. Every drop-needing symbol appears in exactly one cleanup site per path; + no symbol in both `AfterScope` and `BeforeReturn` for the same path. +4. Every cleanup site key references an existing CFG site / node. +5. Plan maps contain no stale entries after regeneration (existing `delete` + convention becomes validator-checked). + +Invalid source remains diagnostics; validator failure is a compiler bug. + +**As implemented (slice 4).** `Validate(types, bindings, graphs)` runs in the +pipeline after every ownership pass, skipped when the module already has errors, +and reports failure as `ICE0002` rather than a source diagnostic. Invariants 2, +4 and 5 are implemented as written; 5 collapses into 4, since a stale entry is +exactly a key that no longer names a program point. + +Invariant 1 is implemented over the sites that actually need publication: a use +kind is published where the decision requires type information the typechecker +holds — call arguments, intrinsic operands, match carriers — and the validator +requires an entry for every argument in `EffectiveCallArguments`. The remaining +uses are structural: a binding moves, a condition reads, an index reads. Those +follow from syntactic position alone, so publishing them would add ceremony +without adding knowledge, and the validator does not demand entries for them. + +Invariant 3 is **deferred**. Proving that a symbol is dropped exactly once per +path is a CFG dataflow walk — the analysis ownership already performs. Repeating +it inside the validator would make the validator a second implementation of the +thing it checks, which §6 rules out. Double-drop stays covered behaviorally by +the ownership suite and the `x_test` drop fixtures. + +`publishedUse`'s capability fallback is retained, not removed. It is reachable +only where the typechecker exited before publishing, which means the program +already has diagnostics; raising a compiler-bug error there would blame the +compiler for source the user was already told is invalid. The validator is the +single place that treats a missing classification as a bug. + +## 4. Migration slices + +Each slice keeps the full suite green and is independently reviewable. + +1. **Capability consolidation** — `OwnershipCapabilityOf` as single walker; + re-point `IsImplicitCopyType`/`NeedsDrop` at it; resolve D1–D5; delete + backend `typeNeedsDrop` (MIR already carries obligations); fix G6/G8. + No behavior change intended; existing suite + fixtures prove it. + + **Deferred out of slice 1: the backend `typeNeedsDrop` deletion.** It still + exists at `backend/llvm/drop_emit.go:303` with ten call sites across + `drop_emit.go` and `emitter.go`. So **G2** (two implementations of "what + needs drop") and **G3** (owned-interface drop policy living only in the + backend) are both still open, and **D2** is unimplemented — an owned + interface is still raw-freed by a backend special case rather than by a + source-published obligation. + + Deleting the walker is not a plumbing change: it moves drop policy for + owned interfaces into the source language, which is observable behavior and + needs `x_test` fixtures of its own. It is scheduled as separate work, not as + a leftover of this slice. +2. **Publish use kinds** — typechecker publishes `ValueUses` for call + arguments, bindings, assignments, returns, concat operands, match arms. + Ownership consumes; delete the re-derivations listed in §2.2. Largest + slice; behavior-preserving with focused regressions per decision site. +3. **Cleanup unification** — fold return-cleanup into CFG scope-exit sites + (removing the asymmetry) or explicitly document the split; delete + vestigial `hir.Return.Cleanup`; resolve the three-drop-channels question + (**decision D7**: keep embedded flags as MIR-lowering detail but single + source in plan). +4. **Validator** — `ownershipresult.Validate` at the phase boundary + tests. + +## 5. Decisions (resolved by maintainer) + +| # | Decision | Resolution | +| --- | --- | --- | +| D1 | Mutable ref | **`ExplicitCopy`, with the single-active-`&mut` invariant**: conceptually only one active `&mut` may exist. A use of a mutable ref either moves it, or is an error if it would create a second active `&mut`. Reservations (temporary, scope-bound reborrows) remain the only sanctioned exception, enforced by the loan machinery. "Copy a `&mut`" is not a second owner — it is an error. | +| D2 | Owned interface drop | **Source-level `Move` + needs-drop.** Structural rule: owned interface allocates → move. Backend raw-free special case becomes the lowering of a source-published drop obligation, not an independent policy. | +| D3 | Function values / closures | **Structural rule applied to the capture set**: closure capability composes from captured state like a struct — captures only Copy state → Copy; captures owned/borrowed state → Move. No special policy. | +| D4 | Generic `T` | **Instantiation-aware.** The structural rule needs a concrete type; at declaration `T` is conservative `Move`, at instantiation the query checks the bound type and applies the rule. | +| D5 | `NoneType` | **`Copy`** — contains nothing; structural rule says trivially copyable. Fixes the current move-on-use oddity. | +| D6 | `IsNoCopyType` | **Delete.** One capability walker; every query derives from it. | +| D7 | Drop channels | **Single source: `CleanupPlan` is authoritative.** Embedded flags become MIR-lowering details filled from the plan. | + +The governing rule stated by the maintainer: + +> Ownership capability is baked into the type itself. Scalar → copyable → +> copy. Contains a reference, pointer, or allocation inside → move. Check +> the type, apply the rule. No per-type policy tables. + +Consequence: capability is a **pure structural function of the type** — +compositional over fields/cases/inner, instantiation-aware for generics, +with exactly one implementation that the whole compiler consumes. + +## 6. Non-goals + +- No borrow-checker redesign; loan machinery stays. +- No change to `place` semantics or `Local` provenance rules. +- No new language surface; this is evidence plumbing with behavior preserved. +- Validators never re-derive semantic decisions; they check published shape. diff --git a/docs/compiler-framework/semantic-results.md b/docs/compiler-framework/semantic-results.md new file mode 100644 index 00000000..04b06373 --- /dev/null +++ b/docs/compiler-framework/semantic-results.md @@ -0,0 +1,409 @@ +# Semantic Result Ownership Inventory + +Status: **migration complete**. + +This document records baseline `project.SemanticInfo` ownership before framework migration. It is the Step 1 design record for the [compiler framework roadmap](README.md). Facts below came from inspected producers, consumers, reset paths, scheduler prerequisites, and incremental LSP reuse. Approved migration progress is recorded below; baseline tables remain for rationale and traceability. + +## Approved migration progress + +Completed slices: + +1. Parsed AST remains immutable during default expansion. `typecheckresult.Result.EffectiveCallArguments` stores source-plus-default argument evidence, and `Module.RebuildTypedASTIndex` indexes source and generated expression trees. +2. `Module.Typechecking` now owns one `typecheckresult.Result` per base-typecheck generation. It contains `ExpandedDefaultBindings`, `EffectiveCallArguments`, `InterfaceImplementations`, `ImplicitConversions`, and `ImplicitCallArguments`. These fields and `InterfaceImplementation` were deleted from `project.SemanticInfo`; no compatibility maps or aliases remain. +3. `typechecker.Check` publishes a fresh result, `resetToPhase` discards it below `Typechecked`, partial semantic consumers use its canonical effective-argument fallback, and HIR consumes its evidence strictly. +4. Intrinsic dispatch, string concatenation classification, and variant construction evidence moved into the same result. `CompilerCall` and `VariantConstruction` moved with their maps; eager constant evaluation treats a missing pre-typecheck result exactly like the previous empty proof map. +5. Base `CaseTests` and `Matches` moved into `typecheckresult.Result`, along with `CaseTest`, `Match`, `MatchArm`, `MatchBinding`, explicit match projections, and canonical `MatchCases` validation. `flowresult.Result.CaseTests` uses `flowresult.CaseTest`, which embeds base case evidence and owns flow-only payload paths. +6. Base `ExprTypes` moved into `typecheckresult.Result`. `Module.BaseExprType` is canonical base lookup; `Module.EffectiveExprType` gives flow evidence precedence and falls back to base evidence. `flowresult.Result.ExprTypes` remains distinct flow-refined evidence. +7. Staged collection, binding, resolution, and type-dependent symbol evidence moved into `bindingresult.Result`: `BlockScopes`, `NodeSymbols`, `MethodsByReceiver`, `MethodsByDecl`, and `OperationFunctions`. Generated defaults and selectors write the same canonical node-symbol table; no precedence accessor or duplicate map exists. +8. `SemanticInfo` and mixed `Module.ConstValues` storage were deleted. `Module.Constants` now owns `constantresult.Result`, physically separating authoritative post-typecheck `ModuleValues` from mutable pretypecheck/local `QueryCache` entries. `FinalizeValues` republishes top-level constants without duplicate cache entries; fingerprints and MIR consume only authoritative values. Module bindings carry defining identity, so foreign queries read owner publication without copying into consumer cache. +9. Typechecker evidence cleanup removed redundant interface method name/owner keys, replaced copied match case descriptors with `CaseCount`, moved `PayloadPath` to flow-owned evidence, and made match field/whole-payload projection explicit with an invalid sentinel consumed exhaustively. + +All inventoried semantic fields and constant-evaluation artifacts now have explicit owners. Semantic-result migration is complete. + +## Current lifecycle + +`collector.collectModule` calls `Module.ResetSemanticData`, publishing fresh +`Module.Bindings` and `Module.Constants` at start of one semantic generation. +Collector, binder, resolver, and typechecker stage one shared binding/scope graph. +Eager constant evaluation stores provisional top-level values in `QueryCache`; +`FinalizeValues` recomputes them with final types and publishes them exclusively in +`ModuleValues`. Imports and prelude reach `Typechecked` before consumer constant +evaluation; foreign symbols read defining-module publication directly. Later +CFG/flow/HIR queries may add only consumer-local entries to `QueryCache`. + +`Module.resetToPhase` follows approved production contract: + +```text +retained <= Parsed -> clear ModuleScope, Bindings, Constants, and later results +exact later reuse -> retain completed artifacts without phase re-entry +``` + +Intermediate semantic re-entry remains unsupported because shared symbol objects +cannot be rewound independently. Production invalidation uses exact artifact reuse +or resets to `Parsed`; that reset discards both constant stores together. + +## Baseline field ownership matrix + +This table records pre-migration storage and problems; current ownership is tracked in Approved migration progress above. + +| Field | Baseline writers / complete phase | Main consumers | Baseline contract problem | +| --- | --- | --- | --- | +| `BlockScopes` | resolver / `Resolved` | typechecker, CFG constant evaluation, flow, definite-init, ownership, usage, HIR, LSP | Scope topology is resolver-owned, but contained symbols later gain types, `Used`, and `RequiresMutable` state consumed by usage/HIR. | +| `ResolvedSymbols` | collector, resolver, typechecker / `Typechecked` | typechecker, semantic fingerprint, flow, definite-init, ownership, HIR, LSP | Name suggests resolver result, but enum declarations, selectors, and expanded defaults have different writers. | +| `ExpandedDefaultBindings` | typechecker / `Typechecked` | typechecker, ownership, HIR | Marker requires paired symbol provenance, with copied type/lowering evidence where available. Parsed reset can delete marker while retaining expanded AST. | +| `ExprTypes` | typechecker / `Typechecked` | typechecker, const evaluation, flow, ownership, HIR, LSP | Base type is distinct from `Flow.ExprTypes`; direct base-map reads coexist with `EffectiveExprType` precedence. | +| `CaseTests` | typechecker / `Typechecked` | const evaluation and flow transfer | Uses `flowresult` type but is stored in base semantic aggregate; flow creates second case-test map. | +| `Matches` | typechecker / `Typechecked` | CFG, flow, definite-init, ownership, HIR | Uses `flowresult` type despite being base typechecker evidence. Presence can coexist with some diagnostics. | +| `ConstValues` | constant evaluation, post-typecheck finalization, later constant queries / no global completion phase | const evaluator cache, semantic fingerprint, CFG and HIR expression evaluation, MIR | One map mixes finalized module constants with working-cache entries that can still appear during CFG/HIR. | +| `MethodSets` | collector membership; binder/resolver/typechecker mutate symbols / `Typechecked` symbol state | typechecker, semantic fingerprint, LSP | Catalog ownership differs from mutable `Type`, `Used`, `Initializing`, and `RequiresMutable` state of symbols inside it. | +| `MethodSymbol` | collector mapping; binder/resolver/typechecker mutate symbol / `Typechecked` symbol state | binder, resolver, typechecker, flow, ownership, HIR, LSP | Stable declaration identity is collection output; pointed-to mutable symbol state advances later. | +| `InterfaceImplementations` | typechecker / `Typechecked` | HIR | Clear typechecker proof; strongest first extraction candidate. | +| `ImplicitConversions` | typechecker / `Typechecked` | HIR | Clear typechecker proof. | +| `ImplicitCallArguments` | typechecker / `Typechecked` | typechecker borrow checks, HIR | Clear typechecker call-adaptation proof. | +| `CompilerCalls` | typechecker / `Typechecked` | HIR | Clear typechecker dispatch proof; entry may coexist with later call diagnostics. | +| `StringConcatenations` | typechecker / `Typechecked` | ownership, HIR | Clear typechecker operation-classification proof. | +| `VariantConstructions` | typechecker / `Typechecked` | const evaluation, flow, ownership, HIR | Clear typechecker construction proof. | +| `OperationFunctions` | binder append and sort / `Bound` | LSP completion | Binder-owned catalog derived from collected top-level function symbols. | + +## Exact producer groups + +### Collection + +Baseline collection output formerly inside `SemanticInfo`: + +- `MethodSets` +- `MethodSymbol` +- enum variant declaration entries in `ResolvedSymbols` + +Collection creates symbol shells. Binder and typechecker later complete or mutate +those symbol objects. Moving maps does not make pointed-to symbols immutable. + +### Binding + +Binder produces: + +- bound types on collected symbols; +- sorted `OperationFunctions` catalog. + +`OperationFunctions` is only used by LSP completion. Compiler semantic phases use +normal scopes and symbols instead. + +### Resolution + +Resolver produces: + +- `BlockScopes` +- most `ResolvedSymbols` entries. + +Typechecker later extends `ResolvedSymbols` for type-dependent selector resolution +and cloned default expressions. Therefore `ResolvedSymbols` cannot honestly move to +a resolver-only result unchanged. + +### Constant evaluation + +Constant evaluation owns `ConstValues`, but map mixes two lifetimes: + +1. finalized module-scope constants after typechecking calls `FinalizeValues`; +2. lazy working-cache entries created by later constant queries during CFG and HIR. + +`FinalizeValues` deletes and recomputes module constants but intentionally retains +local cache entries. `EvaluateExpr` can add entries after `Typechecked`, so map has no +global completion phase. Fingerprinting and MIR need authoritative module constants; +constant queries need mutable cache. Migration must distinguish these contracts even +if implementation retains one staged artifact. + +### Typechecking + +Clear typechecker-owned base types and lowering proofs: + +- `ExprTypes` +- `CaseTests` +- `Matches` +- `InterfaceImplementations` +- `ImplicitConversions` +- `ImplicitCallArguments` +- `CompilerCalls` +- `StringConcatenations` +- `VariantConstructions` +- `ExpandedDefaultBindings` +- type-dependent and cloned-default entries added to `ResolvedSymbols`. + +Flow-refined facts remain in separate `flowresult.Result`. Ownership cleanup remains +in separate `ownershipresult.Result`. + +## Consumer boundaries + +### Direct evidence consumption + +Current downstream phases mostly consume recorded evidence correctly: + +- HIR consumes conversions, compiler-call dispatch, interface slots, string + concatenation, variant construction, expanded defaults, match evidence, and + resolved symbols. +- ownership consumes resolved symbols, expanded-default provenance, operation + classification, variant construction, effective types, and match evidence. +- CFG consumes resolved match-case indexes through `typecheckresult.Result.MatchCases`. +- MIR consumes finalized constant values and separate ownership cleanup plans. + +Migration must preserve these direct evidence paths. It must not make downstream +phases resolve methods, conversions, variants, or call adaptation again. + +### Existing fallbacks + +Some consumers intentionally or defensively fall back when evidence is missing: + +- HIR and ownership may perform lexical symbol lookup for identifiers. +- flow assignment lookup may fall back to scope lookup. +- LSP symbol/type queries reconstruct import, field, or lexical context when exact + semantic evidence is unavailable. + +Each fallback requires review during migration. Compiler fallbacks may hide broken +phase contracts; LSP fallbacks may be necessary for incomplete source. Do not remove +both categories mechanically. + +### Partial results after diagnostics + +Pipeline advances modules through CFG, flow, definite initialization, and ownership +before project error gate. HIR alone is suppressed when diagnostics already contain +errors. Therefore `Module.Phase == Typechecked` does not mean every typechecker proof +exists or whole result is valid. + +Missing evidence may mean: + +- construct does not require that evidence; +- invalid source prevented publication; +- producer skipped after an earlier diagnostic; +- compiler violated internal contract. + +Extracted results need explicit per-entry presence and failure semantics. Validators +must not convert expected diagnostic-driven absence into internal panic, while valid +source missing mandatory proof must fail clearly. + +### Shared mutable symbol and scope graph + +Maps are not only ownership concern. Many results point to same `*symbols.Symbol` +and `*symbols.Scope` graph. State mutates across phases: + +- binder sets symbol types; +- resolver sets `Initializing`, `Used`, and scope contents; +- typechecker may infer types and set `RequiresMutable`; +- usage and HIR consume later state. + +LSP shallow reuse preserves these pointers. Splitting maps into result structs does +not make symbols immutable or reset-safe. Migration needs explicit stable identity, +mutation owner, and snapshot contract for shared symbol graph. + +## Reset and incremental findings + +### Finding 1: parsed reset can retain expanded AST without provenance + +Severity: **high correctness risk**. + +`typechecker.expandCallDefaults` mutates caller AST by appending cloned default +expressions to `CallExpr.Args`. It also records: + +- cloned `ResolvedSymbols` entries; +- `ExpandedDefaultBindings` markers; +- copied expression types, conversions, and interface evidence. + +Incremental invalidation can reset module to `Parsed`. That reset retains AST but +clears complete `SemanticInfo`. Re-typechecking sees argument count already expanded, +so expansion does not run again and deleted provenance is not reconstructed. + +Potential impact: imported defaults referencing declaration-module bindings may be +resolved as caller syntax, remain unresolved, or lose lowering/ownership evidence. +Current tests cover initial imported-default compilation, not parsed reset and +recompile. This failure path is source-derived; no failing reset/recompile case was +executed during inventory. + +Relevant code: + +- `internal/semantics/typechecker/check_call.go`: `expandCallDefaults`, + `copyExpressionEvidence` +- `internal/frontend/ast/clone.go`: `SubstituteExpr` +- `internal/project/modules.go`: `resetToPhase` +- `internal/lsp/state.go`: `seedReusableModules` +- `internal/pipeline/pipeline.go`: `invalidateSemanticDependents` + +### Finding 2: intermediate semantic reset is non-idempotent + +Severity: **high correctness and contract risk**. + +Reset to any phase after `Parsed` retains complete `SemanticInfo`, scopes, and symbol +state. Re-entering later phases can corrupt results: + +- reset to `Collected`, then binder appends duplicate `OperationFunctions`; +- reset to `Bound`, then resolver redeclares parameters in retained scopes; +- reset to `Resolved`, then const evaluation can return stale cached values; +- reset to `ConstEval`, then typechecker runs with retained proof maps and symbol + mutations. + +Current production downgrade paths appear limited to exact-phase reuse or `Parsed`, +so no production intermediate re-entry was established. `ResetModule` still claims +general retained-phase behavior. Existing tests cover `Parsed` and later retained +artifacts, but no reset/re-entry tests cover unsafe `Collected`, `Bound`, `Resolved`, +or `ConstEval` checkpoints. Those intermediate checkpoints are presently +non-reconstructible. Result separation must give each artifact exact reset gate or +reduce supported reset contract to safe checkpoints. + +### Finding 3: LSP snapshots alias retained compiler state + +Severity: **medium architecture risk**. + +LSP reuse stores module pointers or shallow module copies. Retained maps and pointers +can still alias: + +- AST +- imports +- module scope +- semantic maps +- typed AST index +- CFG/HIR/MIR artifacts + +LSP compilation is mostly serialized, so no concrete race was reproduced. Still, +old context does not mean immutable snapshot: new compilation can mutate AST and +artifacts reachable from older context. Snapshot-generation correctness is unproven. +Result migration must state whether artifacts are immutable, uniquely owned, cloned, +or transferred. + +### Finding 4: synthetic clone IDs are unique but schedule-dependent + +Synthetic AST IDs use atomic allocation, preventing concurrent duplicate allocation +under practical node counts before counter wrap. Exact values depend on goroutine +scheduling; no wrap or parser-namespace bound is enforced. Current semantic +fingerprints do not include them. Future serialized artifacts, tests, or caches must +not assume stable or unconditionally collision-free synthetic IDs. + +## Scheduler and concurrency contract + +`advanceModulesThrough` advances ready modules concurrently, one phase per batch, +then waits before invalidation. Import prerequisites ensure imported module semantic +facts are complete before caller typechecking reads imported defaults. + +Current safe assumptions: + +- independent modules mutate separate semantic maps; +- imported semantic reads happen after required import phase; +- context module/type indexes use `CompilerContext.mu`; +- diagnostics and metrics are synchronized; +- semantic invalidation runs after worker join. + +Every extracted result must preserve per-module ownership and import readiness. A +result must not introduce shared mutable maps across independently scheduled modules. +Focused race tests are required for each migration slice. + +## Minimal proposed boundaries + +These are design candidates, not approved types. + +### Candidate A: declaration catalog + +Cohesive current facts: + +- method membership by receiver; +- declaration-to-method symbol identity; +- operation-function catalog after binding. + +Open question: one staged callable catalog from `Collected` through `Bound`, or +separate collection and binding outputs? Source proves distinct production points; +review must weigh that boundary against added navigation/type cost and whether either +output has independent consumers, reset, or reuse. + +### Candidate B: binding table + +Cohesive current fact: AST identity to semantic symbol identity. + +A strict resolver result is inaccurate because collector and typechecker also add +entries. Options: + +1. one staged binding table with explicit writer phases; +2. separate declaration, resolution, and type-selection maps plus one canonical + query operation; +3. change selector/default representation so final binding table has one producer. + +Option 2 adds maps and query policy. It is acceptable only if it removes ambiguity +rather than spreading lookups. Option 3 may produce cleanest contract but has largest +behavioral scope. + +### Implemented C: constant result + +`constantresult.Result` owns physically separate `ModuleValues` and `QueryCache` +maps. A top-level symbol lives in the query cache during eager pretypecheck +evaluation, then moves to authoritative module values during finalization. Local and +lazy entries remain cache-only. Foreign symbols resolve their defining module and +read its published values without consumer caching. Fingerprinting and MIR consume +only module values; CFG/HIR queries evaluate without duplicating published entries. + +### Candidate D: typechecker result + +Strong cohesive output: + +- base expression types; +- conversion and call-adaptation proof; +- intrinsic/compiler-call dispatch; +- string operation classification; +- interface implementation slots; +- variant construction and match evidence; +- base case-test evidence; +- default-expansion provenance if AST expansion remains. + +Possible extraction sizes range from one proof map to cohesive typechecker result. +Choice must compare migration blast radius, import-cycle constraints, partial-result +semantics, and actual reset lifetime. Exact package location must avoid import cycles +without creating wrapper accessors. + +## Recommended migration order after review + +1. **Fix or redesign default expansion lifetime first.** Do not move provenance maps + while AST/reset contract is inconsistent. +2. **Define supported reset checkpoints.** Either implement field/result-specific + reset or reject unsafe intermediate retention. +3. **Extract approved typechecker proof group at smallest safe migration size.** Move + fields and all consumers together; delete old maps immediately. +4. **Completed:** authoritative module constants are distinct from post-typecheck query cache. +5. **Resolve binding-table ownership.** Do not label current multi-writer map as + resolver-only result. +6. **Move callable catalog only if resulting boundary reduces coupling and does not + create decorative result types.** +7. **Add result validators and concurrency/determinism tests after each owner is + explicit.** + +## Required validation per migration slice + +- focused producer and consumer package tests; +- diagnostic codes, text, spans, ordering, and deduplication preservation; +- `ResetModule` tests at every supported retained checkpoint; +- unchanged semantic fingerprint tests; +- imported default expansion followed by parsed reset and recompile; +- LSP unchanged-module reuse and semantic-dependent invalidation; +- repeated-output determinism where generated names or dumps are affected; +- `go test -race` for project, pipeline, LSP, and touched semantic packages; +- full `go test ./...` before review completion. + +## Approved manual decisions + +Maintainer approved these directions before product migration: + +1. **Parsed versus transformed AST:** Must parsed AST remain immutable, with expanded + defaults stored in a separate typed artifact, or may typechecker mutate it if reset + restores pristine syntax and provenance? +2. **Reset contract:** Keep every intermediate semantic checkpoint, or restrict reuse + to checkpoints proven reconstructible given current non-idempotent re-entry? +3. **Binding ownership:** Prefer one staged binding table or separate phase maps with + canonical query policy? +4. **Migration granularity:** Move one proof group or one cohesive typechecker result? + Choose smallest slice that preserves consumers without duplicate maps/wrappers. +5. **LSP snapshots:** Are retained artifacts immutable, uniquely owned by latest + context, or copied on reuse? What generation may mutate shared AST/symbol graph? +6. **Partial results:** After semantic diagnostics, which evidence remains published + and which downstream analyses may consume it? +7. **Missing evidence:** Which absence means prior user diagnostic, not-applicable, + recoverable LSP state, or internal invariant violation? +8. **Symbol graph:** Which phase owns mutable symbol/scope state, and must stable + pointer/`SymbolID` identity survive result boundaries and resets? +9. **Constants:** Separate authoritative module values from query cache physically, or + retain one artifact with explicit entry-kind/finalization contract? +10. **Synthetic IDs:** Are practical atomic uniqueness and schedule dependence enough, + or should namespace/wrap guarantees become enforced invariants? + +Approved answers: parsed AST immutable; exact artifact reuse or reset to `Parsed`; one staged binding table; smallest coherent typechecker slices; old LSP generations immutable; partial semantic evidence may continue through semantic analyses but not HIR/backend; missing mandatory evidence on valid input is invariant failure; symbol identity is stable per generation and read-only after typecheck; finalized module constants differ from mutable query cache; synthetic IDs require namespace/wrap enforcement before persistence. diff --git a/docs/compiler-framework/type-capabilities.md b/docs/compiler-framework/type-capabilities.md new file mode 100644 index 00000000..ca69a665 --- /dev/null +++ b/docs/compiler-framework/type-capabilities.md @@ -0,0 +1,126 @@ +# Type capabilities — what got consolidated, and what deliberately did not + +Step 4 of the maintainability plan asks that a type receive **one** canonical +answer per shared semantic question, and that consumers stop walking type +structure themselves. It also draws a line: *centralize only semantic questions +shared by multiple callers, and do not create a giant capability object for +unrelated properties.* + +This document is the inventory that line was drawn against. It exists so the +decisions below are not re-litigated by the next person who notices that two +functions in `typeinfo` look similar. + +## The one consolidation that happened + +Copy class and drop obligation were three separate recursive walkers: + +| Removed | What it answered | +| --- | --- | +| `IsImplicitCopyType` | does this duplicate on use without an explicit operation | +| `noCopyType` | does this refuse duplication entirely | +| `NeedsDrop` | does this carry a destructor obligation | + +All three walked the same structure with the same cycle guard, and every +consumer asked at least two of them about the same type. They are now one +traversal, `ownershipCapability` in `capability_walk.go`, returning +`OwnershipCapability{Copy, Drop}`. `OwnershipCapabilityOf` is the only public +spelling; there are no `IsX` wrappers left behind for old callers. + +The quantifiers differ inside that one walk and could not be collapsed further: +implicit copy is *for all* members, while no-copy and drop are *there exists*. +A `enumPayload` flag carries the one context the answer depends on — a struct +copies implicitly as an enum payload but never as top-level bulk storage. + +Equivalence with the three predicates was proven by a differential test over 283 +constructed types, then frozen as the `capabilityGolden` table so the answers +cannot drift silently. + +**Consumers:** 20 non-test call sites, in `typechecker`, `ownership`, and +`hir/lower`. + +## Already consolidated before this step + +`ContainsReference`, `ContainsStoredReference`, `ContainsAbstractSelf`, +`ContainsTypeParameter`, `ContainsInvalid` and `ContainsNamedEnum` are six +five-line wrappers over one shared `containsType` walker in `relations.go`, each +supplying a traversal mode and a predicate. That is the target shape, reached +already. Nothing to do. + +## Deliberately not consolidated + +### Sized and Lowerable + +These two are the tempting merge: both recursive, both over the same structure, +both with a cycle guard. They must stay apart, and the reason is not style. + +| | `IsSizedType` | `IsLowerableType` | +| --- | --- | --- | +| Guard key | `*DefinedType` | underlying `Type` | +| Answer on a cycle | `false` — a type containing itself inline has no size | depends on how the cycle was reached; through a pointer it is representable | +| Context parameter | none | `throughIndirection` | +| Interface | not sized | lowerable | +| Type parameter | sized | not lowerable | + +A linked list is lowerable and not sized. One traversal cannot hold both answers +without carrying two guards and two cycle rules, at which point it is two +walkers sharing a function body. Copy and drop merged because they genuinely +share a walk and a cycle rule; these do not. + +This is recorded as a comment above `IsSizedType` so the argument travels with +the code. + +**Consumers:** `IsSizedType` 1, `IsLowerableType` 6. + +### The shallow predicates + +`IsIntegral`, `IsArithmetic`, `IsOrderable`, `IsEquatable` and `IsCondition` are +4–10 lines each, non-recursive, one switch over primitive kinds. They answer +unrelated questions — operator admissibility, not ownership — and merging them +is precisely the "giant capability object for unrelated properties" the step +forbids. They stay as they are. + +**Consumers:** 13, 4, 1, 1, 3 respectively. + +### Reference target, collection shape, target width + +- `ReferenceTarget` is already a single accessor with 37 consumers. No duplicate + exists to remove. +- Collection shape is not a derived question at all: `ArrayShape` is a field on + `ArrayType`, decided when the type is built. +- Target-width representability is `LiteralFitsType`, one function, 4 consumers, + no second implementation. + +## Remaining structural traversal in the backend, and why it stays + +`internal/backend/llvm/drop_emit.go` has `typeHasRuntimeProperty`, reached +through `typeNeedsDrop`, `typeCarriesAllocatorID` and `typeNeedsRawFreeID`. It +recurses over types and asks about drops, which reads at a glance like a second +implementation of the source-level obligation. It is not, and the difference is +structural rather than a matter of trust: + +- It reads `ir.TypeTable`, the **lowered** type universe, not `typeinfo.Type`. + It can see representation choices no source type mentions, and it cannot see + source policy such as the explicit-copy class. +- It never decides *whether* a value is dropped. That decision arrives already + made, as a `mir.Drop` instruction. `emitDrop` is entered from exactly one + place — `emitter.go:311`, handling a `mir.Drop` — and every other call to + `emitDropValue` in the file is this traversal recursing into a drop that was + already ordered upstream. +- Its one non-drop caller decides an ABI shim: a declared-only function whose + signature carries owned storage needs one. Also physical. + +So ownership decides policy and the backend expands representation. A type can +be reachable in the backend walk without carrying a source-level drop +obligation, and neither side is wrong. The boundary is now stated in a comment +above `typeNeedsDrop`, including what to do if you ever find yourself wanting it +to answer the policy question: you are in the wrong phase. + +This closes gap **G2** in `ownership-vocabulary.md`, which recorded the two as +"implemented twice … they agree today, drift tomorrow". They are not two +implementations of one question; they are one policy decision and one structural +expansion, and only the policy side can originate a drop. + +## Behavior changes + +None. The consolidation was proven equivalent against the predicates it +replaced before they were deleted, and no consumer's answer changed. diff --git a/go.mod b/go.mod index d10de473..e2897679 100644 --- a/go.mod +++ b/go.mod @@ -1,3 +1,3 @@ module compiler -go 1.26.2 \ No newline at end of file +go 1.23.2 \ No newline at end of file diff --git a/internal/backend/llvm/drop_emit.go b/internal/backend/llvm/drop_emit.go index 6e77acff..5582a8ae 100644 --- a/internal/backend/llvm/drop_emit.go +++ b/internal/backend/llvm/drop_emit.go @@ -300,6 +300,25 @@ const ( typePropertyNeedsRawFree ) +// typeNeedsDrop is not a second implementation of the source-level drop +// obligation, and it must not become one. +// +// typeinfo.OwnershipCapabilityOf decides *whether a value is dropped at all*. +// That decision reaches here already made, as a mir.Drop instruction; emitDrop +// is only ever entered from one, and every other call below is this function +// recursing into a drop that was already ordered. What it answers is narrower: +// given that this value is being dropped, does its runtime representation hold +// anything worth walking into. It reads the lowered ir.TypeTable, so it can see +// representation choices that no source type mentions, and it cannot see source +// policy such as an explicit-copy class. +// +// The other caller decides an ABI shim rather than a drop: a declared-only +// function whose signature carries owned storage needs one. Also physical. +// +// So a type may be reachable here and not carry a source-level drop obligation +// without either side being wrong. If you ever need this to answer "should this +// be dropped", you are in the wrong phase: ownership owns that, and the answer +// belongs in the mir.Drop it emits. func typeNeedsDrop(types *ir.TypeTable, id ir.TypeID) bool { return typeHasRuntimeProperty(types, id, typePropertyNeedsDrop, make(map[ir.TypeID]bool)) } diff --git a/internal/backend/llvm/emitter.go b/internal/backend/llvm/emitter.go index 69d965ae..ec97318e 100644 --- a/internal/backend/llvm/emitter.go +++ b/internal/backend/llvm/emitter.go @@ -290,62 +290,66 @@ func GenerateLLVMIR(mod *mir.Module, diag *diagnostics.DiagnosticBag, targetInfo continue } lb.setLocation(instr.SourceLocation()) - if assign, ok := instr.(*mir.Assign); ok && assign != nil { - val := emitValueExpr(lb, assign.Value) - if ptr, ok := lb.localPtrs[assign.Name]; ok { + // Emitting nothing for an unrecognized instruction silently drops + // program behavior, so every MIR instruction must be classified + // here. mir.Instr is sealed, so the only way to reach the + // default is to add an instruction inside the mir package and + // not classify it here: a compiler bug, not invalid source. + switch typed := instr.(type) { + case *mir.Assign: + val := emitValueExpr(lb, typed.Value) + if ptr, ok := lb.localPtrs[typed.Name]; ok { lb.store(ptr, val) } else { - lb.locals[assign.Name] = val + lb.locals[typed.Name] = val } - continue - } - if store, ok := instr.(*mir.Store); ok && store != nil { - emitStore(lb, store) - continue - } - if printInstr, ok := instr.(*mir.Print); ok && printInstr != nil { - emitPrint(lb, printInstr) - continue - } - if dropInstr, ok := instr.(*mir.Drop); ok && dropInstr != nil { - emitDrop(lb, dropInstr) - continue - } - if operation, ok := instr.(*mir.DynamicArrayOp); ok && operation != nil { - emitDynamicArrayOp(lb, operation) - continue - } - if call, ok := instr.(*mir.Call); ok && call != nil { - emitDiscardedCall(lb, call) - continue - } - if call, ok := instr.(*mir.InterfaceCall); ok && call != nil { - emitDiscardedInterfaceCall(lb, call) + case *mir.Store: + emitStore(lb, typed) + case *mir.Print: + emitPrint(lb, typed) + case *mir.Drop: + emitDrop(lb, typed) + case *mir.DynamicArrayOp: + emitDynamicArrayOp(lb, typed) + case *mir.Call: + emitDiscardedCall(lb, typed) + case *mir.InterfaceCall: + emitDiscardedInterfaceCall(lb, typed) + default: + panic(fmt.Sprintf("LLVM emission: unhandled MIR instruction %T", instr)) } } - if block.Term != nil { - returnLayout := emitter.layout(llvmFunctionReturnType(mod.Types, fn)) - lb.setLocation(block.Term.SourceLocation()) - switch term := block.Term.(type) { - case *mir.Jump: - lb.branch(fmt.Sprintf("b%d", term.TargetID)) - case *mir.Branch: - cond := emitCondRef(lb, term.Cond) - lb.condBranch(cond, fmt.Sprintf("b%d", term.ThenID), fmt.Sprintf("b%d", term.ElseID)) - case *mir.SwitchVariant: - emitVariantSwitch(lb, term) - case *mir.Ret: - if term.Value == nil || isVoidType(mod.Types, fn.ReturnType) { - if returnLayout.Kind != llvmLayoutVoid { - lb.ret(lb.value("0", returnLayout), returnLayout) - } else { - lb.retVoid(returnLayout) - } - continue + // Both terminator invariants are compiler bugs, not invalid source: + // every block carries a terminator, and every terminator kind emits + // one. Skipping either silently produces unterminated LLVM IR. + // mir.Terminator is sealed, so the unhandled-kind default below can + // only be reached from inside the mir package. + if block.Term == nil { + panic(fmt.Sprintf("LLVM emission: block b%d has no terminator", block.ID)) + } + returnLayout := emitter.layout(llvmFunctionReturnType(mod.Types, fn)) + lb.setLocation(block.Term.SourceLocation()) + switch term := block.Term.(type) { + case *mir.Jump: + lb.branch(fmt.Sprintf("b%d", term.TargetID)) + case *mir.Branch: + cond := emitCondRef(lb, term.Cond) + lb.condBranch(cond, fmt.Sprintf("b%d", term.ThenID), fmt.Sprintf("b%d", term.ElseID)) + case *mir.SwitchVariant: + emitVariantSwitch(lb, term) + case *mir.Ret: + if term.Value == nil || isVoidType(mod.Types, fn.ReturnType) { + if returnLayout.Kind != llvmLayoutVoid { + lb.ret(lb.value("0", returnLayout), returnLayout) + } else { + lb.retVoid(returnLayout) } - val := emitRef(lb, term.Value) - lb.ret(val, returnLayout) + continue } + val := emitRef(lb, term.Value) + lb.ret(val, returnLayout) + default: + panic(fmt.Sprintf("LLVM emission: unhandled MIR terminator %T", block.Term)) } lb.setLocation(nil) } diff --git a/internal/backend/llvm/emitter_test.go b/internal/backend/llvm/emitter_test.go index 2621dbe1..13ce600a 100644 --- a/internal/backend/llvm/emitter_test.go +++ b/internal/backend/llvm/emitter_test.go @@ -348,6 +348,41 @@ func TestTypedLLVMBuilderRejectsOperandMismatches(t *testing.T) { } } +// mir.Instr and mir.Terminator are sealed, so a node this package could pass to +// emission no longer exists: the earlier fabricated one cannot implement either +// interface. What remains reachable is a block that carries no terminator at +// all, which would otherwise emit an unterminated basic block with no signal. +func TestGenerateLLVMIRPanicsForMalformedMIRBlocks(t *testing.T) { + for _, tt := range []struct { + name string + block *mir.Block + want string + }{ + { + name: "missing terminator", + block: &mir.Block{ID: 7}, + want: "LLVM emission: block b7 has no terminator", + }, + } { + t.Run(tt.name, func(t *testing.T) { + defer func() { + if recovered := recover(); recovered != tt.want { + t.Fatalf("GenerateLLVMIR panic = %#v; want %q", recovered, tt.want) + } + }() + GenerateLLVMIR(&mir.Module{ + Name: "unknown-node", + Types: llvmTypes.table, + Funcs: []*mir.Function{{ + Name: "test", + ReturnType: llvmTypes.void, + Blocks: []*mir.Block{tt.block}, + }}, + }, diagnostics.NewDiagnosticBag(), testLinuxAMD64, false) + }) + } +} + func TestLLVMEmitterRejectsUnknownMIROperators(t *testing.T) { emitter := &llvmEmitter{mod: &mir.Module{Types: llvmTypes.table}} operand := &mir.RefConst{Value: "1", Type: llvmTypes.i32} @@ -2873,7 +2908,7 @@ func TestGenerateLLVMIRUsesWidenedUnsignedDynamicArrayIndexForGEP(t *testing.T) t.Fatalf("expected narrow unsigned index widening, got:\n%s", irText) } gep := "" - for line := range strings.SplitSeq(irText, "\n") { + for _, line := range strings.Split(irText, "\n") { if strings.Contains(line, "getelementptr i32") { gep = line break diff --git a/internal/contracts/child_traversal_test.go b/internal/contracts/child_traversal_test.go new file mode 100644 index 00000000..68dee4d8 --- /dev/null +++ b/internal/contracts/child_traversal_test.go @@ -0,0 +1,513 @@ +package contracts + +// Child traversal is handwritten. Interface satisfaction cannot see inside a +// valid method, so adding a node-bearing field and forgetting to visit it +// compiles, passes the node-dispatch contract, and silently hides the field +// from every ast.Inspect consumer. +// +// This reads the AST package and enforces two completeness rules: +// +// - every node-bearing field of a traversable node is visited by that node's +// own forEachChild. An embedded composite that carries nodes must own a +// traversal, and the embedding node must chain into it by type name. +// - every node-bearing field of a non-node sub-structure (Param, +// StructLitField, ...) is expanded by some traversal context that actually +// handles that sub-structure. Evidence is matched by owner, not by bare +// field name, so a field name visited in an unrelated context cannot +// cover a forgotten field. + +import ( + "go/ast" + "go/parser" + "go/token" + "os" + "path/filepath" + "slices" + "strings" + "testing" +) + +// nonNodeComposites are composite field types that carry no AST node. Each entry +// is a deliberate classification, not a default. +var nonNodeComposites = map[string]string{ + "CommentGroup": "documentation text, not part of the syntax tree", + "Comment": "documentation text, not part of the syntax tree", + "NodeIDHolder": "embedded node identity", + "NodeMeta": "embedded identity and position metadata", + "Documented": "doc comment and declaration surface text", + "Location": "source position", + "Position": "source position", + "Token": "lexical token", +} + +type astPackage struct { + structs map[string][]*ast.Field // type name -> fields + traversals map[string][]string // type name -> receiver field names visited + nodeTypes map[string]bool // type names that are AST nodes + markers map[string][]string // marker method -> implementing type names + // expansions holds every function participating in traversal: forEachChild + // methods and helpers accepting a visit func(Node) parameter. Coverage and + // fields are transitive over package-level helper calls, so a chain such as + // FnDecl.forEachChild -> inspectParams -> inspectParam attributes Param + // fields to every owner that expands params. + expansions map[string]*expansion +} + +// expansion is one traversal context. owner is the receiver type for methods +// and empty for package-level helpers. mentions lists struct type names written +// anywhere in the function (signature included), so inspectParam(param Param, +// ...) mentions Param. fields lists every selector field name reachable through +// the helper-call closure. live records whether visit is actually invoked, +// directly or through helpers; a helper that never calls visit proves nothing. +type expansion struct { + owner string + mentions map[string]bool + fields map[string]bool + live bool +} + +func loadASTPackage(t *testing.T) *astPackage { + t.Helper() + dir := filepath.Join(internalDir(t), "frontend", "ast") + pkgs, err := parser.ParseDir(token.NewFileSet(), dir, func(info os.FileInfo) bool { + return !strings.HasSuffix(info.Name(), "_test.go") + }, 0) + if err != nil { + t.Fatalf("parse %s: %v", dir, err) + } + pkg := &astPackage{ + structs: map[string][]*ast.Field{}, + traversals: map[string][]string{}, + nodeTypes: map[string]bool{}, + markers: map[string][]string{}, + expansions: map[string]*expansion{}, + } + // Node interfaces are node-bearing by definition; a field typed as one of + // them holds a child. + for _, name := range []string{"Node", "Decl", "TypeDecl", "Stmt", "Expr", "TypeExpr"} { + pkg.nodeTypes[name] = true + } + packageFuncs := map[string]*ast.FuncDecl{} + for _, parsed := range pkgs { + for _, file := range parsed.Files { + for _, decl := range file.Decls { + switch typed := decl.(type) { + case *ast.GenDecl: + for _, spec := range typed.Specs { + spec, ok := spec.(*ast.TypeSpec) + if !ok { + continue + } + if structType, ok := spec.Type.(*ast.StructType); ok && structType.Fields != nil { + pkg.structs[spec.Name.Name] = structType.Fields.List + } + } + case *ast.FuncDecl: + if marker := markerName(typed); marker != "" { + if owner, ok := receiverTypeName(typed); ok { + pkg.markers[marker] = append(pkg.markers[marker], owner) + } + continue + } + owner, isMethod := receiverTypeName(typed) + if isMethod && typed.Name.Name == "forEachChild" { + pkg.nodeTypes[owner] = true + pkg.traversals[owner] = visitedFields(typed) + } + if isMethod || hasVisitParam(typed) { + key := expansionKey(owner, typed.Name.Name) + visitName, _ := visitParamName(typed) + pkg.expansions[key] = &expansion{ + owner: owner, + mentions: mentionedStructs(pkg, typed), + fields: allSelectorNames(typed), + live: callsIdent(typed, visitName), + } + if !isMethod { + packageFuncs[typed.Name.Name] = typed + } + } + } + } + } + } + pkg.closeOverHelpers(packageFuncs) + if len(pkg.traversals) == 0 { + t.Fatalf("no forEachChild implementations found in %s", dir) + } + return pkg +} + +// closeOverHelpers merges fields, mentions, and liveness transitively through +// package-level helper calls so inspectParam evidence reaches FnDecl.forEachChild. +func (p *astPackage) closeOverHelpers(packageFuncs map[string]*ast.FuncDecl) { + callees := map[string][]string{} + for key, fn := range packageFuncs { + for _, name := range calledPackageFuncs(fn, packageFuncs) { + callees[key] = append(callees[key], name) + } + } + for changed := true; changed; { + changed = false + for key, targets := range callees { + target := p.expansions[key] + if target == nil { + continue + } + for _, callee := range targets { + source := p.expansions[callee] + if source == nil { + continue + } + for field := range source.fields { + if !target.fields[field] { + target.fields[field] = true + changed = true + } + } + for mention := range source.mentions { + if !target.mentions[mention] { + target.mentions[mention] = true + changed = true + } + } + if source.live && !target.live { + target.live = true + changed = true + } + } + } + } +} + +func expansionKey(owner, name string) string { + if owner == "" { + return name + } + return owner + "." + name +} + +// markerName returns the marker method name (stmtNode, exprNode, ...) or "". +func markerName(fn *ast.FuncDecl) string { + switch fn.Name.Name { + case "stmtNode", "exprNode", "declNode", "typeNode": + if fn.Recv != nil { + return fn.Name.Name + } + } + return "" +} + +// hasVisitParam reports whether the function accepts a visit func(Node) +// parameter, making it a traversal helper regardless of its name. +func hasVisitParam(fn *ast.FuncDecl) bool { + _, ok := visitParamName(fn) + return ok +} + +// visitParamName returns the name of the func(Node) parameter, which is the +// identifier the body must call to expand children. +func visitParamName(fn *ast.FuncDecl) (string, bool) { + if fn == nil || fn.Type.Params == nil { + return "", false + } + for _, field := range fn.Type.Params.List { + funcType, ok := field.Type.(*ast.FuncType) + if !ok || funcType.Params == nil || len(funcType.Params.List) != 1 { + continue + } + nodeType, ok := funcType.Params.List[0].Type.(*ast.Ident) + if !ok || nodeType.Name != "Node" || len(field.Names) == 0 { + continue + } + return field.Names[0].Name, true + } + return "", false +} + +// callsIdent reports whether the function body invokes the given identifier. +func callsIdent(fn *ast.FuncDecl, name string) bool { + if name == "" { + return false + } + found := false + ast.Inspect(fn.Body, func(node ast.Node) bool { + call, ok := node.(*ast.CallExpr) + if !ok { + return true + } + if ident, ok := call.Fun.(*ast.Ident); ok && ident.Name == name { + found = true + } + return true + }) + return found +} + +// calledPackageFuncs lists package-level helper functions the body calls. +func calledPackageFuncs(fn *ast.FuncDecl, packageFuncs map[string]*ast.FuncDecl) []string { + names := make([]string, 0) + ast.Inspect(fn.Body, func(node ast.Node) bool { + call, ok := node.(*ast.CallExpr) + if !ok { + return true + } + ident, ok := call.Fun.(*ast.Ident) + if !ok { + return true + } + if _, known := packageFuncs[ident.Name]; known { + names = append(names, ident.Name) + } + return true + }) + return names +} + +func mentionedStructs(pkg *astPackage, fn *ast.FuncDecl) map[string]bool { + mentions := map[string]bool{} + ast.Inspect(fn, func(node ast.Node) bool { + ident, ok := node.(*ast.Ident) + if !ok { + return true + } + if _, isStruct := pkg.structs[ident.Name]; isStruct { + mentions[ident.Name] = true + } + return true + }) + return mentions +} + +func allSelectorNames(fn *ast.FuncDecl) map[string]bool { + fields := map[string]bool{} + ast.Inspect(fn, func(node ast.Node) bool { + if selector, ok := node.(*ast.SelectorExpr); ok { + fields[selector.Sel.Name] = true + } + return true + }) + return fields +} + +func receiverTypeName(fn *ast.FuncDecl) (string, bool) { + if fn.Recv == nil || len(fn.Recv.List) != 1 { + return "", false + } + expr := fn.Recv.List[0].Type + if star, ok := expr.(*ast.StarExpr); ok { + expr = star.X + } + name, ok := expr.(*ast.Ident) + if !ok { + return "", false + } + return name.Name, true +} + +// visitedFields returns every field of the receiver named anywhere in fn, which +// covers direct visits, range loops, and conditional visits alike. +func visitedFields(fn *ast.FuncDecl) []string { + receiver := "" + if len(fn.Recv.List[0].Names) == 1 { + receiver = fn.Recv.List[0].Names[0].Name + } + fields := make([]string, 0) + ast.Inspect(fn, func(node ast.Node) bool { + selector, ok := node.(*ast.SelectorExpr) + if !ok { + return true + } + if base, ok := selector.X.(*ast.Ident); ok && base.Name == receiver { + fields = append(fields, selector.Sel.Name) + } + return true + }) + return fields +} + +// typeNames returns every identifier naming a type inside a field type. +func typeNames(expr ast.Expr) []string { + names := make([]string, 0) + ast.Inspect(expr, func(node ast.Node) bool { + if ident, ok := node.(*ast.Ident); ok { + names = append(names, ident.Name) + } + if selector, ok := node.(*ast.SelectorExpr); ok { + names = append(names, selector.Sel.Name) + return false + } + return true + }) + return names +} + +// bearing reports whether a type name transitively carries an AST node, so a +// field typed []Param counts even though Param is not itself a node. The +// visited set is recursion-internal cycle protection, not caller state. +func (p *astPackage) bearing(name string) bool { + seen := map[string]bool{} + var walk func(string) bool + walk = func(current string) bool { + if p.nodeTypes[current] { + return true + } + if _, inert := nonNodeComposites[current]; inert { + return false + } + fields, ok := p.structs[current] + if !ok || seen[current] { + return false + } + seen[current] = true + for _, field := range fields { + for _, inner := range typeNames(field.Type) { + if inner != current && walk(inner) { + return true + } + } + } + return false + } + return walk(name) +} + +// covers reports whether the expansion context handles the sub-structure: it +// either mentions the type (a helper parameter typed Param) or its owner struct +// holds it directly (StructLit holds []StructLitField). +func (p *astPackage) covers(e *expansion, sub string) bool { + if e.mentions[sub] { + return true + } + if e.owner == "" { + return false + } + return slices.ContainsFunc(p.structs[e.owner], func(field *ast.Field) bool { + return slices.Contains(typeNames(field.Type), sub) + }) +} + +// expandsField reports whether some live traversal context that handles sub +// touches the given field name. +func (p *astPackage) expandsField(sub, field string) bool { + for _, e := range p.expansions { + if e.live && p.covers(e, sub) && e.fields[field] { + return true + } + } + return false +} + +// typesHeldByNodes returns every type named by a field of a traversable node. +func (p *astPackage) typesHeldByNodes() map[string]bool { + held := map[string]bool{} + var walk func(string) + walk = func(name string) { + for _, field := range p.structs[name] { + for _, inner := range typeNames(field.Type) { + if _, ok := p.structs[inner]; !ok || held[inner] { + continue + } + held[inner] = true + walk(inner) + } + } + } + for name := range p.traversals { + walk(name) + } + return held +} + +// Sub-structures such as Param carry nodes but have no traversal of their own; +// their fields are expanded by a helper or an inline loop. Every node-bearing +// field must be expanded by a traversal context that handles that specific +// sub-structure, so a same-named field visited in an unrelated context cannot +// cover a forgotten one. +func TestEverySubStructureFieldIsExpanded(t *testing.T) { + pkg := loadASTPackage(t) + held := pkg.typesHeldByNodes() + for name, fields := range pkg.structs { + if _, isNode := pkg.traversals[name]; isNode { + continue + } + // A sub-structure only needs expansion if some node actually holds it. + // Root aggregates such as Module are held by nobody and are walked by + // their consumers directly, not through ast.Inspect. + if !held[name] || !pkg.bearing(name) { + continue + } + t.Run(name, func(t *testing.T) { + for _, field := range fields { + if len(field.Names) == 0 { + continue + } + if !slices.ContainsFunc(typeNames(field.Type), pkg.bearing) { + continue + } + fieldName := field.Names[0].Name + if !pkg.expandsField(name, fieldName) { + t.Errorf("%s.%s carries an AST node but no traversal context handling %s expands it; ast.Inspect cannot reach it", + name, fieldName, name) + } + } + }) + } +} + +func TestEveryNodeBearingFieldIsTraversed(t *testing.T) { + pkg := loadASTPackage(t) + for owner, visited := range pkg.traversals { + t.Run(owner, func(t *testing.T) { + for _, field := range pkg.structs[owner] { + names := typeNames(field.Type) + bearing := slices.ContainsFunc(names, pkg.bearing) + unknown := "" + if !bearing { + // Composite types must be classified rather than assumed inert. + for _, name := range names { + if _, known := nonNodeComposites[name]; known { + continue + } + if _, isStruct := pkg.structs[name]; isStruct { + unknown = name + break + } + } + } + label := "embedded " + strings.Join(names, ".") + if len(field.Names) > 0 { + label = field.Names[0].Name + } + if unknown != "" && !bearing { + t.Errorf("%s.%s has unclassified composite type %s; classify it in nonNodeComposites or give it a traversal", + owner, label, unknown) + continue + } + if !bearing { + continue + } + if len(field.Names) == 0 { + // Embedded node-bearing composite: it must own a traversal and + // the owner must chain into it, otherwise its children are only + // reachable if every consumer knows to expand it manually. + embedded := names[0] + if _, hasTraversal := pkg.traversals[embedded]; !hasTraversal { + t.Errorf("%s embeds node-bearing %s without a traversal; give it a forEachChild", + owner, embedded) + continue + } + if !slices.Contains(visited, embedded) { + t.Errorf("%s.forEachChild never chains into embedded %s; call its forEachChild or the subtree is invisible to ast.Inspect", + owner, embedded) + } + continue + } + if !slices.Contains(visited, field.Names[0].Name) { + t.Errorf("%s.forEachChild never visits node-bearing field %s; add it or the field is invisible to ast.Inspect", + owner, field.Names[0].Name) + } + } + }) + } +} diff --git a/internal/contracts/ir_dispatch_test.go b/internal/contracts/ir_dispatch_test.go new file mode 100644 index 00000000..b0794edd --- /dev/null +++ b/internal/contracts/ir_dispatch_test.go @@ -0,0 +1,270 @@ +// This file owns the phase-coverage contract for the lowered representations. +// +// node_dispatch_test.go covers AST syntax and type_dispatch_test.go covers the +// semantic type model. This covers what those lower into: hir.Stmt, mir.Instr +// and mir.Terminator. +// +// Membership in those sets is already sealed at compile time by the marker +// methods and by ir/mir/model_membership_test.go. Membership is not coverage: +// a node can be a full member of mir.Instr and still be dropped silently by a +// lowering pass or the backend. That is what this file holds. +package contracts + +import ( + "go/ast" + "go/parser" + "go/token" + "path/filepath" + "slices" + "strings" + "testing" +) + +// irFamily is one lowered node set and the sites that must answer for it. +type irFamily struct { + // name appears in failures, so a contributor knows which set they extended. + name string + // declared is the file declaring the set, and marker the method that marks + // membership in it. + declared string + marker string + // pkgPath and pkgName resolve how a site names these kinds: unqualified + // when the site lives in the declaring package, through the import's local + // alias otherwise. + pkgPath string + pkgName string + sites []irDispatchSite +} + +// Control flow is already blocks and terminators by the time MIR lowering walks +// CFG sites, so a structured statement never arrives at one. lowerCFGStmt's +// default panics rather than skipping, which is what makes that an invariant +// instead of an assumption. +const decomposedBeforeMIRReason = "control flow is decomposed into CFG blocks and terminators before MIR " + + "lowering, so this never reaches a CFG site; the default panics if it does" + +type irDispatchSite struct { + file string + fn string + why string + // omitted classifies kinds this site implements no case for. + omitted map[string]classification +} + +var irFamilies = []irFamily{ + { + name: "hir.Stmt", + declared: "ir/hir/model.go", + marker: "stmtNode", + pkgPath: "compiler/internal/ir/hir", + pkgName: "hir", + sites: []irDispatchSite{ + { + file: "ir/mir/module_lower.go", + fn: "lowerCFGStmt", + why: "how the statement becomes MIR", + omitted: map[string]classification{ + "Block": {contextual, decomposedBeforeMIRReason}, + "For": {contextual, decomposedBeforeMIRReason}, + "If": {contextual, decomposedBeforeMIRReason}, + "SwitchVariant": {contextual, decomposedBeforeMIRReason}, + }, + }, + { + file: "ir/hir/fold/fold.go", + fn: "foldStmt", + why: "how constant folding rewrites the statement", + }, + }, + }, + { + name: "mir.Instr", + declared: "ir/mir/model.go", + marker: "instrNode", + pkgPath: "compiler/internal/ir/mir", + pkgName: "mir", + sites: []irDispatchSite{ + { + file: "ir/mir/module_lower.go", + fn: "appendInstr", + why: "which source location the instruction carries", + omitted: map[string]classification{ + "Print": {ignore, "constructed with its own expression location, which is more precise " + + "than the statement location this stamps"}, + "DynamicArrayOp": {ignore, "constructed with its own expression location, which is more " + + "precise than the statement location this stamps"}, + }, + }, + { + file: "backend/llvm/emitter.go", + fn: "GenerateLLVMIR", + why: "what the backend emits for the instruction", + }, + }, + }, + { + name: "mir.Terminator", + declared: "ir/mir/model.go", + marker: "termNode", + pkgPath: "compiler/internal/ir/mir", + pkgName: "mir", + sites: []irDispatchSite{ + { + file: "ir/mir/module_lower.go", + fn: "setBlockTerm", + why: "which source location the terminator carries", + }, + { + file: "backend/llvm/emitter.go", + fn: "GenerateLLVMIR", + why: "what the backend emits for the terminator", + }, + }, + }, +} + +func TestEveryLoweredNodeKindHasAPhaseDecision(t *testing.T) { + for _, family := range irFamilies { + kinds := declaredMarkerKinds(t, family.declared, family.marker) + if len(kinds) == 0 { + t.Fatalf("%s: no kinds found via %s in %s", family.name, family.marker, family.declared) + } + for _, site := range family.sites { + t.Run(family.name+"/"+site.fn, func(t *testing.T) { + handled := handledKindsIn(t, site.file, site.fn, family.pkgPath, family.pkgName, kinds) + for _, kind := range kinds { + entry, classified := site.omitted[kind] + if slices.Contains(handled, kind) { + if classified { + t.Errorf("%s handles %s.%s but still classifies it %s (%q); delete the entry", + site.fn, family.pkgName, kind, entry.decision, entry.reason) + } + continue + } + if !classified { + t.Errorf("%s makes no decision about %s.%s; it decides %s, so add a case or declare why the kind is inert", + site.fn, family.pkgName, kind, site.why) + } + } + }) + } + } +} + +func TestLoweredOmissionReasonsNameRealKinds(t *testing.T) { + for _, family := range irFamilies { + kinds := declaredMarkerKinds(t, family.declared, family.marker) + for _, site := range family.sites { + for kind, entry := range site.omitted { + if !slices.Contains(kinds, kind) { + t.Errorf("%s classifies %s kind %s that is no longer a member", + site.fn, family.name, kind) + } + if strings.TrimSpace(entry.reason) == "" { + t.Errorf("%s classifies %s as %s without a reason", site.fn, kind, entry.decision) + } + if entry.decision.String() == "unknown" { + t.Errorf("%s classifies %s with an invalid decision", site.fn, kind) + } + } + } + } +} + +// declaredMarkerKinds returns every type in one file that implements a marker +// method, which is how each node family in this repository declares membership. +func declaredMarkerKinds(t *testing.T, file, marker string) []string { + t.Helper() + path := filepath.Join(internalDir(t), filepath.FromSlash(file)) + parsed, err := parser.ParseFile(token.NewFileSet(), path, nil, 0) + if err != nil { + t.Fatalf("parse %s: %v", file, err) + } + kinds := make([]string, 0) + for _, decl := range parsed.Decls { + fn, ok := decl.(*ast.FuncDecl) + if !ok || fn.Name.Name != marker { + continue + } + if name, ok := receiverTypeName(fn); ok && !slices.Contains(kinds, name) { + kinds = append(kinds, name) + } + } + slices.Sort(kinds) + return kinds +} + +// handledKindsIn collects the kinds a function distinguishes, from type switch +// cases and from single type assertions. +// +// It is looser than the AST contract, which requires the switch operand to be a +// parameter: these sites switch on derived values, and some peel one kind with +// an assertion before switching. Filtering names to declared kinds is what keeps +// it honest — a switch over anything else contributes nothing. A site holding +// two switches over disjoint families, as the backend does for instructions and +// terminators, is separated by that same filter. +func handledKindsIn(t *testing.T, file, fn, pkgPath, pkgName string, kinds []string) []string { + t.Helper() + path := filepath.Join(internalDir(t), filepath.FromSlash(file)) + parsed, err := parser.ParseFile(token.NewFileSet(), path, nil, 0) + if err != nil { + t.Fatalf("parse %s: %v", file, err) + } + decl := findFuncDecl(parsed, fn) + if decl == nil { + t.Fatalf("function %s not found in %s", fn, file) + } + // A site inside the declaring package names kinds unqualified; one outside + // names them through the import's local alias. + own := parsed.Name != nil && parsed.Name.Name == pkgName + local := importLocalName(parsed, pkgPath) + handled := make([]string, 0) + record := func(expr ast.Expr) { + star, ok := expr.(*ast.StarExpr) + if !ok { + return + } + var name string + switch target := star.X.(type) { + case *ast.Ident: + if !own { + return + } + name = target.Name + case *ast.SelectorExpr: + if own { + return + } + pkg, ok := target.X.(*ast.Ident) + if !ok || pkg.Name != local { + return + } + name = target.Sel.Name + default: + return + } + if slices.Contains(kinds, name) && !slices.Contains(handled, name) { + handled = append(handled, name) + } + } + ast.Inspect(decl.Body, func(node ast.Node) bool { + switch current := node.(type) { + case *ast.TypeSwitchStmt: + for _, stmt := range current.Body.List { + clause, ok := stmt.(*ast.CaseClause) + if !ok { + continue + } + for _, expr := range clause.List { + record(expr) + } + } + case *ast.TypeAssertExpr: + if current.Type != nil { + record(current.Type) + } + } + return true + }) + return handled +} diff --git a/internal/contracts/node_dispatch_test.go b/internal/contracts/node_dispatch_test.go new file mode 100644 index 00000000..aacbb165 --- /dev/null +++ b/internal/contracts/node_dispatch_test.go @@ -0,0 +1,402 @@ +// Package contracts owns the phase-coverage contract for AST node handling. +// +// Child-field completeness is enforced separately in child_traversal_test.go. +// These tests cover the semantic layer: they read the real sources and compare +// every declared statement and expression kind against every phase that +// dispatches on them, so an omission fails immediately instead of surfacing as +// wrong output much later. +package contracts + +import ( + "go/ast" + "go/parser" + "go/token" + "path/filepath" + "runtime" + "slices" + "strings" + "testing" +) + +// decision is the classification a phase must make for a node kind it does not +// implement a case for. A kind with a case is handled by definition. Whether a +// case body handles or rejects is not distinguishable from the source shape at +// this tier; compile-time visitor interfaces are needed to separate those. +type decision uint8 + +const ( + // traverse: canonical child walk is sufficient, no phase-specific semantics. + traverse decision = iota + // ignore: node is intentionally irrelevant at this site. + ignore + // reject: node is invalid here and another phase reports it. + reject + // contextual: the parent construct owns the node's handling; reaching this + // dispatcher directly is an internal invariant violation. + contextual +) + +func (d decision) String() string { + switch d { + case traverse: + return "traverse" + case ignore: + return "ignore" + case reject: + return "reject" + case contextual: + return "contextual" + } + return "unknown" +} + +type classification struct { + decision decision + reason string +} + +// dispatchSite is one function that switches over an AST family. +type dispatchSite struct { + file string + fn string + // omitted classifies kinds this site implements no case for. An empty map + // means the site is fully exhaustive. + omitted map[string]classification + // inertDeclarations adds the shared declaration-statement classifications. + // Set it on CFG-site consumers rather than repeating one rule at each. + inertDeclarations bool +} + +// Declarations all implement stmtNode, and parseStmt really does build FnDecl, +// StructDecl, InterfaceDecl, EnumDecl and TypeAliasDecl nodes inside a block. +// The resolver reports them as unsupported statements and HIR lowers them to +// hir.Invalid, but CFG through ownership are not error-gated, so the nodes do +// reach those sites. They are skipped because a declaration evaluates no +// expression and performs no assignment at a CFG site, not because they are +// absent. ImportDecl is only produced at module level and BadDecl is never +// produced by the parser; both are tolerated for synthetic trees. +var declarationStatements = map[string]classification{ + "FnDecl": {ignore, "reaches this site after the resolver reports an unsupported statement, and evaluates no expression here"}, + "TypeAliasDecl": {ignore, "reaches this site after the resolver reports an unsupported statement, and evaluates no expression here"}, + "StructDecl": {ignore, "reaches this site after the resolver reports an unsupported statement, and evaluates no expression here"}, + "InterfaceDecl": {ignore, "reaches this site after the resolver reports an unsupported statement, and evaluates no expression here"}, + "EnumDecl": {ignore, "reaches this site after the resolver reports an unsupported statement, and evaluates no expression here"}, + "ImportDecl": {ignore, "the parser only produces import declarations at module level, never as a block statement"}, + "BadDecl": {ignore, "the parser never produces BadDecl; it is tolerated for synthetic trees"}, +} + +const ( + elsePositionReason = "parseIfStmt produces only a block or else-if in else position; anything else is an internal invariant violation (the default panics)" +) + +// omissions returns the site's declared classifications including the shared +// declaration-statement rule. +func (d dispatchSite) omissions() map[string]classification { + merged := make(map[string]classification, len(d.omitted)+len(declarationStatements)) + for kind, entry := range d.omitted { + merged[kind] = entry + } + if d.inertDeclarations { + for kind, entry := range declarationStatements { + if _, declared := merged[kind]; !declared { + merged[kind] = entry + } + } + } + return merged +} + +// Sites are exhaustive unless they declare a reason per omitted kind. Adding a +// statement kind must either add a case here or record why the kind is inert. +var statementSites = []dispatchSite{ + {file: "semantics/resolver/resolver.go", fn: "resolveStmt"}, + {file: "semantics/typechecker/check_stmt.go", fn: "checkStmt"}, + {file: "ir/cfg/build.go", fn: "buildStmt"}, + {file: "ir/hir/lower/module_lower.go", fn: "appendStmt"}, + { + file: "ir/hir/lower/module_lower.go", + fn: "lowerElse", + omitted: map[string]classification{ + "BreakStmt": {reject, elsePositionReason}, + "ContinueStmt": {reject, elsePositionReason}, + "MatchStmt": {reject, elsePositionReason}, + }, + }, + // The effect producer replaced definiteinit.checkReads as the site that reads + // meaning out of a statement. It is exhaustive: every kind has a case, so it + // declares no omissions, and a new kind fails here first. + {file: "semantics/effect/build.go", fn: "publishStmt"}, +} + +func internalDir(t *testing.T) string { + t.Helper() + _, thisFile, _, ok := runtime.Caller(0) + if !ok { + t.Fatal("cannot locate contracts package source") + } + return filepath.Dir(filepath.Dir(thisFile)) +} + +// declaredKinds returns every AST type implementing the marker method, tracked +// through the shared AST package loader so both contracts see one node set. +func declaredKinds(t *testing.T, marker string) []string { + t.Helper() + pkg := loadASTPackage(t) + kinds := slices.Clone(pkg.markers[marker]) + if len(kinds) == 0 { + t.Fatalf("no types implement %s in the ast package", marker) + } + slices.Sort(kinds) + return kinds +} + +// handledKinds returns the ast.X kinds named by type-switch cases whose operand +// is the function's statement/expression input. That input is either a direct +// parameter, a CFG-site payload selected from a parameter (node.stmt), or a +// local extracted from a site through a comma-ok assertion to ast.Stmt. +// Switches on other derived values (a callee extracted from an expression, a +// loop variable) are a different decision and are excluded, so an unrelated +// nested switch cannot fake coverage. +func handledKinds(t *testing.T, file, fn string) []string { + t.Helper() + path := filepath.Join(internalDir(t), filepath.FromSlash(file)) + parsed, err := parser.ParseFile(token.NewFileSet(), path, nil, 0) + if err != nil { + t.Fatalf("parse %s: %v", path, err) + } + decl := findFuncDecl(parsed, fn) + if decl == nil { + t.Fatalf("function %s not found in %s", fn, file) + } + params := paramNames(decl) + stmtLocals := stmtAssertionLocals(decl) + astLocal := importLocalName(parsed, "compiler/internal/frontend/ast") + handled := make([]string, 0) + ast.Inspect(decl.Body, func(node ast.Node) bool { + switchStmt, ok := node.(*ast.TypeSwitchStmt) + if !ok { + return true + } + // Do not descend: switches on derived values inside this switch are not + // part of this function's dispatch contract. + if isDispatchOperand(switchOperandExpr(switchStmt), params, stmtLocals) { + for _, stmt := range switchStmt.Body.List { + clause, ok := stmt.(*ast.CaseClause) + if !ok { + continue + } + for _, expr := range clause.List { + star, ok := expr.(*ast.StarExpr) + if !ok { + continue + } + selector, ok := star.X.(*ast.SelectorExpr) + if !ok { + continue + } + if pkg, ok := selector.X.(*ast.Ident); ok && pkg.Name == astLocal { + handled = append(handled, selector.Sel.Name) + } + } + } + } + return false + }) + return handled +} + +// isDispatchOperand reports whether a type-switch operand is the function's +// statement/expression input under the rule documented on handledKinds. +func isDispatchOperand(operand ast.Expr, params, stmtLocals map[string]bool) bool { + if operand == nil { + return false + } + switch typed := operand.(type) { + case *ast.Ident: + return params[typed.Name] || stmtLocals[typed.Name] + case *ast.SelectorExpr: + base, ok := typed.X.(*ast.Ident) + return ok && params[base.Name] && typed.Sel.Name == "stmt" + } + return false +} + +// stmtAssertionLocals returns locals assigned through a comma-ok assertion to +// ast.Stmt, the canonical CFG-site statement extraction. +func stmtAssertionLocals(fn *ast.FuncDecl) map[string]bool { + locals := map[string]bool{} + ast.Inspect(fn.Body, func(node ast.Node) bool { + assign, ok := node.(*ast.AssignStmt) + if !ok || len(assign.Lhs) != 2 || len(assign.Rhs) != 1 { + return true + } + assert, ok := assign.Rhs[0].(*ast.TypeAssertExpr) + if !ok { + return true + } + selector, ok := assert.Type.(*ast.SelectorExpr) + if !ok { + return true + } + pkg, ok := selector.X.(*ast.Ident) + if !ok || pkg.Name != "ast" || selector.Sel.Name != "Stmt" { + return true + } + if ident, ok := assign.Lhs[0].(*ast.Ident); ok { + locals[ident.Name] = true + } + return true + }) + return locals +} + +func findFuncDecl(file *ast.File, name string) *ast.FuncDecl { + for _, decl := range file.Decls { + fn, ok := decl.(*ast.FuncDecl) + if ok && fn.Name.Name == name { + return fn + } + } + return nil +} + +func paramNames(fn *ast.FuncDecl) map[string]bool { + params := map[string]bool{} + if fn.Type.Params == nil { + return params + } + for _, field := range fn.Type.Params.List { + for _, name := range field.Names { + params[name.Name] = true + } + } + return params +} + +// switchOperandExpr returns the expression a type switch asserts on, for both +// `switch x := y.(type)` and `switch y.(type)` forms. +func switchOperandExpr(switchStmt *ast.TypeSwitchStmt) ast.Expr { + switch assign := switchStmt.Assign.(type) { + case *ast.AssignStmt: + if len(assign.Rhs) != 1 { + return nil + } + assert, ok := assign.Rhs[0].(*ast.TypeAssertExpr) + if !ok { + return nil + } + return assert.X + case *ast.ExprStmt: + assert, ok := assign.X.(*ast.TypeAssertExpr) + if !ok { + return nil + } + return assert.X + } + return nil +} + +// importLocalName resolves the local name the file uses for the package at the +// given import path, so the case-type filter does not hardcode an alias. +func importLocalName(file *ast.File, importPath string) string { + for _, imp := range file.Imports { + if strings.Trim(imp.Path.Value, `"`) != importPath { + continue + } + if imp.Name != nil { + return imp.Name.Name + } + break + } + // Unaliased imports bind the package name, which is the path's last segment. + if index := strings.LastIndex(importPath, "/"); index >= 0 { + return importPath[index+1:] + } + return importPath +} + +// Expression dispatch is split between sites that must be total and narrow +// queries such as "which expressions can hold a reference". Only the total sites +// belong in this contract: demanding a declared reason from every predicate for +// all 23 kinds would be pure boilerplate, and a no-op default there defeats the +// exhaustiveness it pretends to add. +var expressionSites = []dispatchSite{ + {file: "semantics/resolver/resolver.go", fn: "resolveExpr"}, + {file: "semantics/typechecker/check_expr.go", fn: "typeExprBase"}, + // Ownership no longer walks expressions. The producer decides what each one + // does to a binding, once, and ownership consumes that. + {file: "semantics/effect/build.go", fn: "value"}, + { + file: "ir/hir/lower/module_lower.go", + fn: "lowerASTExpr", + omitted: map[string]classification{ + "RangeExpr": {contextual, "a range is lowered by its parent construct (loop iterable or slice index); this dispatcher must never receive one directly"}, + }, + }, +} + +// Type syntax reaches two phases that must decide per kind, and both failures +// are silent: an unresolved kind becomes an invalid type rather than an error, +// and a kind missing from declaration-cycle detection hides a recursive type +// until layout recurses forever. Parsing and printing walk type syntax too, but +// they consume the node's own TypeText rather than dispatching on its kind. +var typeSites = []dispatchSite{ + {file: "semantics/typeinfo/syntax.go", fn: "TypeFromSyntax"}, + {file: "semantics/binder/type_decl_cycles.go", fn: "addTypeDeclEdges"}, +} + +func assertSitesDecide(t *testing.T, sites []dispatchSite, kinds []string) { + t.Helper() + for _, site := range sites { + t.Run(site.fn, func(t *testing.T) { + handled := handledKinds(t, site.file, site.fn) + omitted := site.omissions() + for _, kind := range kinds { + if slices.Contains(handled, kind) { + if entry, declared := omitted[kind]; declared { + t.Errorf("%s handles %s but still classifies it %s (%q); delete the entry", + site.fn, kind, entry.decision, entry.reason) + } + continue + } + if _, declared := omitted[kind]; !declared { + t.Errorf("%s makes no decision about ast.%s; add a case or declare why the kind is inert", + site.fn, kind) + } + } + }) + } +} + +func TestEveryStatementKindHasAPhaseDecision(t *testing.T) { + assertSitesDecide(t, statementSites, declaredKinds(t, "stmtNode")) +} + +func TestEveryExpressionKindHasAPhaseDecision(t *testing.T) { + assertSitesDecide(t, expressionSites, declaredKinds(t, "exprNode")) +} + +func TestEveryTypeKindHasAPhaseDecision(t *testing.T) { + assertSitesDecide(t, typeSites, declaredKinds(t, "typeNode")) +} + +// A reason that no longer names a real statement kind is stale and must not +// silently excuse a future kind of the same name. +func TestOmissionReasonsNameRealNodeKinds(t *testing.T) { + kinds := append(declaredKinds(t, "stmtNode"), declaredKinds(t, "exprNode")...) + kinds = append(kinds, declaredKinds(t, "typeNode")...) + sites := slices.Concat(statementSites, expressionSites, typeSites) + for _, site := range sites { + for kind, entry := range site.omissions() { + if !slices.Contains(kinds, kind) { + t.Errorf("%s classifies kind %s that no longer exists", site.fn, kind) + } + if strings.TrimSpace(entry.reason) == "" { + t.Errorf("%s classifies %s as %s without a reason", site.fn, kind, entry.decision) + } + if entry.decision.String() == "unknown" { + t.Errorf("%s classifies %s with an invalid decision", site.fn, kind) + } + } + } +} diff --git a/internal/contracts/type_dispatch_test.go b/internal/contracts/type_dispatch_test.go new file mode 100644 index 00000000..c9b7144b --- /dev/null +++ b/internal/contracts/type_dispatch_test.go @@ -0,0 +1,109 @@ +// This file owns the phase-coverage contract for semantic type kinds. +// +// node_dispatch_test.go covers AST *syntax*: statements, expressions and type +// syntax. This covers the semantic types those lower into — the typeinfo.Type +// family. Adding one and forgetting that it must answer a capability, lower to +// a runtime type, or compare equal used to compile clean and fail much later, +// or silently produce a wrong answer. +package contracts + +import ( + "slices" + "strings" + "testing" +) + +// typeDispatchSite is one function that must answer for every semantic type. +// +// Only sites that must be *total* belong here. A narrow predicate whose default +// is a legitimate answer does not: classifying kinds there would be ceremony +// around a decision that is already correct. +type typeDispatchSite struct { + file string + fn string + // why records what this site answers, so a contributor reading a failure + // knows what decision they owe rather than only that one is missing. + why string + // omitted classifies kinds this site implements no case for. + omitted map[string]classification +} + +// A missed kind at each of these is silently wrong rather than loudly rejected, +// which is what earns them a contract. IsSizedType and IsLowerableType are +// deliberately absent: their default rejects, so a forgotten kind produces a +// diagnostic rather than a wrong answer. +var typeKindSites = []typeDispatchSite{ + { + file: "semantics/typeinfo/relations.go", + fn: "SameType", + why: "whether two types are the same type", + omitted: map[string]classification{ + "DefinedType": {contextual, "nominal identity is settled before the switch by sameNominalStruct, and " + + "Underlying peels the definition away for the structural comparison that follows"}, + }, + }, + { + file: "project/export_fingerprint.go", + fn: "semanticTypeKey", + why: "which stable semantic identity participates in exported API fingerprints", + }, + { + file: "ir/hir/lower/lower_types.go", + fn: "intern", + why: "which runtime type the backend materializes", + omitted: map[string]classification{ + "EnumType": {contextual, "a named enum is interned through internDefined, which owns variant layout"}, + "OptionalType": {contextual, "an optional is shaped by loweredRuntimeType before it reaches interning"}, + "TypeParameterType": {reject, "a type parameter has no runtime representation; instantiation must " + + "substitute it before lowering, and reaching here yields ir.InvalidType"}, + }, + }, +} + +// Semantic Type itself is now the compile-time extension contract: Type is +// sealed by unexported forEachChild and ownershipShape methods, so a new type +// cannot enter semantic code without declaring both canonical structure and +// ownership composition. No source-inspection test is needed for that boundary. + +func TestEverySemanticTypeKindHasAPhaseDecision(t *testing.T) { + kinds := declaredMarkerKinds(t, "semantics/typeinfo/types.go", "TypeNode") + if len(kinds) < 10 { + t.Fatalf("found %d semantic type kinds, expected the full family", len(kinds)) + } + for _, site := range typeKindSites { + t.Run(site.fn, func(t *testing.T) { + handled := handledKindsIn(t, site.file, site.fn, "compiler/internal/semantics/typeinfo", "typeinfo", kinds) + for _, kind := range kinds { + entry, classified := site.omitted[kind] + if slices.Contains(handled, kind) { + if classified { + t.Errorf("%s handles %s but still classifies it %s (%q); delete the entry", + site.fn, kind, entry.decision, entry.reason) + } + continue + } + if !classified { + t.Errorf("%s makes no decision about typeinfo.%s; it decides %s, so add a case or declare why the kind is inert", + site.fn, kind, site.why) + } + } + }) + } +} + +func TestSemanticTypeOmissionReasonsNameRealKinds(t *testing.T) { + kinds := declaredMarkerKinds(t, "semantics/typeinfo/types.go", "TypeNode") + for _, site := range typeKindSites { + for kind, entry := range site.omitted { + if !slices.Contains(kinds, kind) { + t.Errorf("%s classifies type kind %s that no longer exists", site.fn, kind) + } + if strings.TrimSpace(entry.reason) == "" { + t.Errorf("%s classifies %s as %s without a reason", site.fn, kind, entry.decision) + } + if entry.decision.String() == "unknown" { + t.Errorf("%s classifies %s with an invalid decision", site.fn, kind) + } + } + } +} diff --git a/internal/diagnostics/bag.go b/internal/diagnostics/bag.go index 00a1ec62..9a001d7e 100644 --- a/internal/diagnostics/bag.go +++ b/internal/diagnostics/bag.go @@ -25,7 +25,11 @@ type DiagnosticBag struct { mu *sync.Mutex sourceCache *SourceCache phase phase.Phase - moduleKey string + // moduleScope groups diagnostics by the module that produced them. It is an + // opaque string on purpose: this package must not depend on how module + // identity is spelled, so callers pass moduleid.ID.String() and the bag + // only ever compares and sorts it. + moduleScope string } type diagnosticGroup struct { @@ -43,37 +47,37 @@ func NewDiagnosticBag() *DiagnosticBag { } // BeginPhase replaces one producing phase/module group and returns its writer. -func (db *DiagnosticBag) BeginPhase(producingPhase phase.Phase, moduleKey string) *DiagnosticBag { - scoped := db.AppendPhase(producingPhase, moduleKey) +func (db *DiagnosticBag) BeginPhase(producingPhase phase.Phase, moduleScope string) *DiagnosticBag { + scoped := db.AppendPhase(producingPhase, moduleScope) db.mu.Lock() if db.groups[producingPhase] == nil { db.groups[producingPhase] = make(map[string]diagnosticGroup) } - db.groups[producingPhase][moduleKey] = diagnosticGroup{active: true} + db.groups[producingPhase][moduleScope] = diagnosticGroup{active: true} db.mu.Unlock() return scoped } // AppendPhase returns a writer that continues one producing phase/module group. -func (db *DiagnosticBag) AppendPhase(producingPhase phase.Phase, moduleKey string) *DiagnosticBag { +func (db *DiagnosticBag) AppendPhase(producingPhase phase.Phase, moduleScope string) *DiagnosticBag { return &DiagnosticBag{ groups: db.groups, mu: db.mu, sourceCache: db.sourceCache, phase: producingPhase, - moduleKey: moduleKey, + moduleScope: moduleScope, } } // DiscardModuleAfter removes diagnostics invalidated by a module phase reset. -func (db *DiagnosticBag) DiscardModuleAfter(moduleKey string, retained phase.Phase) { +func (db *DiagnosticBag) DiscardModuleAfter(moduleScope string, retained phase.Phase) { db.mu.Lock() defer db.mu.Unlock() for producingPhase, modules := range db.groups { if producingPhase <= retained { continue } - delete(modules, moduleKey) + delete(modules, moduleScope) if len(modules) == 0 { delete(db.groups, producingPhase) } @@ -82,7 +86,7 @@ func (db *DiagnosticBag) DiscardModuleAfter(moduleKey string, retained phase.Pha // CopyModuleRange replaces one module's destination groups inside an inclusive // phase range. Inactive groups remain reusable but do not affect compilation or output. -func (db *DiagnosticBag) CopyModuleRange(source *DiagnosticBag, moduleKey string, first, last phase.Phase, active bool) { +func (db *DiagnosticBag) CopyModuleRange(source *DiagnosticBag, moduleScope string, first, last phase.Phase, active bool) { if source == nil || db.mu == source.mu || first > last { return } @@ -92,7 +96,7 @@ func (db *DiagnosticBag) CopyModuleRange(source *DiagnosticBag, moduleKey string if producingPhase < first || producingPhase > last { continue } - if group, ok := modules[moduleKey]; ok { + if group, ok := modules[moduleScope]; ok { copiedGroups[producingPhase] = diagnosticGroup{ diagnostics: append([]*Diagnostic(nil), group.diagnostics...), active: active, @@ -107,7 +111,7 @@ func (db *DiagnosticBag) CopyModuleRange(source *DiagnosticBag, moduleKey string if producingPhase < first || producingPhase > last { continue } - delete(modules, moduleKey) + delete(modules, moduleScope) if len(modules) == 0 { delete(db.groups, producingPhase) } @@ -116,12 +120,12 @@ func (db *DiagnosticBag) CopyModuleRange(source *DiagnosticBag, moduleKey string if db.groups[producingPhase] == nil { db.groups[producingPhase] = make(map[string]diagnosticGroup) } - db.groups[producingPhase][moduleKey] = group + db.groups[producingPhase][moduleScope] = group } } // ActivateModuleRange publishes retained groups after their project barrier succeeds. -func (db *DiagnosticBag) ActivateModuleRange(moduleKey string, first, last phase.Phase) { +func (db *DiagnosticBag) ActivateModuleRange(moduleScope string, first, last phase.Phase) { if first > last { return } @@ -131,12 +135,12 @@ func (db *DiagnosticBag) ActivateModuleRange(moduleKey string, first, last phase if producingPhase < first || producingPhase > last { continue } - group, ok := modules[moduleKey] + group, ok := modules[moduleScope] if !ok { continue } group.active = true - modules[moduleKey] = group + modules[moduleScope] = group } } @@ -186,10 +190,10 @@ func (db *DiagnosticBag) Add(diag *Diagnostic) { if db.groups[db.phase] == nil { db.groups[db.phase] = make(map[string]diagnosticGroup) } - group := db.groups[db.phase][db.moduleKey] + group := db.groups[db.phase][db.moduleScope] group.diagnostics = append(group.diagnostics, diag) group.active = true - db.groups[db.phase][db.moduleKey] = group + db.groups[db.phase][db.moduleScope] = group } // AddError adds an error diagnostic to the bag and returns it for chaining/customization. @@ -260,13 +264,13 @@ func (db *DiagnosticBag) Diagnostics() []*Diagnostic { result := make([]*Diagnostic, 0) for _, producingPhase := range phases { modules := db.groups[producingPhase] - moduleKeys := make([]string, 0, len(modules)) - for moduleKey := range modules { - moduleKeys = append(moduleKeys, moduleKey) + moduleScopes := make([]string, 0, len(modules)) + for moduleScope := range modules { + moduleScopes = append(moduleScopes, moduleScope) } - slices.Sort(moduleKeys) - for _, moduleKey := range moduleKeys { - group := modules[moduleKey] + slices.Sort(moduleScopes) + for _, moduleScope := range moduleScopes { + group := modules[moduleScope] if group.active { result = append(result, group.diagnostics...) } diff --git a/internal/diagnostics/codes.go b/internal/diagnostics/codes.go index 71eb3398..2bf5df35 100644 --- a/internal/diagnostics/codes.go +++ b/internal/diagnostics/codes.go @@ -84,10 +84,17 @@ const ( ErrAmbiguousImport = "M0005" ErrInvalidEntrypoint = "M0006" + // Internal compiler errors (ICE prefix). ICE0001 is the generic marker in + // diagnostic.go; codes here name a specific broken compiler invariant. + ErrInvalidEvidence = "ICE0002" + ErrInvalidTopology = "ICE0003" + // Style/Info codes (S prefix) - InfoTrailingComma = "S0001" - InfoUnnecessarySemicolon = "S0002" - InfoRedundantComma = "S0003" + InfoTrailingComma = "S0001" + InfoUnnecessarySemicolon = "S0002" + InfoRedundantComma = "S0003" + InfoRedundantPreludeImport = "S0004" + InfoRedundantGlobalQualifier = "S0005" // Warnings (W prefix) WarnUnreachableCode = "W0001" diff --git a/internal/frontend/ast/decl.go b/internal/frontend/ast/decl.go index bce8d3d2..7cd95052 100644 --- a/internal/frontend/ast/decl.go +++ b/internal/frontend/ast/decl.go @@ -467,6 +467,7 @@ type FnDecl struct { func (*FnDecl) declNode() {} func (*FnDecl) stmtNode() {} func (d *FnDecl) forEachChild(visit func(Node)) { + d.Attributed.forEachChild(visit) visit(d.Name) if d.Receiver != nil { inspectParam(*d.Receiver, visit) @@ -503,6 +504,7 @@ type TypeAliasDecl struct { func (*TypeAliasDecl) declNode() {} func (*TypeAliasDecl) stmtNode() {} func (d *TypeAliasDecl) forEachChild(visit func(Node)) { + d.Attributed.forEachChild(visit) visit(d.Name) inspectTypeParams(d.TypeParams, visit) visit(d.Type) @@ -532,6 +534,7 @@ type StructDecl struct { func (*StructDecl) declNode() {} func (*StructDecl) stmtNode() {} func (d *StructDecl) forEachChild(visit func(Node)) { + d.Attributed.forEachChild(visit) visit(d.Name) inspectTypeParams(d.TypeParams, visit) visit(d.Type) @@ -560,6 +563,7 @@ type InterfaceDecl struct { func (*InterfaceDecl) declNode() {} func (*InterfaceDecl) stmtNode() {} func (d *InterfaceDecl) forEachChild(visit func(Node)) { + d.Attributed.forEachChild(visit) visit(d.Name) inspectTypeParams(d.TypeParams, visit) visit(d.Type) @@ -588,6 +592,7 @@ type EnumDecl struct { func (*EnumDecl) declNode() {} func (*EnumDecl) stmtNode() {} func (d *EnumDecl) forEachChild(visit func(Node)) { + d.Attributed.forEachChild(visit) visit(d.Name) inspectTypeParams(d.TypeParams, visit) visit(d.Type) diff --git a/internal/frontend/ast/inspect.go b/internal/frontend/ast/inspect.go index 181d8566..0be8acaa 100644 --- a/internal/frontend/ast/inspect.go +++ b/internal/frontend/ast/inspect.go @@ -1,10 +1,12 @@ package ast +import "compiler/pkg/typednil" + // Inspect traverses the AST in depth-first order: it starts by calling f(node); // if f returns true, Inspect invokes f recursively for each of the non-nil children of node, // followed by a call to f(nil). func Inspect(node Node, f func(Node) bool) { - if node == nil || IsNilNode(node) { + if typednil.IsNil(node) { return } if !f(node) { diff --git a/internal/frontend/ast/location.go b/internal/frontend/ast/location.go index eab1ad1c..cdd8e0b6 100644 --- a/internal/frontend/ast/location.go +++ b/internal/frontend/ast/location.go @@ -2,23 +2,13 @@ package ast import ( "compiler/internal/source" - "reflect" + "compiler/pkg/typednil" ) -// IsNilNode handles the Go interface nil trap: a non-nil interface holding a -// nil pointer is not equal to nil, so a plain `n == nil` check is insufficient. -func IsNilNode(n Node) bool { - if n == nil { - return true - } - v := reflect.ValueOf(n) - return v.Kind() == reflect.Pointer && v.IsNil() -} - // LocOf safely returns the location of a node, handling nil interfaces // and nil pointer receivers without panicking. func LocOf(n Node) *source.Location { - if IsNilNode(n) { + if typednil.IsNil(n) { return nil } return n.loc() diff --git a/internal/frontend/ast/meta.go b/internal/frontend/ast/meta.go index a75fe664..5d7fb1b3 100644 --- a/internal/frontend/ast/meta.go +++ b/internal/frontend/ast/meta.go @@ -46,6 +46,15 @@ type Attribute struct { Location *source.Location } +func (a *Attribute) forEachChild(visit func(Node)) { + if a == nil { + return + } + for _, arg := range a.Args { + visit(arg) + } +} + const ( AttributeExtern = "extern" AttributeTest = "test" @@ -122,6 +131,15 @@ type Attributed struct { Attributes []Attribute } +func (a *Attributed) forEachChild(visit func(Node)) { + if a == nil { + return + } + for _, attr := range a.Attributes { + attr.forEachChild(visit) + } +} + func (a *Attributed) SetAttributes(attrs []Attribute) { if a == nil { return diff --git a/internal/frontend/lexer/lexer_test.go b/internal/frontend/lexer/lexer_test.go index 75661d86..b14bc723 100644 --- a/internal/frontend/lexer/lexer_test.go +++ b/internal/frontend/lexer/lexer_test.go @@ -148,7 +148,7 @@ fn add(a: i32, b: i32): i32 { return a + b; }` - for b.Loop() { + for i := 0; i < b.N; i++ { New("benchmark"+peeper.SourceExt, src, nil).Tokenize() } } diff --git a/internal/frontend/parser/parser.go b/internal/frontend/parser/parser.go index 6241aa2e..2fa4d6fe 100644 --- a/internal/frontend/parser/parser.go +++ b/internal/frontend/parser/parser.go @@ -17,6 +17,7 @@ import ( "compiler/internal/frontend/ast" "compiler/internal/frontend/token" "compiler/internal/source" + "compiler/pkg/typednil" ) type Parser struct { @@ -772,7 +773,7 @@ func (p *Parser) nextID() ast.NodeID { } func reg[T ast.Node](p *Parser, n T) T { - if !ast.IsNilNode(n) { + if !typednil.IsNil(n) { n.SetID(p.nextID()) } return n diff --git a/internal/graph/directed.go b/internal/graph/directed.go new file mode 100644 index 00000000..f3917f31 --- /dev/null +++ b/internal/graph/directed.go @@ -0,0 +1,227 @@ +package graph + +import "slices" + +// Directed is the canonical directed-graph storage kernel. It owns ordered +// outgoing and incoming edge indexes once; domain graphs keep their semantic +// edge types and layer policy on top of this structure instead of maintaining +// private adjacency stores. +// +// Directed is intentionally not synchronized. Long-lived shared graphs may +// protect it with their own lock, while phase-local graphs such as CFGs avoid +// synchronization they do not need. +type Directed[Node comparable, Edge comparable] struct { + endpoints func(Edge) (Node, Node) + out map[Node][]Edge + in map[Node][]Edge +} + +func NewDirected[Node comparable, Edge comparable](endpoints func(Edge) (Node, Node)) *Directed[Node, Edge] { + if endpoints == nil { + panic("graph: Directed requires an edge endpoint function") + } + return &Directed[Node, Edge]{ + endpoints: endpoints, + out: make(map[Node][]Edge), + in: make(map[Node][]Edge), + } +} + +// AddEdge inserts edge once. Edge identity belongs to the domain edge value; +// two edges with the same endpoints but different semantic metadata are kept. +func (g *Directed[Node, Edge]) AddEdge(edge Edge) bool { + if g == nil { + return false + } + from, to := g.endpoints(edge) + if slices.Contains(g.out[from], edge) { + return false + } + g.out[from] = append(g.out[from], edge) + g.in[to] = append(g.in[to], edge) + return true +} + +// Edges returns a snapshot of every stored edge, including disconnected edges +// whose endpoints a domain validator may not recognize. Source order is +// unspecified; within a source, insertion order is preserved. +func (g *Directed[Node, Edge]) Edges() []Edge { + if g == nil { + return nil + } + var edges []Edge + for _, outgoing := range g.out { + edges = append(edges, outgoing...) + } + return edges +} + +// OutEdges returns outgoing edges in insertion order. The returned slice is a +// snapshot so consumers cannot corrupt the graph's reverse index accidentally. +func (g *Directed[Node, Edge]) OutEdges(id Node) []Edge { + if g == nil { + return nil + } + return append([]Edge(nil), g.out[id]...) +} + +// InEdges returns incoming edges in insertion order. +func (g *Directed[Node, Edge]) InEdges(id Node) []Edge { + if g == nil { + return nil + } + return append([]Edge(nil), g.in[id]...) +} + +func (g *Directed[Node, Edge]) Successors(id Node, include func(Edge) bool) []Node { + if g == nil { + return nil + } + result := make([]Node, 0, len(g.out[id])) + for _, edge := range g.out[id] { + if include != nil && !include(edge) { + continue + } + _, to := g.endpoints(edge) + result = append(result, to) + } + return result +} + +func (g *Directed[Node, Edge]) Predecessors(id Node, include func(Edge) bool) []Node { + if g == nil { + return nil + } + result := make([]Node, 0, len(g.in[id])) + for _, edge := range g.in[id] { + if include != nil && !include(edge) { + continue + } + from, _ := g.endpoints(edge) + result = append(result, from) + } + return result +} + +func (g *Directed[Node, Edge]) OutDegree(id Node, include func(Edge) bool) int { + if g == nil { + return 0 + } + return countEdges(g.out[id], include) +} + +func (g *Directed[Node, Edge]) InDegree(id Node, include func(Edge) bool) int { + if g == nil { + return 0 + } + return countEdges(g.in[id], include) +} + +func (g *Directed[Node, Edge]) TopoSort(ids []Node, include func(Edge) bool) ([]Node, [][]Node) { + if g == nil || len(ids) == 0 { + return nil, nil + } + index := make(map[Node]struct{}, len(ids)) + for _, id := range ids { + index[id] = struct{}{} + } + + const ( + visitNone = iota + visitTemp + visitDone + ) + state := make(map[Node]uint8, len(index)) + order := make([]Node, 0, len(index)) + stack := make([]Node, 0, len(index)) + cycles := make([][]Node, 0) + + var visit func(Node) + visit = func(id Node) { + switch state[id] { + case visitTemp: + cycles = append(cycles, extractDirectedCycle(stack, id)) + return + case visitDone: + return + } + state[id] = visitTemp + stack = append(stack, id) + for _, next := range g.Successors(id, include) { + if _, ok := index[next]; ok { + visit(next) + } + } + stack = stack[:len(stack)-1] + state[id] = visitDone + order = append(order, id) + } + + for _, id := range ids { + visit(id) + } + return order, cycles +} + +func (g *Directed[Node, Edge]) WeaklyConnectedComponents(ids []Node, include func(Edge) bool) [][]Node { + if g == nil || len(ids) == 0 { + return nil + } + index := make(map[Node]struct{}, len(ids)) + for _, id := range ids { + index[id] = struct{}{} + } + visited := make(map[Node]struct{}, len(index)) + components := make([][]Node, 0) + for _, start := range ids { + if _, ok := visited[start]; ok { + continue + } + queue := []Node{start} + visited[start] = struct{}{} + component := make([]Node, 0) + for len(queue) > 0 { + current := queue[0] + queue = queue[1:] + component = append(component, current) + neighbors := g.Successors(current, include) + neighbors = append(neighbors, g.Predecessors(current, include)...) + for _, next := range neighbors { + if _, ok := index[next]; !ok { + continue + } + if _, ok := visited[next]; ok { + continue + } + visited[next] = struct{}{} + queue = append(queue, next) + } + } + components = append(components, component) + } + return components +} + +func countEdges[Edge comparable](edges []Edge, include func(Edge) bool) int { + if include == nil { + return len(edges) + } + total := 0 + for _, edge := range edges { + if include(edge) { + total++ + } + } + return total +} + +func extractDirectedCycle[Node comparable](stack []Node, target Node) []Node { + for i := len(stack) - 1; i >= 0; i-- { + if stack[i] == target { + cycle := append([]Node(nil), stack[i:]...) + cycle = append(cycle, target) + return cycle + } + } + return []Node{target} +} diff --git a/internal/graph/directed_test.go b/internal/graph/directed_test.go new file mode 100644 index 00000000..8da53b1e --- /dev/null +++ b/internal/graph/directed_test.go @@ -0,0 +1,131 @@ +package graph + +import ( + "fmt" + "reflect" + "testing" +) + +type testDirectedEdge struct { + from string + to string + kind int +} + +func TestDirectedPreservesZeroNode(t *testing.T) { + g := NewDirected(func(edge [2]int) (int, int) { return edge[0], edge[1] }) + g.AddEdge([2]int{1, 0}) + order, cycles := g.TopoSort([]int{1, 0}, nil) + if !reflect.DeepEqual(order, []int{0, 1}) || len(cycles) != 0 { + t.Fatalf("topology = %v, %v; want [0 1], no cycles", order, cycles) + } + if got := g.WeaklyConnectedComponents([]int{1, 0}, nil); !reflect.DeepEqual(got, [][]int{{1, 0}}) { + t.Fatalf("components = %v; want [[1 0]]", got) + } + if got := g.InEdges(0); !reflect.DeepEqual(got, [][2]int{{1, 0}}) { + t.Fatalf("zero node incoming edges = %v", got) + } +} + +func BenchmarkDirectedConstruction(b *testing.B) { + for _, shape := range []struct { + name string + sources int + degree int + }{ + {name: "cfg", sources: 1024, degree: 2}, + {name: "dependencies", sources: 256, degree: 8}, + {name: "fanout", sources: 1, degree: 128}, + {name: "fanout", sources: 1, degree: 1024}, + {name: "fanout", sources: 1, degree: 4096}, + {name: "fanout", sources: 1, degree: 16384}, + } { + b.Run(fmt.Sprintf("%s/%d", shape.name, shape.degree), func(b *testing.B) { + edges := make([][2]int, 0, shape.sources*shape.degree) + for from := 0; from < shape.sources; from++ { + for offset := 1; offset <= shape.degree; offset++ { + edges = append(edges, [2]int{from, from + offset}) + } + } + b.ReportAllocs() + b.ResetTimer() + for n := 0; n < b.N; n++ { + g := NewDirected(func(edge [2]int) (int, int) { return edge[0], edge[1] }) + for _, edge := range edges { + g.AddEdge(edge) + } + } + }) + } +} + +func TestDirectedOwnsBothAdjacencyDirections(t *testing.T) { + g := NewDirected(func(edge testDirectedEdge) (string, string) { return edge.from, edge.to }) + first := testDirectedEdge{from: "a", to: "b", kind: 1} + second := testDirectedEdge{from: "a", to: "b", kind: 2} + if !g.AddEdge(first) || !g.AddEdge(second) || g.AddEdge(first) { + t.Fatal("edge identity should preserve semantic metadata and reject exact duplicates") + } + if got := g.OutEdges("a"); !reflect.DeepEqual(got, []testDirectedEdge{first, second}) { + t.Fatalf("out edges = %#v", got) + } + if got := g.InEdges("b"); !reflect.DeepEqual(got, []testDirectedEdge{first, second}) { + t.Fatalf("in edges = %#v", got) + } +} + +func TestDirectedEdgesSnapshot(t *testing.T) { + var missing *Directed[string, testDirectedEdge] + if len(missing.Edges()) != 0 { + t.Fatal("nil graph has edges") + } + g := NewDirected(func(edge testDirectedEdge) (string, string) { return edge.from, edge.to }) + want := map[testDirectedEdge]bool{ + {from: "a", to: "b", kind: 1}: true, + {from: "a", to: "b", kind: 2}: true, + {from: "foreign", to: "disconnected", kind: 1}: true, + } + for edge := range want { + g.AddEdge(edge) + } + snapshot := g.Edges() + if len(snapshot) != len(want) { + t.Fatalf("snapshot = %v, want %v", snapshot, want) + } + for _, edge := range snapshot { + if !want[edge] { + t.Fatalf("unexpected or repeated edge %v", edge) + } + delete(want, edge) + } + original := snapshot[0] + snapshot[0] = testDirectedEdge{} + found := false + for _, edge := range g.OutEdges(original.from) { + found = found || edge == original + } + if !found { + t.Fatal("snapshot mutation changed canonical adjacency") + } + g.AddEdge(testDirectedEdge{from: "new", to: "node"}) + if len(snapshot) != 3 { + t.Fatal("later graph mutation changed snapshot") + } +} + +func TestDirectedAlgorithmsShareCanonicalAdjacency(t *testing.T) { + g := NewDirected(func(edge testDirectedEdge) (string, string) { return edge.from, edge.to }) + g.AddEdge(testDirectedEdge{from: "a", to: "b", kind: 1}) + g.AddEdge(testDirectedEdge{from: "b", to: "c", kind: 1}) + g.AddEdge(testDirectedEdge{from: "x", to: "y", kind: 2}) + includeOne := func(edge testDirectedEdge) bool { return edge.kind == 1 } + + order, cycles := g.TopoSort([]string{"a", "b", "c"}, includeOne) + if len(cycles) != 0 || !reflect.DeepEqual(order, []string{"c", "b", "a"}) { + t.Fatalf("topological result = %v, cycles = %v", order, cycles) + } + components := g.WeaklyConnectedComponents([]string{"a", "b", "c", "x", "y"}, includeOne) + if !reflect.DeepEqual(components, [][]string{{"a", "b", "c"}, {"x"}, {"y"}}) { + t.Fatalf("components = %#v", components) + } +} diff --git a/internal/graph/graph.go b/internal/graph/graph.go index 0c6dffeb..f0d735e8 100644 --- a/internal/graph/graph.go +++ b/internal/graph/graph.go @@ -9,18 +9,22 @@ type NodeID string type EdgeKind string +type edge struct { + from NodeID + to NodeID + kind EdgeKind +} + type Graph struct { mu sync.RWMutex edgeKind EdgeKind - out map[NodeID]map[EdgeKind]map[NodeID]struct{} - in map[NodeID]map[EdgeKind]map[NodeID]struct{} + directed *Directed[NodeID, edge] } func New(edgeKind EdgeKind) *Graph { return &Graph{ edgeKind: edgeKind, - out: make(map[NodeID]map[EdgeKind]map[NodeID]struct{}), - in: make(map[NodeID]map[EdgeKind]map[NodeID]struct{}), + directed: NewDirected(func(edge edge) (NodeID, NodeID) { return edge.from, edge.to }), } } @@ -37,8 +41,7 @@ func (g *Graph) AddEdge(from, to NodeID, kinds ...EdgeKind) { } g.mu.Lock() defer g.mu.Unlock() - g.addEdgeLocked(g.out, from, kind, to) - g.addEdgeLocked(g.in, to, kind, from) + g.directed.AddEdge(edge{from: from, to: to, kind: kind}) } func (g *Graph) Successors(id NodeID, kinds ...EdgeKind) []NodeID { @@ -47,7 +50,7 @@ func (g *Graph) Successors(id NodeID, kinds ...EdgeKind) []NodeID { } g.mu.RLock() defer g.mu.RUnlock() - return successorSnapshot(g.out[id], kindSet(kinds, g.edgeKind)) + return g.directed.Successors(id, g.edgeFilter(kinds)) } func (g *Graph) Predecessors(id NodeID, kinds ...EdgeKind) []NodeID { @@ -56,7 +59,7 @@ func (g *Graph) Predecessors(id NodeID, kinds ...EdgeKind) []NodeID { } g.mu.RLock() defer g.mu.RUnlock() - return successorSnapshot(g.in[id], kindSet(kinds, g.edgeKind)) + return g.directed.Predecessors(id, g.edgeFilter(kinds)) } func (g *Graph) OutDegree(id NodeID, kinds ...EdgeKind) int { @@ -65,7 +68,7 @@ func (g *Graph) OutDegree(id NodeID, kinds ...EdgeKind) int { } g.mu.RLock() defer g.mu.RUnlock() - return degree(g.out[id], kindSet(kinds, g.edgeKind)) + return g.directed.OutDegree(id, g.edgeFilter(kinds)) } func (g *Graph) InDegree(id NodeID, kinds ...EdgeKind) int { @@ -74,7 +77,7 @@ func (g *Graph) InDegree(id NodeID, kinds ...EdgeKind) int { } g.mu.RLock() defer g.mu.RUnlock() - return degree(g.in[id], kindSet(kinds, g.edgeKind)) + return g.directed.InDegree(id, g.edgeFilter(kinds)) } func (g *Graph) TopoSort(ids []NodeID, kinds ...EdgeKind) ([]NodeID, [][]NodeID) { @@ -83,57 +86,7 @@ func (g *Graph) TopoSort(ids []NodeID, kinds ...EdgeKind) ([]NodeID, [][]NodeID) } g.mu.RLock() defer g.mu.RUnlock() - - index := make(map[NodeID]struct{}, len(ids)) - for _, id := range ids { - if id != "" { - index[id] = struct{}{} - } - } - if len(index) == 0 { - return nil, nil - } - - const ( - visitNone = iota - visitTemp - visitDone - ) - - state := make(map[NodeID]uint8, len(index)) - order := make([]NodeID, 0, len(index)) - stack := make([]NodeID, 0, len(index)) - cycles := make([][]NodeID, 0) - allowedKinds := kindSet(kinds, g.edgeKind) - - var visit func(NodeID) - visit = func(id NodeID) { - switch state[id] { - case visitTemp: - cycles = append(cycles, extractCycle(stack, id)) - return - case visitDone: - return - } - state[id] = visitTemp - stack = append(stack, id) - for _, next := range successorSnapshot(g.out[id], allowedKinds) { - if _, ok := index[next]; ok { - visit(next) - } - } - stack = stack[:len(stack)-1] - state[id] = visitDone - order = append(order, id) - } - - for _, id := range ids { - if id != "" { - visit(id) - } - } - - return order, cycles + return g.directed.TopoSort(nonEmptyNodeIDs(ids), g.edgeFilter(kinds)) } func (g *Graph) WeaklyConnectedComponents(ids []NodeID, kinds ...EdgeKind) [][]NodeID { @@ -142,64 +95,34 @@ func (g *Graph) WeaklyConnectedComponents(ids []NodeID, kinds ...EdgeKind) [][]N } g.mu.RLock() defer g.mu.RUnlock() + return g.directed.WeaklyConnectedComponents(nonEmptyNodeIDs(ids), g.edgeFilter(kinds)) +} - index := make(map[NodeID]struct{}, len(ids)) - for _, id := range ids { - if id != "" { - index[id] = struct{}{} - } +// Empty IDs are invalid only in the domain facade, not in Directed. +func nonEmptyNodeIDs(ids []NodeID) []NodeID { + first := slices.Index(ids, NodeID("")) + if first < 0 { + return ids } - if len(index) == 0 { - return nil - } - - allowedKinds := kindSet(kinds, g.edgeKind) - visited := make(map[NodeID]struct{}, len(index)) - components := make([][]NodeID, 0) - for _, start := range ids { - if start == "" { - continue - } - if _, ok := visited[start]; ok { - continue - } - queue := []NodeID{start} - visited[start] = struct{}{} - component := make([]NodeID, 0) - for len(queue) > 0 { - current := queue[0] - queue = queue[1:] - component = append(component, current) - neighbors := successorSnapshot(g.out[current], allowedKinds) - neighbors = append(neighbors, successorSnapshot(g.in[current], allowedKinds)...) - for _, next := range neighbors { - if _, ok := index[next]; !ok { - continue - } - if _, ok := visited[next]; ok { - continue - } - visited[next] = struct{}{} - queue = append(queue, next) - } + filtered := make([]NodeID, first, len(ids)-1) + copy(filtered, ids[:first]) + for _, id := range ids[first+1:] { + if id != "" { + filtered = append(filtered, id) } - components = append(components, component) } - return components + return filtered } -func (g *Graph) addEdgeLocked(index map[NodeID]map[EdgeKind]map[NodeID]struct{}, from NodeID, kind EdgeKind, to NodeID) { - edgesByKind, ok := index[from] - if !ok { - edgesByKind = make(map[EdgeKind]map[NodeID]struct{}) - index[from] = edgesByKind +func (g *Graph) edgeFilter(kinds []EdgeKind) func(edge) bool { + allowed := kindSet(kinds, g.edgeKind) + if len(allowed) == 0 { + return nil } - edges, ok := edgesByKind[kind] - if !ok { - edges = make(map[NodeID]struct{}) - edgesByKind[kind] = edges + return func(candidate edge) bool { + _, ok := allowed[candidate.kind] + return ok } - edges[to] = struct{}{} } func kindSet(kinds []EdgeKind, defaultKind EdgeKind) map[EdgeKind]struct{} { @@ -215,53 +138,5 @@ func kindSet(kinds []EdgeKind, defaultKind EdgeKind) map[EdgeKind]struct{} { allowed[kind] = struct{}{} } } - if len(allowed) == 0 { - return nil - } return allowed } - -func successorSnapshot(edgesByKind map[EdgeKind]map[NodeID]struct{}, allowed map[EdgeKind]struct{}) []NodeID { - if len(edgesByKind) == 0 { - return nil - } - result := make([]NodeID, 0) - for kind, edges := range edgesByKind { - if len(allowed) > 0 { - if _, ok := allowed[kind]; !ok { - continue - } - } - for id := range edges { - result = append(result, id) - } - } - return result -} - -func degree(edgesByKind map[EdgeKind]map[NodeID]struct{}, allowed map[EdgeKind]struct{}) int { - if len(edgesByKind) == 0 { - return 0 - } - total := 0 - for kind, edges := range edgesByKind { - if len(allowed) > 0 { - if _, ok := allowed[kind]; !ok { - continue - } - } - total += len(edges) - } - return total -} - -func extractCycle(stack []NodeID, target NodeID) []NodeID { - for i := range slices.Backward(stack) { - if stack[i] == target { - cycle := append([]NodeID{}, stack[i:]...) - cycle = append(cycle, target) - return cycle - } - } - return []NodeID{target} -} diff --git a/internal/graph/graph_test.go b/internal/graph/graph_test.go index 884fdd71..7f316411 100644 --- a/internal/graph/graph_test.go +++ b/internal/graph/graph_test.go @@ -1,6 +1,7 @@ package graph import ( + "reflect" "slices" "testing" ) @@ -10,6 +11,46 @@ const ( testEdgeMetadata EdgeKind = "metadata" ) +func TestAlgorithmsIgnoreEmptyNodeIDs(t *testing.T) { + g := New(testEdgeImport) + g.AddEdge("a", "b") + g.AddEdge("b", "a", testEdgeMetadata) + for _, tc := range []struct { + name string + ids []NodeID + kinds []EdgeKind + }{ + {name: "nil"}, + {name: "empty", ids: []NodeID{}}, + {name: "all empty", ids: []NodeID{"", ""}}, + {name: "mixed duplicates isolated", ids: []NodeID{"", "a", "", "b", "a", "isolated", ""}}, + {name: "metadata", ids: []NodeID{"", "a", "b", "isolated", ""}, kinds: []EdgeKind{testEdgeMetadata}}, + {name: "cycle", ids: []NodeID{"", "a", "b", "a", ""}, kinds: []EdgeKind{testEdgeImport, testEdgeMetadata}}, + } { + t.Run(tc.name, func(t *testing.T) { + original := slices.Clone(tc.ids) + var valid []NodeID + for _, id := range tc.ids { + if id != "" { + valid = append(valid, id) + } + } + wantOrder, wantCycles := g.TopoSort(valid, tc.kinds...) + order, cycles := g.TopoSort(tc.ids, tc.kinds...) + if !reflect.DeepEqual(order, wantOrder) || !reflect.DeepEqual(cycles, wantCycles) { + t.Errorf("topology = %v, %v; want %v, %v", order, cycles, wantOrder, wantCycles) + } + wantComponents := g.WeaklyConnectedComponents(valid, tc.kinds...) + if got := g.WeaklyConnectedComponents(tc.ids, tc.kinds...); !reflect.DeepEqual(got, wantComponents) { + t.Errorf("components = %v; want %v", got, wantComponents) + } + if !reflect.DeepEqual(tc.ids, original) { + t.Errorf("caller IDs changed: %v; want %v", tc.ids, original) + } + }) + } +} + func TestTopoSortOrdersImportDependencies(t *testing.T) { g := New(testEdgeImport) g.AddEdge("a", "b") diff --git a/internal/graph/worklist.go b/internal/graph/worklist.go new file mode 100644 index 00000000..6c9e150e --- /dev/null +++ b/internal/graph/worklist.go @@ -0,0 +1,51 @@ +package graph + +// Worklist is the canonical FIFO scheduler for fixed-point and graph analyses. +// It deduplicates pending nodes while allowing a node to be scheduled again +// after it has been processed and new information reaches it. +type Worklist[Node comparable] struct { + queue []Node + queued map[Node]struct{} + next int +} + +func NewWorklist[Node comparable](initial ...Node) *Worklist[Node] { + work := &Worklist[Node]{queued: make(map[Node]struct{}, len(initial))} + for _, node := range initial { + work.Add(node) + } + return work +} + +// Add schedules node if it is not already pending. +func (w *Worklist[Node]) Add(node Node) bool { + if w == nil { + return false + } + if _, found := w.queued[node]; found { + return false + } + w.queued[node] = struct{}{} + w.queue = append(w.queue, node) + return true +} + +// Next returns the next pending node. Once returned, that node may be scheduled +// again if a transfer changes one of its inputs. +func (w *Worklist[Node]) Next() (Node, bool) { + var zero Node + if w == nil || w.next >= len(w.queue) { + return zero, false + } + node := w.queue[w.next] + w.next++ + delete(w.queued, node) + if w.next == len(w.queue) { + w.queue = w.queue[:0] + w.next = 0 + } else if w.next > 64 && w.next*2 >= len(w.queue) { + w.queue = append(w.queue[:0], w.queue[w.next:]...) + w.next = 0 + } + return node, true +} diff --git a/internal/graph/worklist_test.go b/internal/graph/worklist_test.go new file mode 100644 index 00000000..199aa84a --- /dev/null +++ b/internal/graph/worklist_test.go @@ -0,0 +1,25 @@ +package graph + +import "testing" + +func TestWorklistDeduplicatesPendingNodesAndAllowsReschedule(t *testing.T) { + work := NewWorklist(1, 2, 1) + if work.Add(2) { + t.Fatal("pending node was scheduled twice") + } + first, ok := work.Next() + if !ok || first != 1 { + t.Fatalf("first = %d, %v", first, ok) + } + if !work.Add(1) { + t.Fatal("processed node should be schedulable again") + } + second, _ := work.Next() + third, _ := work.Next() + if second != 2 || third != 1 { + t.Fatalf("remaining order = %d, %d", second, third) + } + if _, ok := work.Next(); ok { + t.Fatal("worklist should be empty") + } +} diff --git a/internal/ir/cfg/analyze.go b/internal/ir/cfg/analyze.go index 5f797dee..5395c1fb 100644 --- a/internal/ir/cfg/analyze.go +++ b/internal/ir/cfg/analyze.go @@ -124,14 +124,14 @@ func findMissingReturnBranches(fn *Graph) []*Block { found := make([]*Block, 0) seen := make(map[*Block]bool) for block := range reachesExit { - if block.Origin != BlockNormal { + if structuredControl(block.Origin) { if !seen[block] { found = append(found, block) seen[block] = true } continue } - queue := append([]*Block(nil), block.Predecessors...) + queue := predecessorBlocks(fn, block) traceSeen := make(map[*Block]bool) for len(queue) > 0 { current := queue[0] @@ -140,20 +140,49 @@ func findMissingReturnBranches(fn *Graph) []*Block { continue } traceSeen[current] = true - if current.Origin != BlockNormal { + if structuredControl(current.Origin) { if !seen[current] { found = append(found, current) seen[current] = true } continue } - queue = append(queue, current.Predecessors...) + queue = append(queue, predecessorBlocks(fn, current)...) } } sortMissingBranches(found) return found } +func predecessorBlocks(fn *Graph, block *Block) []*Block { + if fn == nil || fn.BlockEdges == nil || block == nil { + return nil + } + ids := fn.BlockEdges.Predecessors(block.ID, nil) + blocks := make([]*Block, 0, len(ids)) + for _, id := range ids { + if id >= 0 && id < len(fn.Blocks) { + blocks = append(blocks, fn.Blocks[id]) + } + } + return blocks +} + +// structuredControl reports whether a block is part of a structured construct +// rather than a plain continuation. Missing-return reporting walks back to the +// nearest such block to name the branch that falls through. A loop exit is a +// continuation despite carrying a loop role: the code after the loop lives +// there, and reporting it would name the wrong branch. +func structuredControl(origin BlockOrigin) bool { + switch origin { + case BlockNormal, BlockLoopExit: + return false + case BlockThen, BlockElse, BlockLoopInit, BlockLoop, BlockLoopBody, BlockLoopLatch: + return true + } + return true +} + func filterMostSpecificBranches(blocks []*Block) []*Block { if len(blocks) <= 1 { return blocks diff --git a/internal/ir/cfg/build.go b/internal/ir/cfg/build.go index 6fe39d67..2d6b184d 100644 --- a/internal/ir/cfg/build.go +++ b/internal/ir/cfg/build.go @@ -4,6 +4,7 @@ import ( "fmt" "compiler/internal/frontend/ast" + graphcore "compiler/internal/graph" "compiler/internal/ir" "compiler/internal/source" ) @@ -187,7 +188,7 @@ func (b *builder) buildStmt(stmt ast.Stmt, current *Block, scopeID ir.NodeID) *B init := b.newBlock(BlockLoopInit, ast.LocOf(node)) bodyBlock := b.newBlock(BlockLoopBody, ast.LocOf(node)) latch := b.newBlock(BlockLoopLatch, ast.LocOf(node)) - exit := b.newBlock(BlockNormal, ast.LocOf(node)) + exit := b.newBlock(BlockLoopExit, ast.LocOf(node)) init.NodeID = loopID bodyBlock.NodeID = loopID latch.NodeID = loopID @@ -309,11 +310,15 @@ func finalizeGraph(fn *Graph) { } } markReachable(fn.Entry, make(map[int]bool)) - rebuildPredecessors(fn) + rebuildBlockTopology(fn) finalizeSites(fn) } func finalizeSites(fn *Graph) { + if fn == nil { + return + } + fn.SiteEdges = graphcore.NewDirected(func(edge Edge) (SiteID, SiteID) { return edge.From, edge.To }) for _, block := range fn.Blocks { if block == nil { continue @@ -339,8 +344,6 @@ func finalizeSites(fn *Graph) { } for index, site := range block.Sites { site.ID = SiteID{Block: block.ID, Index: index} - site.Successors = nil - site.Predecessors = nil } } for _, block := range fn.Blocks { @@ -348,20 +351,20 @@ func finalizeSites(fn *Graph) { continue } for index := 0; index+1 < len(block.Sites); index++ { - connectSites(block.Sites[index], block.Sites[index+1], EdgeNormal, 0) + connectSites(fn, block.Sites[index], block.Sites[index+1], EdgeNormal, 0) } last := block.Sites[len(block.Sites)-1] switch term := block.Terminator.(type) { case *Jump: - connectBlockSite(last, term.Target, EdgeNormal, 0) + connectBlockSite(fn, last, term.Target, EdgeNormal, 0) case *Branch: - connectBlockSite(last, term.TrueTarget, EdgeTrue, 0) - connectBlockSite(last, term.FalseTarget, EdgeFalse, 0) + connectBlockSite(fn, last, term.TrueTarget, EdgeTrue, 0) + connectBlockSite(fn, last, term.FalseTarget, EdgeFalse, 0) case *Return: - connectBlockSite(last, fn.Exit, EdgeReturn, 0) + connectBlockSite(fn, last, fn.Exit, EdgeReturn, 0) case *SwitchVariant: for _, target := range term.Targets { - connectBlockSite(last, target.Target, EdgeVariantCase, target.Case) + connectBlockSite(fn, last, target.Target, EdgeVariantCase, target.Case) } case nil: default: @@ -370,25 +373,18 @@ func finalizeSites(fn *Graph) { } } -func connectBlockSite(from *Site, target *Block, kind EdgeKind, caseIndex int) { +func connectBlockSite(fn *Graph, from *Site, target *Block, kind EdgeKind, caseIndex int) { if target == nil || len(target.Sites) == 0 { return } - connectSites(from, target.Sites[0], kind, caseIndex) + connectSites(fn, from, target.Sites[0], kind, caseIndex) } -func connectSites(from, to *Site, kind EdgeKind, caseIndex int) { - if from == nil || to == nil { +func connectSites(fn *Graph, from, to *Site, kind EdgeKind, caseIndex int) { + if fn == nil || fn.SiteEdges == nil || from == nil || to == nil { return } - for _, existing := range from.Successors { - if existing.To == to.ID && existing.Kind == kind && existing.Case == caseIndex { - return - } - } - edge := Edge{From: from.ID, To: to.ID, Kind: kind, Case: caseIndex} - from.Successors = append(from.Successors, edge) - to.Predecessors = append(to.Predecessors, edge) + fn.SiteEdges.AddEdge(Edge{From: from.ID, To: to.ID, Kind: kind, Case: caseIndex}) } func markReachable(block *Block, seen map[int]bool) { @@ -405,19 +401,18 @@ func markReachable(block *Block, seen map[int]bool) { } } -func rebuildPredecessors(fn *Graph) { - for _, block := range fn.Blocks { - if block != nil { - block.Predecessors = nil - } +func rebuildBlockTopology(fn *Graph) { + if fn == nil { + return } + fn.BlockEdges = graphcore.NewDirected(func(edge BlockEdge) (int, int) { return edge.From, edge.To }) for _, block := range fn.Blocks { if block == nil || block.Terminator == nil { continue } for _, successor := range block.Terminator.Successors() { if successor != nil { - successor.Predecessors = append(successor.Predecessors, block) + fn.BlockEdges.AddEdge(BlockEdge{From: block.ID, To: successor.ID}) } } } diff --git a/internal/ir/cfg/cfg_test.go b/internal/ir/cfg/cfg_test.go index 79620db1..b5804710 100644 --- a/internal/ir/cfg/cfg_test.go +++ b/internal/ir/cfg/cfg_test.go @@ -88,19 +88,21 @@ func TestBuildModuleCreatesCanonicalSiteAdjacency(t *testing.T) { t.Fatalf("entry sites = %#v, want one branch site", graph.Entry.Sites) } branchSite := graph.Entry.Sites[0] - if branchSite.Kind != SiteTerminator || branchSite.NodeID != 30 || len(branchSite.Successors) != 2 { + branchEdges := graph.SiteEdges.OutEdges(branchSite.ID) + if branchSite.Kind != SiteTerminator || branchSite.NodeID != 30 || len(branchEdges) != 2 { t.Fatalf("branch site = %#v, want two branch successors", branchSite) } wantKinds := map[EdgeKind]bool{EdgeTrue: false, EdgeFalse: false} - for _, successor := range branchSite.Successors { + for _, successor := range branchEdges { wantKinds[successor.Kind] = true site := graph.Blocks[successor.To.Block].Sites[successor.To.Index] - if len(site.Predecessors) != 1 || site.Predecessors[0].From != branchSite.ID || site.Predecessors[0].Kind != successor.Kind { - t.Fatalf("site %#v predecessors = %v, want branch %#v", site.ID, site.Predecessors, branchSite.ID) + predecessors := graph.SiteEdges.InEdges(site.ID) + if len(predecessors) != 1 || predecessors[0].From != branchSite.ID || predecessors[0].Kind != successor.Kind { + t.Fatalf("site %#v predecessors = %v, want branch %#v", site.ID, predecessors, branchSite.ID) } } if !wantKinds[EdgeTrue] || !wantKinds[EdgeFalse] { - t.Fatalf("branch edge kinds = %#v, want true and false", branchSite.Successors) + t.Fatalf("branch edge kinds = %#v, want true and false", branchEdges) } } @@ -116,10 +118,11 @@ func TestFinalizeSitesLabelsVariantCaseEdges(t *testing.T) { }} graph := &Graph{Entry: entry, Exit: &Block{ID: 3}, Blocks: []*Block{entry, first, second}} finalizeSites(graph) - if len(entry.Sites) != 1 || len(entry.Sites[0].Successors) != 2 { + edges := graph.SiteEdges.OutEdges(entry.Sites[0].ID) + if len(entry.Sites) != 1 || len(edges) != 2 { t.Fatalf("switch sites = %#v", entry.Sites) } - for caseIndex, edge := range entry.Sites[0].Successors { + for caseIndex, edge := range edges { if edge.Kind != EdgeVariantCase || edge.Case != caseIndex { t.Fatalf("switch edge %d = %#v", caseIndex, edge) } @@ -159,8 +162,8 @@ func TestBuildModuleCreatesSemanticVariantSwitchAndSharedJoin(t *testing.T) { if len(join.Sites) == 0 || join.Sites[0].NodeID != 40 { t.Fatalf("match join sites = %#v, want following statement", join.Sites) } - if len(graph.Entry.Sites) != 1 || len(graph.Entry.Sites[0].Successors) != 2 || - graph.Entry.Sites[0].Successors[0].Case != 1 || graph.Entry.Sites[0].Successors[1].Case != 0 { + caseEdges := graph.SiteEdges.OutEdges(graph.Entry.Sites[0].ID) + if len(graph.Entry.Sites) != 1 || len(caseEdges) != 2 || caseEdges[0].Case != 1 || caseEdges[1].Case != 0 { t.Fatalf("match case edges = %#v", graph.Entry.Sites) } } @@ -203,7 +206,7 @@ func TestBuildModuleCreatesForInLoopBlocksWithSynthesizedCondition(t *testing.T) header := loopBlock(t, graph, 30, BlockLoop) loopBody := loopBlock(t, graph, 30, BlockLoopBody) latch := loopBlock(t, graph, 30, BlockLoopLatch) - exit := loopBlock(t, graph, 30, BlockNormal) + exit := loopBlock(t, graph, 30, BlockLoopExit) entryJump, ok := graph.Entry.Terminator.(*Jump) if !ok || entryJump.Target != init { @@ -329,7 +332,7 @@ func TestBuildModuleNestedLoopJumpsUseInnermostTargets(t *testing.T) { } body := &ast.BlockStmt{NodeIDHolder: ast.NodeIDHolder{NodeID: 10}, Stmts: []ast.Stmt{outer}} graph := BuildModule(testModule(body, nil), BuildQueries{}).Functions[0] - innerExit := loopBlock(t, graph, 40, BlockNormal) + innerExit := loopBlock(t, graph, 40, BlockLoopExit) innerLatch := loopBlock(t, graph, 40, BlockLoopLatch) outerLatch := loopBlock(t, graph, 30, BlockLoopLatch) foundBreak := false @@ -418,7 +421,7 @@ func TestBuildModuleInfiniteLoopBreakMakesExitReachable(t *testing.T) { init := loopBlock(t, graph, 30, BlockLoopInit) loopBody := loopBlock(t, graph, 30, BlockLoopBody) latch := loopBlock(t, graph, 30, BlockLoopLatch) - exit := loopBlock(t, graph, 30, BlockNormal) + exit := loopBlock(t, graph, 30, BlockLoopExit) initJump, initOK := init.Terminator.(*Jump) latchJump, latchOK := latch.Terminator.(*Jump) if !initOK || initJump.Target != loopBody || !latchOK || latchJump.Target != loopBody { @@ -442,10 +445,10 @@ func TestAnalyzeDoesNotRebuildFinalizedTopology(t *testing.T) { body := &ast.BlockStmt{NodeIDHolder: ast.NodeIDHolder{NodeID: 10}} module := BuildModule(testModule(body, nil), BuildQueries{}) graph := module.Functions[0] - before := append([]*Block(nil), graph.Entry.Predecessors...) + before := graph.BlockEdges.InEdges(graph.Entry.ID) graph.Entry.Sites = nil Analyze(module, diagnostics.NewDiagnosticBag(), nil) - if graph.Entry.Sites != nil || !reflect.DeepEqual(graph.Entry.Predecessors, before) { + if graph.Entry.Sites != nil || !reflect.DeepEqual(graph.BlockEdges.InEdges(graph.Entry.ID), before) { t.Fatalf("Analyze mutated finalized topology: entry = %#v", graph.Entry) } } @@ -525,3 +528,28 @@ func hasDiagnosticCode(diag *diagnostics.DiagnosticBag, code string) bool { } return false } + +// Missing-return reporting walks back to the nearest structured-control block +// to name the branch that falls through, so this classification decides which +// span a user sees. A loop exit carries a loop role but is a continuation: the +// code after the loop lives there. +func TestStructuredControlClassifiesEveryBlockOrigin(t *testing.T) { + for _, test := range []struct { + origin BlockOrigin + name string + want bool + }{ + {BlockNormal, "normal", false}, + {BlockLoopExit, "loop exit", false}, + {BlockThen, "then", true}, + {BlockElse, "else", true}, + {BlockLoopInit, "loop init", true}, + {BlockLoop, "loop header", true}, + {BlockLoopBody, "loop body", true}, + {BlockLoopLatch, "loop latch", true}, + } { + if got := structuredControl(test.origin); got != test.want { + t.Errorf("structuredControl(%s) = %t, want %t", test.name, got, test.want) + } + } +} diff --git a/internal/ir/cfg/model.go b/internal/ir/cfg/model.go index 06bdea86..f564e210 100644 --- a/internal/ir/cfg/model.go +++ b/internal/ir/cfg/model.go @@ -1,6 +1,7 @@ package cfg import ( + graphcore "compiler/internal/graph" "compiler/internal/ir" "compiler/internal/source" ) @@ -19,6 +20,10 @@ func (m *Module) Function(id ir.NodeID) *Graph { return m.byNodeID[id] } +// Graph is finalized by BuildModule. Terminators and ordered block sites define +// control flow; BlockEdges and SiteEdges are derived traversal indexes. Consumers +// must not mutate topology after publication: rebuild the CFG before publishing +// a new generation, since site IDs and downstream evidence depend on it. type Graph struct { NodeID ir.NodeID Name string @@ -28,6 +33,11 @@ type Graph struct { Entry *Block Exit *Block Blocks []*Block + // SiteEdges is the canonical ordered site topology. Edge values retain CFG + // branch meaning; the shared graph kernel owns forward/reverse adjacency. + SiteEdges *graphcore.Directed[SiteID, Edge] + // BlockEdges is the canonical block topology derived from terminators. + BlockEdges *graphcore.Directed[int, BlockEdge] } // SiteID identifies one ordered semantic program point within a CFG block. @@ -54,6 +64,11 @@ type Edge struct { Case int } +type BlockEdge struct { + From int + To int +} + type SiteKind uint8 const ( @@ -65,13 +80,11 @@ const ( // Site records source identity and lexical scope at one CFG program point. type Site struct { - ID SiteID - Kind SiteKind - NodeID ir.NodeID - ScopeID ir.NodeID - Location *source.Location - Successors []Edge - Predecessors []Edge + ID SiteID + Kind SiteKind + NodeID ir.NodeID + ScopeID ir.NodeID + Location *source.Location } type BlockOrigin uint8 @@ -84,17 +97,22 @@ const ( BlockLoop BlockLoopBody BlockLoopLatch + // BlockLoopExit is the continuation a loop leaves to. It carries the loop's + // NodeID like the roles above, so a consumer can ask which loop it exits, + // but it is not part of the loop's structure: the code after the loop lives + // here. Before it existed, exiting a loop was identified by the absence of a + // role plus a NodeID that happened to name one. + BlockLoopExit ) type Block struct { - ID int - NodeID ir.NodeID - Origin BlockOrigin - Location *source.Location - Sites []*Site - Terminator Terminator - Predecessors []*Block - Reachable bool + ID int + NodeID ir.NodeID + Origin BlockOrigin + Location *source.Location + Sites []*Site + Terminator Terminator + Reachable bool } type Terminator interface { diff --git a/internal/ir/cfg/validate.go b/internal/ir/cfg/validate.go new file mode 100644 index 00000000..be608533 --- /dev/null +++ b/internal/ir/cfg/validate.go @@ -0,0 +1,322 @@ +package cfg + +import ( + "errors" + "fmt" + "sort" + "strings" +) + +// maxReportedProblems bounds one internal error so a systematic construction +// break reports a readable sample instead of one line per block in the module. +const maxReportedProblems = 10 + +// Validate checks that finalized control-flow topology is internally +// consistent: blocks and sites are identified as construction promises, every +// reachable block terminates, edges agree with the terminator that produced +// them, and every adjacency is recorded from both ends. A failure means CFG +// construction produced a malformed graph, so callers report it as an internal +// error, never as a source diagnostic. +// +// This is deliberately disjoint from Analyze. Analyze reports problems in the +// user's program — unreachable code, constant conditions, a missing return — +// against a graph it assumes well-formed. Validate checks the graph itself and +// says nothing about the program. Neither re-derives a semantic decision. +func (m *Module) Validate() error { + if m == nil { + return nil + } + problems := make([]string, 0) + for _, fn := range m.Functions { + if fn == nil { + problems = append(problems, "module holds a nil function graph") + continue + } + problems = append(problems, validateGraph(fn)...) + } + if len(problems) == 0 { + return nil + } + // Reported in a stable order: callers compare internal errors across runs, + // and block iteration is the only ordered part of the walk. + sort.Strings(problems) + total := len(problems) + if total > maxReportedProblems { + problems = problems[:maxReportedProblems] + return fmt.Errorf("%s (%d more)", strings.Join(problems, "; "), total-maxReportedProblems) + } + return errors.New(strings.Join(problems, "; ")) +} + +func validateGraph(fn *Graph) []string { + problems := validateBlockIdentity(fn) + // Every later check indexes blocks by ID, so a broken index makes their + // output noise rather than evidence. + if len(problems) > 0 { + return problems + } + problems = append(problems, validateTermination(fn)...) + problems = append(problems, validateBlockAdjacency(fn)...) + problems = append(problems, validateSites(fn)...) + problems = append(problems, validateReachability(fn)...) + return problems +} + +// validateBlockIdentity checks the promise every other check depends on: block +// IDs are dense indexes into Blocks, and entry and exit are blocks of this +// graph rather than of another one. +func validateBlockIdentity(fn *Graph) []string { + problems := make([]string, 0) + for index, block := range fn.Blocks { + if block == nil { + problems = append(problems, fmt.Sprintf("function %d holds a nil block at index %d", fn.NodeID, index)) + continue + } + if block.ID != index { + problems = append(problems, fmt.Sprintf("function %d block at index %d identifies as b%d", fn.NodeID, index, block.ID)) + } + } + if len(problems) > 0 { + return problems + } + if fn.Entry == nil { + problems = append(problems, fmt.Sprintf("function %d has no entry block", fn.NodeID)) + } else if !ownsBlock(fn, fn.Entry) { + problems = append(problems, fmt.Sprintf("function %d entry b%d is not one of its blocks", fn.NodeID, fn.Entry.ID)) + } + if fn.Exit == nil { + problems = append(problems, fmt.Sprintf("function %d has no exit block", fn.NodeID)) + } else if !ownsBlock(fn, fn.Exit) { + problems = append(problems, fmt.Sprintf("function %d exit b%d is not one of its blocks", fn.NodeID, fn.Exit.ID)) + } + return problems +} + +// validateTermination checks that control leaves every reachable block. The +// exit block is the one exception: it is where control stops. +func validateTermination(fn *Graph) []string { + problems := make([]string, 0) + for _, block := range fn.Blocks { + if block == fn.Exit { + if block.Terminator != nil { + problems = append(problems, fmt.Sprintf("function %d exit b%d carries a terminator", fn.NodeID, block.ID)) + } + continue + } + if block.Terminator == nil { + // An unreachable block may legitimately have been abandoned mid + // construction; a reachable one leaves control nowhere. + if block.Reachable { + problems = append(problems, fmt.Sprintf("function %d reachable block b%d has no terminator", fn.NodeID, block.ID)) + } + continue + } + for _, successor := range block.Terminator.Successors() { + if successor == nil { + problems = append(problems, fmt.Sprintf("function %d block b%d transfers to a nil block", fn.NodeID, block.ID)) + } else if !ownsBlock(fn, successor) { + problems = append(problems, fmt.Sprintf("function %d block b%d transfers to b%d, which is not one of its blocks", fn.NodeID, block.ID, successor.ID)) + } + } + if variant, ok := block.Terminator.(*SwitchVariant); ok { + problems = append(problems, validateVariantCases(fn, block, variant)...) + } + } + return problems +} + +// validateVariantCases checks that a variant switch selects each case once. +// Which cases a switch should carry is a typechecking decision; that two +// targets claim the same one is a topology defect, because the second is +// unreachable through the edge that names it. +func validateVariantCases(fn *Graph, block *Block, term *SwitchVariant) []string { + problems := make([]string, 0) + seen := make(map[int]bool, len(term.Targets)) + for _, target := range term.Targets { + if target.Case < 0 { + problems = append(problems, fmt.Sprintf("function %d block b%d switches on negative case %d", fn.NodeID, block.ID, target.Case)) + } + if seen[target.Case] { + problems = append(problems, fmt.Sprintf("function %d block b%d switches twice on case %d", fn.NodeID, block.ID, target.Case)) + } + seen[target.Case] = true + } + return problems +} + +// validateBlockAdjacency checks that block-level predecessors record exactly +// the transfers terminators make. A consumer walking backwards must see the +// same graph as one walking forwards. +func validateBlockAdjacency(fn *Graph) []string { + problems := make([]string, 0) + if fn.BlockEdges == nil { + return append(problems, fmt.Sprintf("function %d has no block topology", fn.NodeID)) + } + forward := make(map[[2]int]bool) + for _, block := range fn.Blocks { + if block.Terminator == nil { + continue + } + for _, successor := range block.Terminator.Successors() { + if successor != nil && ownsBlock(fn, successor) { + forward[[2]int{block.ID, successor.ID}] = true + } + } + } + for _, edge := range fn.BlockEdges.Edges() { + pair := [2]int{edge.From, edge.To} + if !forward[pair] { + problems = append(problems, fmt.Sprintf("function %d block topology records b%d -> b%d, but the terminator does not", fn.NodeID, edge.From, edge.To)) + } + delete(forward, pair) + } + for pair := range forward { + problems = append(problems, fmt.Sprintf("function %d block b%d transfers to b%d, which is absent from block topology", fn.NodeID, pair[0], pair[1])) + } + return problems +} + +// validateSites checks the program points consumers key evidence against: every +// block owns at least one, each carries the identity its position implies, and +// every site edge resolves, agrees with the terminator that produced it, and is +// recorded from both ends. +func validateSites(fn *Graph) []string { + problems := make([]string, 0) + for _, block := range fn.Blocks { + if len(block.Sites) == 0 { + problems = append(problems, fmt.Sprintf("function %d block b%d owns no site", fn.NodeID, block.ID)) + continue + } + for index, site := range block.Sites { + if site == nil { + problems = append(problems, fmt.Sprintf("function %d block b%d holds a nil site at index %d", fn.NodeID, block.ID, index)) + continue + } + want := SiteID{Block: block.ID, Index: index} + if site.ID != want { + problems = append(problems, fmt.Sprintf("function %d site at b%d[%d] identifies as b%d[%d]", fn.NodeID, block.ID, index, site.ID.Block, site.ID.Index)) + } + if site.Kind == SiteScopeExit && site.ScopeID == 0 { + problems = append(problems, fmt.Sprintf("function %d scope exit at b%d[%d] names no scope", fn.NodeID, block.ID, index)) + } + } + } + if len(problems) > 0 { + return problems + } + return validateSiteEdges(fn) +} + +func validateSiteEdges(fn *Graph) []string { + problems := make([]string, 0) + if fn.SiteEdges == nil { + return append(problems, fmt.Sprintf("function %d has no site topology", fn.NodeID)) + } + // Check the derived index against source topology, not against another + // adjacency index. A return has no block successor, but does have a site + // edge to the function exit. + expected := make(map[Edge]bool) + for _, block := range fn.Blocks { + last := len(block.Sites) - 1 + for index := range last { + expected[Edge{From: block.Sites[index].ID, To: block.Sites[index+1].ID, Kind: EdgeNormal}] = true + } + expect := func(target *Block, kind EdgeKind, caseIndex int) { + if target != nil && ownsBlock(fn, target) { + expected[Edge{From: block.Sites[last].ID, To: target.Sites[0].ID, Kind: kind, Case: caseIndex}] = true + } + } + switch term := block.Terminator.(type) { + case *Jump: + expect(term.Target, EdgeNormal, 0) + case *Branch: + expect(term.TrueTarget, EdgeTrue, 0) + expect(term.FalseTarget, EdgeFalse, 0) + case *Return: + expect(fn.Exit, EdgeReturn, 0) + case *SwitchVariant: + for _, target := range term.Targets { + expect(target.Target, EdgeVariantCase, target.Case) + } + case nil: + default: + problems = append(problems, fmt.Sprintf("function %d block b%d has unknown terminator %T", fn.NodeID, block.ID, term)) + } + } + for _, edge := range fn.SiteEdges.Edges() { + if siteAt(fn, edge.From) == nil { + problems = append(problems, fmt.Sprintf("function %d edge leaves %v, which is not a site", fn.NodeID, edge.From)) + } + if siteAt(fn, edge.To) == nil { + problems = append(problems, fmt.Sprintf("function %d edge transfers to %v, which is not a site", fn.NodeID, edge.To)) + } + if !expected[edge] { + problems = append(problems, fmt.Sprintf("function %d site edge %v -> %v (%s, case %d) is not described by its block sites or terminator", fn.NodeID, edge.From, edge.To, edgeKindName(edge.Kind), edge.Case)) + } + delete(expected, edge) + } + for edge := range expected { + problems = append(problems, fmt.Sprintf("function %d site edge %v -> %v (%s, case %d) is absent from site topology", fn.NodeID, edge.From, edge.To, edgeKindName(edge.Kind), edge.Case)) + } + return problems +} + +// validateReachability checks the flag consumers trust against the traversal it +// claims to summarize. Analyze reports unreachable user code from this flag, so +// a stale flag turns a construction defect into a wrong diagnostic. +func validateReachability(fn *Graph) []string { + seen := make(map[int]bool, len(fn.Blocks)) + var walk func(block *Block) + walk = func(block *Block) { + if block == nil || seen[block.ID] { + return + } + seen[block.ID] = true + if block.Terminator == nil { + return + } + for _, successor := range block.Terminator.Successors() { + walk(successor) + } + } + walk(fn.Entry) + + problems := make([]string, 0) + for _, block := range fn.Blocks { + if block.Reachable != seen[block.ID] { + problems = append(problems, fmt.Sprintf("function %d block b%d is marked reachable=%t but entry traversal says %t", fn.NodeID, block.ID, block.Reachable, seen[block.ID])) + } + } + return problems +} + +func ownsBlock(fn *Graph, block *Block) bool { + return block.ID >= 0 && block.ID < len(fn.Blocks) && fn.Blocks[block.ID] == block +} + +func siteAt(fn *Graph, id SiteID) *Site { + if id.Block < 0 || id.Block >= len(fn.Blocks) { + return nil + } + block := fn.Blocks[id.Block] + if id.Index < 0 || id.Index >= len(block.Sites) { + return nil + } + return block.Sites[id.Index] +} + +func edgeKindName(kind EdgeKind) string { + switch kind { + case EdgeNormal: + return "normal" + case EdgeTrue: + return "true" + case EdgeFalse: + return "false" + case EdgeReturn: + return "return" + case EdgeVariantCase: + return "variant-case" + } + return fmt.Sprintf("unknown(%d)", kind) +} diff --git a/internal/ir/cfg/validate_test.go b/internal/ir/cfg/validate_test.go new file mode 100644 index 00000000..04237d9b --- /dev/null +++ b/internal/ir/cfg/validate_test.go @@ -0,0 +1,268 @@ +package cfg + +import ( + "strings" + "testing" + + "compiler/internal/frontend/ast" + graphcore "compiler/internal/graph" + "compiler/internal/source" +) + +// branchingModule builds a graph with every terminator kind reachable from one +// function, so a break in any check group has something real to break. +func branchingModule(t *testing.T) *Module { + t.Helper() + location := source.NewLocation("validate_test.peep", source.Position{Line: 1, Column: 1}, source.Position{Line: 1, Column: 10}) + branch := &ast.IfStmt{ + NodeIDHolder: ast.NodeIDHolder{NodeID: 30}, + Cond: &ast.BoolLit{NodeIDHolder: ast.NodeIDHolder{NodeID: 31}, Value: true, Location: location}, + Then: &ast.BlockStmt{ + NodeIDHolder: ast.NodeIDHolder{NodeID: 32}, + Stmts: []ast.Stmt{&ast.ReturnStmt{NodeIDHolder: ast.NodeIDHolder{NodeID: 33}, Location: location}}, + }, + Else: &ast.BlockStmt{NodeIDHolder: ast.NodeIDHolder{NodeID: 34}}, + Location: location, + } + body := &ast.BlockStmt{NodeIDHolder: ast.NodeIDHolder{NodeID: 10}, Stmts: []ast.Stmt{branch}} + module := BuildModule(testModule(body, nil), BuildQueries{}) + if module == nil || len(module.Functions) != 1 { + t.Fatalf("test fixture built %#v, want one function graph", module) + } + if module.Functions[0] == nil { + t.Fatal("test fixture built a nil graph") + } + return module +} + +// A graph the builder produced must validate. Without this the negative tests +// below could all pass against a fixture that was malformed to begin with. +func TestValidateAcceptsConstructedTopology(t *testing.T) { + if err := branchingModule(t).Validate(); err != nil { + t.Fatalf("constructed topology rejected: %v", err) + } +} + +func TestValidateRejectsTopologyDefects(t *testing.T) { + tests := []struct { + name string + damage func(*Graph) + want string + }{ + { + name: "block identity does not match its index", + damage: func(fn *Graph) { fn.Blocks[2].ID = 99 }, + want: "identifies as b99", + }, + { + name: "entry belongs to another graph", + damage: func(fn *Graph) { fn.Entry = &Block{ID: 0} }, + want: "entry b0 is not one of its blocks", + }, + { + name: "exit is missing", + damage: func(fn *Graph) { fn.Exit = nil }, + want: "has no exit block", + }, + { + name: "a reachable block leaves control nowhere", + damage: func(fn *Graph) { + fn.Entry.Terminator = nil + }, + want: "reachable block b0 has no terminator", + }, + { + name: "the exit block terminates", + damage: func(fn *Graph) { + fn.Exit.Terminator = &Jump{Target: fn.Entry} + }, + want: "carries a terminator", + }, + { + name: "a transfer leaves the graph", + damage: func(fn *Graph) { + fn.Entry.Terminator = &Jump{Target: &Block{ID: 7}} + }, + want: "is not one of its blocks", + }, + { + name: "a variant switch claims one case twice", + damage: func(fn *Graph) { + fn.Entry.Terminator = &SwitchVariant{Targets: []VariantTarget{ + {Case: 0, Target: fn.Exit}, {Case: 0, Target: fn.Exit}, + }} + }, + want: "switches twice on case 0", + }, + { + name: "a predecessor records a transfer that does not exist", + damage: func(fn *Graph) { + fn.BlockEdges.AddEdge(BlockEdge{From: fn.Blocks[2].ID, To: fn.Exit.ID}) + }, + want: "but the terminator does not", + }, + { + name: "block edge with foreign source", + damage: func(fn *Graph) { fn.BlockEdges.AddEdge(BlockEdge{From: 99, To: fn.Exit.ID}) }, + want: "but the terminator does not", + }, + { + name: "block edge with foreign target", + damage: func(fn *Graph) { fn.BlockEdges.AddEdge(BlockEdge{From: fn.Entry.ID, To: 99}) }, + want: "but the terminator does not", + }, + { + name: "entirely foreign block edge", + damage: func(fn *Graph) { fn.BlockEdges.AddEdge(BlockEdge{From: 98, To: 99}) }, + want: "but the terminator does not", + }, + { + name: "entirely foreign site edge", + damage: func(fn *Graph) { fn.SiteEdges.AddEdge(Edge{From: SiteID{Block: 98}, To: SiteID{Block: 99}}) }, + want: "which is not a site", + }, + { + name: "missing site edges", + damage: func(fn *Graph) { + fn.SiteEdges = graphcore.NewDirected(func(edge Edge) (SiteID, SiteID) { return edge.From, edge.To }) + }, + want: "absent from site topology", + }, + { + name: "branch edge names wrong valid target", + damage: func(fn *Graph) { + rewriteFirstSiteEdge(fn, func(edge Edge) Edge { edge.To = fn.Entry.Sites[0].ID; return edge }) + }, + want: "not described by its block sites or terminator", + }, + { + name: "branch edge carries spurious case metadata", + damage: func(fn *Graph) { + rewriteFirstSiteEdge(fn, func(edge Edge) Edge { edge.Case = 99; return edge }) + }, + want: "not described by its block sites or terminator", + }, + { + name: "a transfer goes unrecorded by its target", + damage: func(fn *Graph) { + fn.BlockEdges = graphcore.NewDirected(func(edge BlockEdge) (int, int) { return edge.From, edge.To }) + }, + want: "absent from block topology", + }, + { + name: "a site carries the wrong identity", + damage: func(fn *Graph) { + fn.Entry.Sites[0].ID = SiteID{Block: 4, Index: 6} + }, + want: "identifies as b4[6]", + }, + { + name: "a scope exit names no scope", + damage: func(fn *Graph) { + for _, block := range fn.Blocks { + for _, site := range block.Sites { + if site.Kind == SiteScopeExit { + site.ScopeID = 0 + return + } + } + } + t.Fatal("fixture has no scope exit site to damage") + }, + want: "names no scope", + }, + { + name: "a site edge points at no site", + damage: func(fn *Graph) { + rewriteFirstSiteEdge(fn, func(edge Edge) Edge { + edge.To = SiteID{Block: 42, Index: 0} + return edge + }) + }, + want: "which is not a site", + }, + { + name: "a branch leaves on a plain sequence edge", + damage: func(fn *Graph) { + rewriteFirstSiteEdge(fn, func(edge Edge) Edge { + edge.Kind = EdgeNormal + return edge + }) + }, + want: "not described by its block sites or terminator", + }, + { + name: "reachability disagrees with entry traversal", + damage: func(fn *Graph) { + fn.Blocks[len(fn.Blocks)-1].Reachable = !fn.Blocks[len(fn.Blocks)-1].Reachable + }, + want: "entry traversal says", + }, + } + + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + module := branchingModule(t) + test.damage(module.Functions[0]) + err := module.Validate() + if err == nil { + t.Fatalf("damaged topology accepted: %s", test.name) + } + if !strings.Contains(err.Error(), test.want) { + t.Fatalf("error = %q, want it to mention %q", err.Error(), test.want) + } + }) + } +} + +// Problems are collected by walking maps, so an unsorted report would name +// different defects on different runs and make an internal error unreproducible. +func TestValidateReportsDefectsDeterministically(t *testing.T) { + first := "" + for attempt := 0; attempt < 8; attempt++ { + module := branchingModule(t) + fn := module.Functions[0] + fn.BlockEdges = graphcore.NewDirected(func(edge BlockEdge) (int, int) { return edge.From, edge.To }) + for _, block := range fn.Blocks { + block.Reachable = !block.Reachable + } + err := module.Validate() + if err == nil { + t.Fatal("damaged topology accepted") + } + if attempt == 0 { + first = err.Error() + continue + } + if err.Error() != first { + t.Fatalf("report %d = %q, want %q", attempt, err.Error(), first) + } + } +} + +func rewriteFirstSiteEdge(fn *Graph, rewrite func(Edge) Edge) { + replacement := graphcore.NewDirected(func(edge Edge) (SiteID, SiteID) { return edge.From, edge.To }) + rewritten := false + for _, block := range fn.Blocks { + for _, site := range block.Sites { + for _, edge := range fn.SiteEdges.OutEdges(site.ID) { + if !rewritten { + edge = rewrite(edge) + rewritten = true + } + replacement.AddEdge(edge) + } + } + } + fn.SiteEdges = replacement +} + +func TestValidateAcceptsAnEmptyModule(t *testing.T) { + var missing *Module + if err := missing.Validate(); err != nil { + t.Fatalf("nil module rejected: %v", err) + } + if err := (&Module{}).Validate(); err != nil { + t.Fatalf("empty module rejected: %v", err) + } +} diff --git a/internal/ir/hir/fold/fold.go b/internal/ir/hir/fold/fold.go index 8cb234ca..807382c8 100644 --- a/internal/ir/hir/fold/fold.go +++ b/internal/ir/hir/fold/fold.go @@ -61,23 +61,18 @@ func foldStmt(types *ir.TypeTable, stmt hir.Stmt, env map[string]constvalue.Valu return []hir.Stmt{&hir.ExprStmt{Value: ir.FoldExpr(types, node.Value, env), NodeID: node.NodeID, ValueNodeID: node.ValueNodeID, Location: node.Location}} case *hir.Assign: return []hir.Stmt{&hir.Assign{ - Target: ir.FoldPlace(types, node.Target, env), - Value: ir.FoldExpr(types, node.Value, env), - DropTarget: node.DropTarget, - NodeID: node.NodeID, - Location: node.Location, + Target: ir.FoldPlace(types, node.Target, env), + Value: ir.FoldExpr(types, node.Value, env), + NodeID: node.NodeID, + Location: node.Location, }} case *hir.Invalid: return []hir.Stmt{node} case *hir.Return: - cleanup := make([]ir.Expr, 0, len(node.Cleanup)) - for _, expr := range node.Cleanup { - cleanup = append(cleanup, ir.FoldExpr(types, expr, env)) - } if node.Value == nil { - return []hir.Stmt{&hir.Return{Cleanup: cleanup, NodeID: node.NodeID, Location: node.Location}} + return []hir.Stmt{&hir.Return{NodeID: node.NodeID, Location: node.Location}} } - return []hir.Stmt{&hir.Return{Value: ir.FoldExpr(types, node.Value, env), Cleanup: cleanup, NodeID: node.NodeID, Location: node.Location}} + return []hir.Stmt{&hir.Return{Value: ir.FoldExpr(types, node.Value, env), NodeID: node.NodeID, Location: node.Location}} case *hir.If: thenBlock := foldBlock(types, node.Then, env) var elseStmt hir.Stmt diff --git a/internal/ir/hir/fold/fold_test.go b/internal/ir/hir/fold/fold_test.go index cfbadc36..5c966700 100644 --- a/internal/ir/hir/fold/fold_test.go +++ b/internal/ir/hir/fold/fold_test.go @@ -86,31 +86,6 @@ func TestApplyTypedExpressionFoldingPreservesStatementsAfterReturn(t *testing.T) } } -func TestApplyTypedExpressionFoldingPreservesReturnCleanup(t *testing.T) { - types := ir.NewTypeTable() - i32 := types.Intern(ir.Type{Kind: ir.TypeInteger, Signed: true, Bits: 32}) - ownedI32 := types.Intern(ir.Type{Kind: ir.TypeOwnedPtr, Elem: i32}) - mod := &hir.Module{Funcs: []*hir.Function{{ - Name: "main", - Body: &hir.Block{Stmts: []hir.Stmt{&hir.Return{ - Value: &ir.IntLit{Value: "0", Type: i32}, - Cleanup: []ir.Expr{&ir.Drop{Value: &ir.Ident{ - Name: "owner", - Type: ownedI32, - }}}, - }}}, - }}, Types: types} - - out := ApplyTypedExpressionFolding(mod) - ret, ok := out.Funcs[0].Body.Stmts[0].(*hir.Return) - if !ok || len(ret.Cleanup) != 1 { - t.Fatalf("folded return cleanup = %#v, want one expression", ret) - } - if _, ok := ret.Cleanup[0].(*ir.Drop); !ok { - t.Fatalf("folded cleanup = %#v, want drop", ret.Cleanup[0]) - } -} - func TestApplyTypedExpressionFoldingPreservesPlaceRootAndFoldsIndexes(t *testing.T) { types := ir.NewTypeTable() i32 := types.Intern(ir.Type{Kind: ir.TypeInteger, Signed: true, Bits: 32}) @@ -164,9 +139,8 @@ func TestApplyTypedExpressionFoldingFoldsAssignments(t *testing.T) { }}, Type: i32, }, - Value: &ir.Binary{Op: "+", Left: &ir.IntLit{Value: "20", Type: i32}, Right: &ir.IntLit{Value: "22", Type: i32}, Type: i32}, - DropTarget: true, - NodeID: 31, + Value: &ir.Binary{Op: "+", Left: &ir.IntLit{Value: "20", Type: i32}, Right: &ir.IntLit{Value: "22", Type: i32}, Type: i32}, + NodeID: 31, }, }}, }}, Types: types} @@ -177,7 +151,7 @@ func TestApplyTypedExpressionFoldingFoldsAssignments(t *testing.T) { index, indexOK := assignment.Target.Projections[0].Index.(*ir.IntLit) value, valueOK := assignment.Value.(*ir.IntLit) if !rootOK || root.Name != "items" || !indexOK || index.Value != "1" || !valueOK || value.Value != "42" || - !assignment.DropTarget || assignment.NodeID != 31 { + assignment.NodeID != 31 { t.Fatalf("folded assignment = %#v, want preserved target with folded index and value", assignment) } } diff --git a/internal/ir/hir/lower/lower_interface.go b/internal/ir/hir/lower/lower_interface.go index f85eab29..56905d98 100644 --- a/internal/ir/hir/lower/lower_interface.go +++ b/internal/ir/hir/lower/lower_interface.go @@ -32,13 +32,13 @@ func maybeLowerInterfaceExpr(ctx *project.CompilerContext, module *project.Modul dataType = target } slots := make([]ir.InterfaceSlot, 0, len(iface.Methods)) - implementations := module.Semantics.InterfaceImplementations[expr.ID()] + implementations := module.Typechecking.InterfaceImplementations[expr.ID()] if len(implementations) != len(iface.Methods) { return &ir.InvalidExpr{Message: "missing interface implementation evidence", Type: ir.InvalidType, SourceInfo: ir.SourceInfo{Location: ast.LocOf(expr)}} } for index, method := range iface.Methods { implementation := implementations[index] - if implementation.MethodName != method.Name || implementation.CallableType == nil || implementation.Symbol == nil || implementation.OwnerKey == "" { + if implementation.Symbol == nil || implementation.Symbol.Name != method.Name || implementation.CallableType == nil { return &ir.InvalidExpr{Message: "missing interface method implementation", Type: ir.InvalidType, SourceInfo: ir.SourceInfo{Location: ast.LocOf(expr)}} } slotType, ok := interfaceSlotTypeID(ctx, module, method) diff --git a/internal/ir/hir/lower/module_lower.go b/internal/ir/hir/lower/module_lower.go index 4df53715..74a96d67 100644 --- a/internal/ir/hir/lower/module_lower.go +++ b/internal/ir/hir/lower/module_lower.go @@ -15,6 +15,7 @@ import ( "compiler/internal/semantics/intrinsics" "compiler/internal/semantics/place" "compiler/internal/semantics/symbols" + "compiler/internal/semantics/typecheckresult" "compiler/internal/semantics/typeinfo" "compiler/internal/source" "compiler/pkg/numeric" @@ -26,7 +27,7 @@ func GenerateHIR(ctx *project.CompilerContext, module *project.Module) *hir.Modu return nil } out := &hir.Module{ - Name: module.ImportPath, + Name: module.ID.ImportPath, FilePath: module.FilePath, Types: ctx.Types, Externs: make([]hir.Extern, 0), @@ -48,7 +49,7 @@ func GenerateHIR(ctx *project.CompilerContext, module *project.Module) *hir.Modu } var sym *symbols.Symbol if fn.Receiver != nil { - sym = module.Semantics.MethodSymbol[fn.ID()] + sym = module.Bindings.MethodsByDecl[fn.ID()] } else { sym, _ = module.ModuleScope.Lookup(fn.Name.Name) } @@ -159,8 +160,8 @@ func appendBlock(module *project.Module, parentScope *symbols.Scope, out *hir.Bl out.Location = ast.LocOf(block) out.NodeID = hir.NodeID(block.ID()) scope := parentScope - if module.Semantics != nil { - if s, ok := module.Semantics.BlockScopes[block.ID()]; ok && s != nil { + if module.Bindings != nil { + if s, ok := module.Bindings.BlockScopes[block.ID()]; ok && s != nil { scope = s } } @@ -237,7 +238,7 @@ func appendStmt(module *project.Module, scope *symbols.Scope, out *hir.Block, st case *ast.ForStmt: out.Stmts = append(out.Stmts, lowerForStmt(ctx, module, scope, node, returnType)) case *ast.MatchStmt: - evidence, found := module.Semantics.Matches[node.ID()] + evidence, found := module.Typechecking.Matches[node.ID()] if !found || len(evidence.Arms) != len(node.Arms) { out.Stmts = append(out.Stmts, &hir.Invalid{Message: "match statement missing semantic evidence", NodeID: hir.NodeID(node.ID()), Location: ast.LocOf(node)}) return @@ -257,13 +258,22 @@ func appendStmt(module *project.Module, scope *symbols.Scope, out *hir.Block, st if arm.Payload != nil { caseBlock.PayloadType = loweredTypeID(ctx, module, arm.Payload) } - for _, field := range arm.Fields { + for _, field := range arm.Bindings { + wholePayload := false + switch field.Projection { + case typecheckresult.MatchPayloadField: + case typecheckresult.MatchWholePayload: + wholePayload = true + default: + out.Stmts = append(out.Stmts, &hir.Invalid{Message: "match binding has invalid projection", NodeID: hir.NodeID(node.ID()), Location: ast.LocOf(node)}) + return + } if field.Binding == nil { continue } caseBlock.Bindings = append(caseBlock.Bindings, hir.VariantBinding{ FieldIndex: field.Field, - WholePayload: field.WholePayload, + WholePayload: wholePayload, Name: symbolName(module, field.Binding), Type: loweredTypeID(ctx, module, field.Type), SymbolID: field.Binding.ID, @@ -323,8 +333,10 @@ func lowerForStmt(ctx *project.CompilerContext, module *project.Module, scope *s return loop } - evidence, found := module.Semantics.ForIterations[node.ID()] - if !found || evidence.Cursor == nil || evidence.Value == nil { + // Published evidence is complete by construction: the typechecker publishes + // only for a loop that typed cleanly. Absence is the one case to handle. + evidence, found := module.Typechecking.ForIterations[node.ID()] + if !found { return &hir.Invalid{Message: "for-in statement missing semantic evidence", NodeID: hir.NodeID(node.ID()), Location: location} } loop.Init = &hir.Block{Stmts: make([]hir.Stmt, 0), Location: location} @@ -332,46 +344,45 @@ func lowerForStmt(ctx *project.CompilerContext, module *project.Module, scope *s loop.Next = &hir.Block{Stmts: make([]hir.Stmt, 0), Location: location} boolType := loweredTypeID(ctx, module, &typeinfo.BoolType{}) - switch evidence.Kind { - case project.ForIterationRange: + switch plan := evidence.Plan.(type) { + case *typecheckresult.RangeIteration: + // Range evidence is published only for a RangeExpr iterable with both + // bounds present, so the assertion cannot fail for a published loop. rangeExpr, ok := node.Iterable.(*ast.RangeExpr) - if !ok || rangeExpr.Start == nil || rangeExpr.End == nil || evidence.End == nil { + if !ok { return &hir.Invalid{Message: "range iteration evidence does not match syntax", NodeID: hir.NodeID(node.ID()), Location: location} } loop.Init.Stmts = append(loop.Init.Stmts, generatedBinding(ctx, module, evidence.Cursor, lowerASTExpr(ctx, module, scope, rangeExpr.Start, evidence.ElementType), location), - generatedBinding(ctx, module, evidence.End, lowerASTExpr(ctx, module, scope, rangeExpr.End, evidence.ElementType), location), + generatedBinding(ctx, module, plan.Limit, lowerASTExpr(ctx, module, scope, rangeExpr.End, evidence.ElementType), location), ) - if evidence.Ordinal != nil { - ordinalType := loweredTypeID(ctx, module, evidence.Ordinal.Type) - loop.Init.Stmts = append(loop.Init.Stmts, generatedBinding(ctx, module, evidence.Ordinal, + if plan.Ordinal != nil { + ordinalType := loweredTypeID(ctx, module, plan.Ordinal.Type) + loop.Init.Stmts = append(loop.Init.Stmts, generatedBinding(ctx, module, plan.Ordinal, &ir.IntLit{Value: "0", Type: ordinalType, SourceInfo: ir.SourceInfo{Location: location}}, location)) } loop.Cond = &ir.Binary{ - Op: "<", Left: generatedIdent(ctx, module, evidence.Cursor, location), Right: generatedIdent(ctx, module, evidence.End, location), Type: boolType, + Op: "<", Left: generatedIdent(ctx, module, evidence.Cursor, location), Right: generatedIdent(ctx, module, plan.Limit, location), Type: boolType, SourceInfo: ir.SourceInfo{Location: location}, } if evidence.Index != nil { loop.Bindings.Stmts = append(loop.Bindings.Stmts, - generatedBinding(ctx, module, evidence.Index, generatedIdent(ctx, module, evidence.Ordinal, location), location)) + generatedBinding(ctx, module, evidence.Index, generatedIdent(ctx, module, plan.Ordinal, location), location)) } loop.Bindings.Stmts = append(loop.Bindings.Stmts, generatedBinding(ctx, module, evidence.Value, generatedIdent(ctx, module, evidence.Cursor, location), location)) loop.Next.Stmts = append(loop.Next.Stmts, incrementSymbol(ctx, module, evidence.Cursor, location)) - if evidence.Ordinal != nil { - loop.Next.Stmts = append(loop.Next.Stmts, incrementSymbol(ctx, module, evidence.Ordinal, location)) - } - case project.ForIterationSequence: - if evidence.Carrier == nil { - return &hir.Invalid{Message: "sequence iteration missing carrier evidence", NodeID: hir.NodeID(node.ID()), Location: location} + if plan.Ordinal != nil { + loop.Next.Stmts = append(loop.Next.Stmts, incrementSymbol(ctx, module, plan.Ordinal, location)) } - carrier := generatedIdent(ctx, module, evidence.Carrier, location) + case *typecheckresult.SequenceIteration: + carrier := generatedIdent(ctx, module, plan.Carrier, location) cursor := generatedIdent(ctx, module, evidence.Cursor, location) cursorType := loweredTypeID(ctx, module, evidence.Cursor.Type) elementType := loweredTypeID(ctx, module, evidence.ElementType) loop.Init.Stmts = append(loop.Init.Stmts, - generatedBinding(ctx, module, evidence.Carrier, - lowerImplicitReferenceValue(ctx, module, scope, node.Iterable, evidence.CarrierType), location), + generatedBinding(ctx, module, plan.Carrier, + lowerImplicitReferenceValue(ctx, module, scope, node.Iterable, plan.CarrierType), location), generatedBinding(ctx, module, evidence.Cursor, &ir.IntLit{Value: "0", Type: cursorType, SourceInfo: ir.SourceInfo{Location: location}}, location), ) @@ -386,7 +397,7 @@ func lowerForStmt(ctx *project.CompilerContext, module *project.Module, scope *s } loop.Bindings.Stmts = append(loop.Bindings.Stmts, generatedBinding(ctx, module, evidence.Value, &ir.Load{Place: &ir.Place{ - Root: generatedIdent(ctx, module, evidence.Carrier, location), + Root: generatedIdent(ctx, module, plan.Carrier, location), Projections: []ir.PlaceProjection{{ Kind: ir.PlaceProjectionIndex, Index: generatedIdent(ctx, module, evidence.Cursor, location), Type: elementType, Location: location, }}, @@ -539,7 +550,7 @@ func lowerReferenceValue(ctx *project.CompilerContext, module *project.Module, s exprType := func(node ast.Expr) typeinfo.Type { return exprResolvedType(module, node) } - if !place.Addressable(scope, expr, exprType, expandedDefaultBindingResolver(module)) { + if !place.Addressable(scope, expr, exprType, module.ExpandedDefaultBinding) { return &ir.TempBorrow{ Value: lowerASTExpr(ctx, module, scope, expr, target), Slice: borrowAsView, @@ -614,8 +625,8 @@ func lowerASTExpr(ctx *project.CompilerContext, module *project.Module, scope *s } expectedTypeID := loweredTypeID(ctx, module, expectedType) conversion, converting := typeinfo.Conversion{}, false - if module != nil && module.Semantics != nil { - conversion, converting = module.Semantics.ImplicitConversions[expr.ID()] + if module != nil && module.Typechecking != nil { + conversion, converting = module.Typechecking.ImplicitConversions[expr.ID()] } if module != nil && module.Flow != nil { if test, ok := module.Flow.CaseTests[expr.ID()]; ok { @@ -657,7 +668,7 @@ func lowerASTExpr(ctx *project.CompilerContext, module *project.Module, scope *s } } } - if construction, ok := module.Semantics.VariantConstructions[expr.ID()]; ok { + if construction, ok := module.Typechecking.VariantConstructions[expr.ID()]; ok { variant := &ir.VariantMake{ Case: construction.Case, Type: loweredTypeID(ctx, module, construction.EnumType), @@ -707,8 +718,8 @@ func lowerASTExpr(ctx *project.CompilerContext, module *project.Module, scope *s case *ast.ScopeResolution: var sym *symbols.Symbol - if module != nil && module.Semantics != nil { - sym = module.Semantics.ResolvedSymbols[node.ID()] + if module != nil && module.Bindings != nil { + sym = module.Bindings.NodeSymbols[node.ID()] } if sym == nil { if qualifier, member, imported := node.ImportValueMember(); imported { @@ -760,7 +771,7 @@ func lowerASTExpr(ctx *project.CompilerContext, module *project.Module, scope *s return &ir.AddrOf{Place: lowerPlace(ctx, module, scope, node.Expr), Type: t, SourceInfo: ir.SourceInfo{Location: loc}} case *ast.BinaryExpr: - if _, concat := module.Semantics.StringConcatenations[node.ID()]; concat { + if _, concat := module.Typechecking.StringConcatenations[node.ID()]; concat { return &ir.StringConcat{ Left: lowerASTExpr(ctx, module, scope, node.Left, &typeinfo.StringType{}), Right: lowerASTExpr(ctx, module, scope, node.Right, &typeinfo.RefType{Target: &typeinfo.StringType{}}), @@ -800,35 +811,39 @@ func lowerASTExpr(ctx *project.CompilerContext, module *project.Module, scope *s return &ir.Binary{Op: node.Op, Left: left, Right: right, Type: t, SourceInfo: ir.SourceInfo{Location: loc}} case *ast.CallExpr: - if compilerCall, ok := module.Semantics.CompilerCalls[node.ID()]; ok { + effectiveArgs, ok := module.Typechecking.EffectiveCallArguments[node.ID()] + if !ok { + return &ir.InvalidExpr{Message: "call missing effective argument evidence", Type: ir.InvalidType, SourceInfo: ir.SourceInfo{Location: loc}} + } + if compilerCall, ok := module.Typechecking.CompilerCalls[node.ID()]; ok { switch compilerCall.Kind { case intrinsics.FunctionAlloc: - return lowerAllocCall(ctx, module, scope, node) + return lowerAllocCall(ctx, module, scope, node, effectiveArgs) case intrinsics.FunctionCollection: - return lowerCollectionCall(ctx, module, scope, node, compilerCall.Operation) + return lowerCollectionCall(ctx, module, scope, node, effectiveArgs, compilerCall.Operation) case intrinsics.FunctionDynamicArrayOwner: - return lowerDynamicArrayOwnerCall(ctx, module, scope, node, compilerCall.Operation) + return lowerDynamicArrayOwnerCall(ctx, module, scope, node, effectiveArgs, compilerCall.Operation) case intrinsics.FunctionFromBytes: - return lowerStringFromBytesCall(ctx, module, scope, node) + return lowerStringFromBytesCall(ctx, module, scope, node, effectiveArgs) default: panic(fmt.Sprintf("unsupported intrinsic function kind %d for %q", compilerCall.Kind, compilerCall.Operation)) } } if selector, ok := node.Callee.(*ast.SelectorExpr); ok && selector != nil { - return lowerSelectorMethodCall(ctx, module, scope, selector, node) + return lowerSelectorMethodCall(ctx, module, scope, selector, node, effectiveArgs) } calleeExpr := lowerASTExpr(ctx, module, scope, node.Callee, nil) - args := make([]ir.Expr, 0, len(node.Args)) + args := make([]ir.Expr, 0, len(effectiveArgs)) var fnType *typeinfo.FuncType if resolved := exprResolvedType(module, node.Callee); resolved != nil { fnType, _ = typeinfo.Underlying(resolved).(*typeinfo.FuncType) } - for _, arg := range node.Args { + for _, arg := range effectiveArgs { var paramExpected typeinfo.Type if fnType != nil && len(args) < len(fnType.Params) { paramExpected = fnType.Params[len(args)] } - if implicit := module.Semantics.ImplicitCallArguments[arg.ID()]; implicit != nil { + if implicit := module.Typechecking.ImplicitCallArguments[arg.ID()]; implicit != nil { args = append(args, lowerImplicitReferenceValue(ctx, module, scope, arg, implicit)) } else { args = append(args, lowerASTExpr(ctx, module, scope, arg, paramExpected)) @@ -897,14 +912,14 @@ func lowerASTExpr(ctx *project.CompilerContext, module *project.Module, scope *s } } -func lowerCollectionCall(ctx *project.CompilerContext, module *project.Module, scope *symbols.Scope, call *ast.CallExpr, op symbols.CompilerOp) ir.Expr { +func lowerCollectionCall(ctx *project.CompilerContext, module *project.Module, scope *symbols.Scope, call *ast.CallExpr, effectiveArgs []ast.Expr, op symbols.CompilerOp) ir.Expr { fnType, _ := exprResolvedType(module, call.Callee).(*typeinfo.FuncType) - if fnType == nil || len(fnType.Params) != 1 { - return &ir.InvalidExpr{Message: "collection function type missing", Type: ir.InvalidType, SourceInfo: ir.SourceInfo{Location: ast.LocOf(call)}} + if fnType == nil || len(fnType.Params) != 1 || len(effectiveArgs) != 1 { + return &ir.InvalidExpr{Message: "collection function type or arguments missing", Type: ir.InvalidType, SourceInfo: ir.SourceInfo{Location: ast.LocOf(call)}} } - value := call.Args[0] + value := effectiveArgs[0] var receiver ir.Expr - if implicit := module.Semantics.ImplicitCallArguments[value.ID()]; implicit != nil { + if implicit := module.Typechecking.ImplicitCallArguments[value.ID()]; implicit != nil { receiver = lowerImplicitReferenceValue(ctx, module, scope, value, implicit) } else { receiver = lowerASTExpr(ctx, module, scope, value, fnType.Params[0]) @@ -954,14 +969,14 @@ func optionalPromotionInnerType(expectedType, resolvedType typeinfo.Type, expr a return expected.Inner } -func lowerSelectorMethodCall(ctx *project.CompilerContext, module *project.Module, scope *symbols.Scope, selector *ast.SelectorExpr, call *ast.CallExpr) ir.Expr { +func lowerSelectorMethodCall(ctx *project.CompilerContext, module *project.Module, scope *symbols.Scope, selector *ast.SelectorExpr, call *ast.CallExpr, effectiveArgs []ast.Expr) ir.Expr { if module == nil || selector == nil || selector.Expr == nil || selector.Name == nil { return &ir.InvalidExpr{Message: "invalid selector call", Type: ir.InvalidType} } baseType := exprResolvedType(module, selector.Expr) if iface, slot, ok := lookupInterfaceMethod(module, baseType, selector.Name.Name); ok { - args := make([]ir.Expr, 0, len(call.Args)) - for i, arg := range call.Args { + args := make([]ir.Expr, 0, len(effectiveArgs)) + for i, arg := range effectiveArgs { var argExpected typeinfo.Type if i+1 < len(iface.Params) { argExpected = iface.Params[i+1].Type @@ -982,7 +997,7 @@ func lowerSelectorMethodCall(ctx *project.CompilerContext, module *project.Modul SourceInfo: ir.SourceInfo{Location: ast.LocOf(call)}, } } - methodSym := module.Semantics.ResolvedSymbols[selector.Name.ID()] + methodSym := module.Bindings.NodeSymbols[selector.Name.ID()] fnType, _ := exprResolvedType(module, selector).(*typeinfo.FuncType) if methodSym == nil || fnType == nil || len(fnType.Params) == 0 { return &ir.InvalidExpr{Message: "unsupported selector call lowering", Type: ir.InvalidType} @@ -991,14 +1006,14 @@ func lowerSelectorMethodCall(ctx *project.CompilerContext, module *project.Modul return &ir.InvalidExpr{Message: "selector method receiver missing", Type: ir.InvalidType} } var baseExpr ir.Expr - if implicit := module.Semantics.ImplicitCallArguments[selector.Expr.ID()]; implicit != nil { + if implicit := module.Typechecking.ImplicitCallArguments[selector.Expr.ID()]; implicit != nil { baseExpr = lowerImplicitReferenceValue(ctx, module, scope, selector.Expr, implicit) } else { baseExpr = lowerASTExpr(ctx, module, scope, selector.Expr, nil) } - args := make([]ir.Expr, 0, len(call.Args)+1) + args := make([]ir.Expr, 0, len(effectiveArgs)+1) args = append(args, baseExpr) - for i, arg := range call.Args { + for i, arg := range effectiveArgs { var argExpected typeinfo.Type if i+1 < len(fnType.Params) { argExpected = fnType.Params[i+1] @@ -1036,7 +1051,7 @@ func lowerSelectorExpr(ctx *project.CompilerContext, module *project.Module, sco exprType := func(expr ast.Expr) typeinfo.Type { return exprResolvedType(module, expr) } - if throughPtr || place.Addressable(scope, selector.Expr, exprType, expandedDefaultBindingResolver(module)) { + if throughPtr || place.Addressable(scope, selector.Expr, exprType, module.ExpandedDefaultBinding) { return &ir.Load{Place: lowerPlace(ctx, module, scope, selector), SourceInfo: ir.SourceInfo{NodeID: ir.NodeID(selector.ID()), Location: ast.LocOf(selector)}} } return &ir.Field{ @@ -1150,14 +1165,14 @@ func lowerArrayLiteralExpr(ctx *project.CompilerContext, module *project.Module, } } -func lowerDynamicArrayOwnerCall(ctx *project.CompilerContext, module *project.Module, scope *symbols.Scope, node *ast.CallExpr, op symbols.CompilerOp) ir.Expr { +func lowerDynamicArrayOwnerCall(ctx *project.CompilerContext, module *project.Module, scope *symbols.Scope, node *ast.CallExpr, effectiveArgs []ast.Expr, op symbols.CompilerOp) ir.Expr { fnType, _ := typeinfo.Underlying(exprResolvedType(module, node.Callee)).(*typeinfo.FuncType) - if fnType == nil || len(fnType.Params) != len(node.Args) || len(node.Args) < 2 { - return &ir.InvalidExpr{Message: "dynamic-array operation type missing", Type: ir.InvalidType, SourceInfo: ir.SourceInfo{Location: ast.LocOf(node)}} + if fnType == nil || len(fnType.Params) != len(effectiveArgs) || len(effectiveArgs) < 2 { + return &ir.InvalidExpr{Message: "dynamic-array operation type or arguments missing", Type: ir.InvalidType, SourceInfo: ir.SourceInfo{Location: ast.LocOf(node)}} } - args := make([]ir.Expr, 0, len(node.Args)) - for i, arg := range node.Args { - if implicit := module.Semantics.ImplicitCallArguments[arg.ID()]; implicit != nil { + args := make([]ir.Expr, 0, len(effectiveArgs)) + for i, arg := range effectiveArgs { + if implicit := module.Typechecking.ImplicitCallArguments[arg.ID()]; implicit != nil { args = append(args, lowerImplicitReferenceValue(ctx, module, scope, arg, implicit)) } else { args = append(args, lowerASTExpr(ctx, module, scope, arg, fnType.Params[i])) @@ -1191,14 +1206,14 @@ func lowerDynamicArrayOwnerCall(ctx *project.CompilerContext, module *project.Mo return out } -func lowerAllocCall(ctx *project.CompilerContext, module *project.Module, scope *symbols.Scope, node *ast.CallExpr) ir.Expr { - if len(node.Args) < 1 || len(node.Args) > 2 { - return &ir.InvalidExpr{Message: "alloc requires 1 or 2 arguments", Type: ir.InvalidType, SourceInfo: ir.SourceInfo{Location: ast.LocOf(node)}} +func lowerAllocCall(ctx *project.CompilerContext, module *project.Module, scope *symbols.Scope, node *ast.CallExpr, effectiveArgs []ast.Expr) ir.Expr { + if len(effectiveArgs) < 1 || len(effectiveArgs) > 2 { + return &ir.InvalidExpr{Message: "alloc requires 1 or 2 effective arguments", Type: ir.InvalidType, SourceInfo: ir.SourceInfo{Location: ast.LocOf(node)}} } - value := lowerASTExpr(ctx, module, scope, node.Args[0], nil) + value := lowerASTExpr(ctx, module, scope, effectiveArgs[0], nil) var allocator ir.Expr - if len(node.Args) > 1 { - allocator = lowerASTExpr(ctx, module, scope, node.Args[1], &typeinfo.AllocatorType{}) + if len(effectiveArgs) > 1 { + allocator = lowerASTExpr(ctx, module, scope, effectiveArgs[1], &typeinfo.AllocatorType{}) } resultType := loweredTypeID(ctx, module, exprResolvedType(module, node)) return &ir.AllocExpr{ @@ -1209,15 +1224,15 @@ func lowerAllocCall(ctx *project.CompilerContext, module *project.Module, scope } } -func lowerStringFromBytesCall(ctx *project.CompilerContext, module *project.Module, scope *symbols.Scope, node *ast.CallExpr) ir.Expr { +func lowerStringFromBytesCall(ctx *project.CompilerContext, module *project.Module, scope *symbols.Scope, node *ast.CallExpr, effectiveArgs []ast.Expr) ir.Expr { fnType, _ := exprResolvedType(module, node.Callee).(*typeinfo.FuncType) - if fnType == nil || len(fnType.Params) != 2 || len(node.Args) < 1 || len(node.Args) > 2 { - panic("validated from_bytes call missing intrinsic signature or arguments") + if fnType == nil || len(fnType.Params) != 2 || len(effectiveArgs) < 1 || len(effectiveArgs) > 2 { + panic("validated from_bytes call missing intrinsic signature or effective arguments") } - bytes := lowerASTExpr(ctx, module, scope, node.Args[0], fnType.Params[0]) + bytes := lowerASTExpr(ctx, module, scope, effectiveArgs[0], fnType.Params[0]) var allocator ir.Expr - if len(node.Args) == 2 { - allocator = lowerASTExpr(ctx, module, scope, node.Args[1], fnType.Params[1]) + if len(effectiveArgs) == 2 { + allocator = lowerASTExpr(ctx, module, scope, effectiveArgs[1], fnType.Params[1]) } return &ir.StringFromBytes{ Bytes: bytes, @@ -1239,8 +1254,8 @@ func lowerIdentExpr(ctx *project.CompilerContext, module *project.Module, scope return &ir.InvalidExpr{Message: "nil identifier", Type: ir.InvalidType} } var sym *symbols.Symbol - if module != nil && module.Semantics != nil { - sym = module.Semantics.ResolvedSymbols[node.ID()] + if module != nil && module.Bindings != nil { + sym = module.Bindings.NodeSymbols[node.ID()] } if sym == nil && scope != nil { sym, _ = scope.Lookup(node.Name) @@ -1310,7 +1325,7 @@ func callableName(module *project.Module, sym *symbols.Symbol) (string, bool) { return name, true } } - if module != nil && module.IsEntry && sym.Kind == symbols.SymbolFunc && sym.Name == "main" && sym.DefiningModule == module.DefiningModuleKey() { + if module != nil && module.IsEntry && sym.Kind == symbols.SymbolFunc && sym.Name == "main" && sym.DefiningModule == module.ID { return "main", false } receiver := "" @@ -1343,23 +1358,11 @@ func callableName(module *project.Module, sym *symbols.Symbol) (string, bool) { return b.String(), false } -func expandedDefaultBindingResolver(module *project.Module) place.BindingResolver { - return func(ident *ast.Ident) (place.Binding, bool) { - if module == nil || module.Semantics == nil || ident == nil { - return place.Binding{}, false - } - if _, ok := module.Semantics.ExpandedDefaultBindings[ident.ID()]; !ok { - return place.Binding{}, false - } - return place.Binding{Symbol: module.Semantics.ResolvedSymbols[ident.ID()]}, true - } -} - func shouldDiscardBindingValue(sym *symbols.Symbol) bool { if sym == nil || sym.Used { return false } - if typ, ok := symbols.GetSymbolType(sym); ok && typeinfo.NeedsDrop(typ) { + if typ, ok := symbols.GetSymbolType(sym); ok && typeinfo.OwnershipCapabilityOf(typ).Drop { return false } switch node := sym.ASTNode.(type) { diff --git a/internal/ir/hir/lower/module_lower_test.go b/internal/ir/hir/lower/module_lower_test.go index 32ef8d12..8b190c92 100644 --- a/internal/ir/hir/lower/module_lower_test.go +++ b/internal/ir/hir/lower/module_lower_test.go @@ -12,12 +12,14 @@ import ( "compiler/internal/ir" "compiler/internal/ir/cfg" "compiler/internal/ir/hir" + "compiler/internal/moduleid" "compiler/internal/project" "compiler/internal/semantics/binder" "compiler/internal/semantics/collector" "compiler/internal/semantics/resolver" "compiler/internal/semantics/symbols" "compiler/internal/semantics/typechecker" + "compiler/internal/semantics/typecheckresult" "compiler/internal/semantics/typeinfo" "compiler/pkg/peeper" ) @@ -27,23 +29,25 @@ func generateTestHIR(t *testing.T, filePath, importPath, src string, beforeLower diag := diagnostics.NewDiagnosticBag() ctx := project.New(".", peeper.SourceExt, diag) module := &project.Module{ - Key: project.ModuleKeyFor(project.ModuleOriginLocal, filePath), - ImportPath: importPath, - FilePath: filePath, - IsEntry: true, - Content: src, - AST: parser.New(filePath, lexer.New(filePath, src, diag).Tokenize(), diag).ParseModule(), - Imports: make(map[string]project.ResolvedImport), + ID: moduleid.ID{ + Origin: string(project.ModuleOriginLocal), + ImportPath: importPath, + }, + FilePath: filePath, + IsEntry: true, + Content: src, + AST: parser.New(filePath, lexer.New(filePath, src, diag).Tokenize(), diag).ParseModule(), + Imports: make(map[string]project.ResolvedImport), } ctx.AddModule(module) collector.Collect(ctx, module) binder.Bind(ctx, module) resolver.Resolve(ctx, module) typechecker.Check(ctx, module) - module.TypedASTNodes = ast.Index(module.AST) + module.RebuildTypedASTIndex() module.CFG = cfg.BuildModule(module.AST, cfg.BuildQueries{ - MatchCases: module.Semantics.MatchCases, - LoopGuaranteedEntry: module.Semantics.ForLoopGuaranteedEntry, + MatchCases: module.Typechecking.MatchCases, + LoopGuaranteedEntry: module.Typechecking.ForLoopGuaranteedEntry, }) module.Flow = typechecker.CheckFlow(ctx, module) if diag.HasErrors() { @@ -138,7 +142,7 @@ func TestGenerateHIRLowersSequenceForIntoStructuredSegments(t *testing.T) { func TestGenerateHIRRejectsForInWithoutSemanticEvidence(t *testing.T) { out := generateTestHIR(t, "hir_for_evidence_test"+peeper.SourceExt, "hir_for_evidence_test", `fn main() { for value in 0..2 {} }`, func(module *project.Module) { - module.Semantics.ForIterations = make(map[ast.NodeID]project.ForIteration) + module.Typechecking.ForIterations = make(map[ast.NodeID]typecheckresult.ForIteration) }) invalid, ok := out.Funcs[0].Body.Stmts[0].(*hir.Invalid) if !ok || !strings.Contains(invalid.Message, "missing semantic evidence") { @@ -146,6 +150,22 @@ func TestGenerateHIRRejectsForInWithoutSemanticEvidence(t *testing.T) { } } +// A published record with no plan is the one malformed shape the type still +// admits: IterationPlan is closed, so a consumer's switch over the two plans is +// exhaustive, but a zero Plan is reachable if a producer ever publishes early. +func TestGenerateHIRRejectsForInWithoutAnIterationPlan(t *testing.T) { + out := generateTestHIR(t, "hir_for_plan_test"+peeper.SourceExt, "hir_for_plan_test", `fn main() { for value in 0..2 {} }`, func(module *project.Module) { + for id, evidence := range module.Typechecking.ForIterations { + evidence.Plan = nil + module.Typechecking.ForIterations[id] = evidence + } + }) + invalid, ok := out.Funcs[0].Body.Stmts[0].(*hir.Invalid) + if !ok || !strings.Contains(invalid.Message, "unknown for-in iteration evidence") { + t.Fatalf("for-in without an iteration plan = %#v", out.Funcs[0].Body.Stmts[0]) + } +} + func TestGenerateHIRCallableNamesAreStableAndModuleAware(t *testing.T) { const src = `struct Counter { value: i32 } fn Value() -> i32 { return 1; } @@ -171,9 +191,9 @@ fn (self: Counter) Read() -> i32 { return self.value; }` func TestCallableNameFramesModuleIdentityComponents(t *testing.T) { first := symbols.New("Value", symbols.SymbolFunc, nil, nil) - first.DefiningModule = symbols.DefiningModuleKey{Origin: "local", Namespace: "ab", Dependency: "c", ImportPath: "sample/value"} + first.DefiningModule = moduleid.ID{Origin: "local", Namespace: "ab", Dependency: "c", ImportPath: "sample/value"} second := symbols.New("Value", symbols.SymbolFunc, nil, nil) - second.DefiningModule = symbols.DefiningModuleKey{Origin: "local", Namespace: "a", Dependency: "bc", ImportPath: "sample/value"} + second.DefiningModule = moduleid.ID{Origin: "local", Namespace: "a", Dependency: "bc", ImportPath: "sample/value"} firstName, _ := callableName(nil, first) secondName, _ := callableName(nil, second) if firstName == secondName { @@ -363,7 +383,7 @@ fn read(value: ?i32, other: Holder) -> i32 { } func TestLoweredRuntimeTypeDoesNotInventUseSiteVariantIdentity(t *testing.T) { - consumer := &project.Module{Key: "local:consumer.peep", ModuleScope: symbols.NewScope(nil)} + consumer := &project.Module{ID: moduleid.ID{Origin: "local", ImportPath: "consumer.peep"}, ModuleScope: symbols.NewScope(nil)} typ := &typeinfo.DefinedType{ Name: "Status", Underlying: &typeinfo.EnumType{Cases: []typeinfo.VariantCase{{Name: "Ready"}}}, @@ -1097,7 +1117,7 @@ fn main() -> i32 { return reader.read(); }` out := generateTestHIR(t, "hir_interface_evidence_test"+peeper.SourceExt, "hir_interface_evidence_test", src, - func(module *project.Module) { module.Semantics.MethodSets = nil }) + func(module *project.Module) { module.Bindings.MethodsByReceiver = nil }) mainFn := out.Funcs[len(out.Funcs)-1] binding, ok := mainFn.Body.Stmts[1].(*hir.Binding) if !ok { @@ -1126,7 +1146,7 @@ fn main() -> i32 { return use(&counter); }` out := generateTestHIR(t, "hir_default_interface_evidence_test"+peeper.SourceExt, "hir_default_interface_evidence_test", src, - func(module *project.Module) { module.Semantics.MethodSets = nil }) + func(module *project.Module) { module.Bindings.MethodsByReceiver = nil }) mainFn := out.Funcs[len(out.Funcs)-1] ret := mainFn.Body.Stmts[1].(*hir.Return) call := ret.Value.(*ir.Call) @@ -1189,7 +1209,7 @@ fn (self: &Counter) Read() -> i32 { return self.value; } fn main() -> i32 { let counter: Counter = .{ value = 7 }; return counter.Read(); -}`, func(module *project.Module) { module.Semantics.MethodSets = nil }) +}`, func(module *project.Module) { module.Bindings.MethodsByReceiver = nil }) mainFn := out.Funcs[len(out.Funcs)-1] ret := mainFn.Body.Stmts[1].(*hir.Return) call, ok := ret.Value.(*ir.Call) @@ -1310,6 +1330,29 @@ fn Read(result: Result) -> i32 { } } +func TestGenerateHIRRejectsInvalidMatchProjectionEvidence(t *testing.T) { + out := generateTestHIR(t, "hir_invalid_match_projection_test"+peeper.SourceExt, "hir_invalid_match_projection_test", `enum Result { + Ok: { value: i32 }, +} +fn Read(result: Result) -> i32 { + match result { + Result::Ok with { value = payload } => { return payload; } + } +}`, func(module *project.Module) { + for _, match := range module.Typechecking.Matches { + match.Arms[0].Bindings[0].Projection = typecheckresult.MatchProjectionInvalid + return + } + }) + if out == nil || len(out.Funcs) != 1 || len(out.Funcs[0].Body.Stmts) != 1 { + t.Fatalf("unexpected HIR shape: %#v", out) + } + invalid, ok := out.Funcs[0].Body.Stmts[0].(*hir.Invalid) + if !ok || invalid.Message != "match binding has invalid projection" { + t.Fatalf("invalid match evidence lowered as %#v", out.Funcs[0].Body.Stmts[0]) + } +} + func TestGenerateHIRLowersDirectEnumPayload(t *testing.T) { out := generateTestHIR(t, "hir_enum_direct_payload_test"+peeper.SourceExt, "hir_enum_direct_payload_test", `enum Code { Value: i32, diff --git a/internal/ir/hir/lower/variant_rebind_test.go b/internal/ir/hir/lower/variant_rebind_test.go new file mode 100644 index 00000000..b7a28d7b --- /dev/null +++ b/internal/ir/hir/lower/variant_rebind_test.go @@ -0,0 +1,52 @@ +package lower + +import ( + "testing" + + "compiler/internal/ir" + "compiler/internal/ir/hir" + "compiler/pkg/peeper" +) + +func TestGenerateHIRLowersIndexAssignment(t *testing.T) { + out := generateTestHIR(t, "hir_index_assignment"+peeper.SourceExt, "hir_index_assignment", `fn main() { + let mut values = [2]i32{1, 2}; + values[0] = 7; +}`) + assign := out.Funcs[0].Body.Stmts[1].(*hir.Assign) + projections := assign.Target.Projections + if len(projections) != 1 || projections[0].Kind != ir.PlaceProjectionIndex { + t.Fatalf("index assignment projections = %#v, want index", projections) + } + if out.Types.Text(assign.Target.Type) != "i32" || assign.Value.TypeID() != assign.Target.Type { + t.Fatal("index assignment lost element type") + } +} + +func TestGenerateHIRLowersVariantReferenceFieldRebind(t *testing.T) { + out := generateTestHIR(t, "hir_variant_rebind_test"+peeper.SourceExt, "hir_variant_rebind_test", `enum Resource { Borrowed: { value: &i32 }, Empty } +fn Read(_: &i32) {} +fn probe(mut first: i32, mut second: i32) { + let mut resource = Resource::Borrowed with .{ value = &first }; + if resource is Resource::Borrowed { + resource.value = &second; + match resource { + Resource::Borrowed with { value = ref } => { Read(ref); } + Resource::Empty => {} + } + } +}`) + branch := out.Funcs[1].Body.Stmts[1].(*hir.If) + assign := branch.Then.Stmts[0].(*hir.Assign) + projections := assign.Target.Projections + if len(projections) != 2 { + t.Fatalf("rebind projections = %#v, want payload then field", projections) + } + if projections[0].Kind != ir.PlaceProjectionVariantPayload || projections[0].Case != 0 || + projections[1].Kind != ir.PlaceProjectionField || projections[1].FieldIndex != 0 { + t.Fatalf("rebind projections = %#v, want Borrowed payload then value field", projections) + } + if out.Types.Text(assign.Target.Type) != "&i32" || assign.Value.TypeID() != assign.Target.Type { + t.Fatalf("rebind types = %s <- %s, want &i32", out.Types.Text(assign.Target.Type), out.Types.Text(assign.Value.TypeID())) + } +} diff --git a/internal/ir/hir/model.go b/internal/ir/hir/model.go index 24c668e7..0a3f2a8e 100644 --- a/internal/ir/hir/model.go +++ b/internal/ir/hir/model.go @@ -86,11 +86,10 @@ type ExprStmt struct { } type Assign struct { - Target *ir.Place - Value ir.Expr - DropTarget bool - NodeID NodeID - Location *source.Location + Target *ir.Place + Value ir.Expr + NodeID NodeID + Location *source.Location } type Invalid struct { @@ -101,7 +100,6 @@ type Invalid struct { type Return struct { Value ir.Expr - Cleanup []ir.Expr NodeID NodeID Location *source.Location } @@ -300,9 +298,6 @@ func (s *ExprStmt) appendText(b *strings.Builder, indent int) { func (s *Assign) appendText(b *strings.Builder, indent int) { writeIndent(b, indent) - if s.DropTarget { - b.WriteString("drop-before ") - } b.WriteString(s.Target.String()) b.WriteString(" = ") b.WriteString(s.Value.String()) @@ -320,11 +315,6 @@ func (s *Invalid) appendText(b *strings.Builder, indent int) { } func (s *Return) appendText(b *strings.Builder, indent int) { - for _, cleanup := range s.Cleanup { - writeIndent(b, indent) - b.WriteString(cleanup.String()) - b.WriteString("\n") - } writeIndent(b, indent) b.WriteString("return") if s != nil && s.Value != nil { diff --git a/internal/ir/hir/validate.go b/internal/ir/hir/validate.go new file mode 100644 index 00000000..b89ba24e --- /dev/null +++ b/internal/ir/hir/validate.go @@ -0,0 +1,96 @@ +package hir + +import ( + "errors" + "fmt" + "sort" + "strings" + + "compiler/pkg/typednil" +) + +const maxReportedProblems = 10 + +// Validate checks the shape of a lowered function body: that a function has one, +// that no statement slot is empty, and that a construct carrying a body actually +// has it. +// +// It does not re-derive meaning. Whether the right statement was lowered for a +// construct is lowering's decision; that every statement kind is handled at all +// is held by the dispatch contract in internal/contracts. What is left is the +// shape, which nothing else checks and which the backend otherwise discovers by +// dereferencing a nil. +// +// A failure is a compiler bug, not a source error. +func (m *Module) Validate() error { + if m == nil || len(m.Funcs) == 0 { + return nil + } + problems := make([]string, 0) + for _, fn := range m.Funcs { + if fn == nil { + problems = append(problems, "module holds a nil function") + continue + } + if fn.Body == nil { + problems = append(problems, fmt.Sprintf("function %s has no body", fn.Name)) + continue + } + problems = append(problems, validateStmt(fn.Name, fn.Body)...) + } + if len(problems) == 0 { + return nil + } + sort.Strings(problems) + if len(problems) > maxReportedProblems { + return fmt.Errorf("%s (%d more)", strings.Join(problems[:maxReportedProblems], "; "), len(problems)-maxReportedProblems) + } + return errors.New(strings.Join(problems, "; ")) +} + +// validateStmt checks what a child traversal cannot see: an empty slot is +// invisible to a walk that skips nils. It enumerates statement kinds with an +// explicit switch rather than forEachChild so nil slots and required bodies are +// reported with their role; a new statement kind with children must extend this +// switch to keep that reporting. +func validateStmt(fn string, stmt Stmt) []string { + problems := make([]string, 0) + if typednil.IsNil(stmt) { + return append(problems, fmt.Sprintf("function %s holds a nil statement", fn)) + } + switch node := stmt.(type) { + case *Block: + for index, child := range node.Stmts { + if typednil.IsNil(child) { + problems = append(problems, fmt.Sprintf("function %s holds a nil statement at block index %d", fn, index)) + continue + } + problems = append(problems, validateStmt(fn, child)...) + } + case *If: + problems = append(problems, validateBody(fn, "if", node.Then)...) + if !typednil.IsNil(node.Else) { + problems = append(problems, validateStmt(fn, node.Else)...) + } + case *For: + // Init, Bindings and Next are optional; a loop with no body is not. + problems = append(problems, validateBody(fn, "loop", node.Body)...) + for _, part := range []*Block{node.Init, node.Bindings, node.Next} { + if part != nil { + problems = append(problems, validateStmt(fn, part)...) + } + } + case *SwitchVariant: + for _, arm := range node.Cases { + problems = append(problems, validateBody(fn, fmt.Sprintf("case %d", arm.Case), arm.Body)...) + } + } + return problems +} + +func validateBody(fn, role string, body *Block) []string { + if body == nil { + return []string{fmt.Sprintf("function %s has a %s with no body", fn, role)} + } + return validateStmt(fn, body) +} diff --git a/internal/ir/hir/validate_test.go b/internal/ir/hir/validate_test.go new file mode 100644 index 00000000..ebfdae2b --- /dev/null +++ b/internal/ir/hir/validate_test.go @@ -0,0 +1,120 @@ +package hir + +import ( + "strings" + "testing" +) + +func wellFormedHIR() *Module { + return &Module{ + Name: "probe", + Funcs: []*Function{{ + Name: "choose", + Body: &Block{Stmts: []Stmt{ + &If{Then: &Block{}, Else: &Block{}}, + &For{Body: &Block{}}, + &SwitchVariant{Cases: []VariantCaseBlock{{Case: 0, Body: &Block{}}}}, + &Return{}, + }}, + }}, + } +} + +// Without a positive case, a negative one could pass against a fixture that was +// already broken. +func TestValidateAcceptsWellFormedModule(t *testing.T) { + if err := wellFormedHIR().Validate(); err != nil { + t.Fatalf("Validate() = %v, want nil for a well-formed module", err) + } +} + +// A typed nil wraps a nil pointer in a non-nil interface, so it must be treated +// like a plain nil optional slot rather than dereferenced as a real statement. +func TestValidateAcceptsTypedNilOptionalSlot(t *testing.T) { + module := wellFormedHIR() + module.Funcs[0].Body.Stmts[0].(*If).Else = (*Block)(nil) + if err := module.Validate(); err != nil { + t.Fatalf("Validate() = %v, want nil for a typed-nil optional else", err) + } +} + +func TestValidateReportsDefects(t *testing.T) { + tests := []struct { + name string + damage func(*Module) + want string + }{ + { + name: "function with no body", + damage: func(m *Module) { m.Funcs[0].Body = nil }, + want: "function choose has no body", + }, + { + name: "nil statement in a block", + damage: func(m *Module) { m.Funcs[0].Body.Stmts[3] = nil }, + want: "holds a nil statement at block index 3", + }, + { + name: "if with no then block", + damage: func(m *Module) { m.Funcs[0].Body.Stmts[0].(*If).Then = nil }, + want: "has a if with no body", + }, + { + name: "loop with no body", + damage: func(m *Module) { m.Funcs[0].Body.Stmts[1].(*For).Body = nil }, + want: "has a loop with no body", + }, + { + name: "case arm with no body", + damage: func(m *Module) { + m.Funcs[0].Body.Stmts[2].(*SwitchVariant).Cases[0].Body = nil + }, + want: "has a case 0 with no body", + }, + { + name: "typed-nil block statement in a block", + damage: func(m *Module) { m.Funcs[0].Body.Stmts[3] = (*Block)(nil) }, + want: "holds a nil statement at block index 3", + }, + { + name: "typed-nil return statement in a block", + damage: func(m *Module) { m.Funcs[0].Body.Stmts[3] = (*Return)(nil) }, + want: "holds a nil statement at block index 3", + }, + { + name: "nil function", + damage: func(m *Module) { m.Funcs = append(m.Funcs, nil) }, + want: "module holds a nil function", + }, + } + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + module := wellFormedHIR() + test.damage(module) + err := module.Validate() + if err == nil { + t.Fatalf("Validate() = nil, want a report containing %q", test.want) + } + if !strings.Contains(err.Error(), test.want) { + t.Fatalf("Validate() = %v, want a report containing %q", err, test.want) + } + }) + } +} + +// A nested body is reached through the same walk, so a defect inside one is +// reported rather than skipped. +func TestValidateDescendsIntoNestedBodies(t *testing.T) { + module := wellFormedHIR() + module.Funcs[0].Body.Stmts[1].(*For).Body.Stmts = []Stmt{&If{Then: nil}} + err := module.Validate() + if err == nil || !strings.Contains(err.Error(), "has a if with no body") { + t.Fatalf("Validate() = %v, want the nested defect reported", err) + } +} + +func TestValidateAcceptsEmptyModule(t *testing.T) { + if err := (*Module)(nil).Validate(); err != nil { + t.Fatalf("Validate() = %v, want nil for an empty artifact", err) + } +} diff --git a/internal/ir/mir/model.go b/internal/ir/mir/model.go index 1c645916..007a0c93 100644 --- a/internal/ir/mir/model.go +++ b/internal/ir/mir/model.go @@ -67,12 +67,20 @@ type Block struct { Term Terminator } +// Instr and Terminator are sealed by their marker methods. Without them the two +// interfaces are structurally identical, so a terminator satisfies Instr and an +// instruction satisfies Terminator, and lowering can put either in the other's +// position with nothing to catch it until the backend panics. The markers also +// keep the set closed to this package, so the backend's exhaustive switches +// cannot be outflanked by a node declared elsewhere. type Instr interface { + instrNode() Text() string SourceLocation() *source.Location } type Terminator interface { + termNode() Text() string SourceLocation() *source.Location } @@ -377,6 +385,19 @@ func (i *Ret) Text() string { return "ret " + i.Value.Text() } +func (*Assign) instrNode() {} +func (*Store) instrNode() {} +func (*Print) instrNode() {} +func (*Drop) instrNode() {} +func (*DynamicArrayOp) instrNode() {} +func (*Call) instrNode() {} +func (*InterfaceCall) instrNode() {} + +func (*Jump) termNode() {} +func (*Branch) termNode() {} +func (*SwitchVariant) termNode() {} +func (*Ret) termNode() {} + func (*Unary) valueExprNode() {} func (*Binary) valueExprNode() {} func (*StringConcat) valueExprNode() {} diff --git a/internal/ir/mir/model_membership_test.go b/internal/ir/mir/model_membership_test.go new file mode 100644 index 00000000..bbbdb6ee --- /dev/null +++ b/internal/ir/mir/model_membership_test.go @@ -0,0 +1,24 @@ +package mir + +// The two node sets, asserted at compile time. Instr and Terminator are +// structurally identical apart from their markers, so these declarations are +// what keep a node in the set its position requires: drop a marker, or move a +// node between the sets, and this file stops compiling. +// +// This is membership only. That every member is actually classified — by MIR +// lowering and by the backend — is a separate contract, held by +// contracts.TestEveryLoweredNodeKindHasAPhaseDecision. +var ( + _ Instr = (*Assign)(nil) + _ Instr = (*Store)(nil) + _ Instr = (*Print)(nil) + _ Instr = (*Drop)(nil) + _ Instr = (*DynamicArrayOp)(nil) + _ Instr = (*Call)(nil) + _ Instr = (*InterfaceCall)(nil) + + _ Terminator = (*Jump)(nil) + _ Terminator = (*Branch)(nil) + _ Terminator = (*SwitchVariant)(nil) + _ Terminator = (*Ret)(nil) +) diff --git a/internal/ir/mir/module_lower.go b/internal/ir/mir/module_lower.go index bfa2f6db..f594f488 100644 --- a/internal/ir/mir/module_lower.go +++ b/internal/ir/mir/module_lower.go @@ -424,11 +424,11 @@ func (l *lowerer) lowerCFGStmt(stmt hir.Stmt) bool { if target == nil || target.Root == nil { return false } - dropTarget := node.DropTarget + // The ownership cleanup plan is the only source of drop obligations over + // source values; lowering never decides one for itself. + dropTarget := false if l.cleanup != nil { - if _, planned := l.cleanup.BeforeAssign[node.NodeID]; planned { - dropTarget = true - } + _, dropTarget = l.cleanup.BeforeAssign[node.NodeID] } if ident, direct := target.Root.(*ir.Ident); direct && len(target.Projections) == 0 { if dropTarget { @@ -544,9 +544,6 @@ func (l *lowerer) lowerCFGTerminator(source, exit *cfg.Block, blocks map[*cfg.Bl temporaryMark := len(l.temporaryDrops) value := l.lowerExpr(ret.Value, &l.current.Instrs) l.flushTemporaryDrops(&l.current.Instrs, temporaryMark) - for _, cleanup := range ret.Cleanup { - l.lowerExpr(cleanup, &l.current.Instrs) - } if l.cleanup != nil { l.appendPlannedDrops(l.cleanup.BeforeReturn[term.NodeID], &l.current.Instrs) } diff --git a/internal/ir/mir/module_lower_test.go b/internal/ir/mir/module_lower_test.go index 0d34ebf0..5e0b5b71 100644 --- a/internal/ir/mir/module_lower_test.go +++ b/internal/ir/mir/module_lower_test.go @@ -243,29 +243,57 @@ func TestGenerateMIRDoesNotAssignUninitializedBinding(t *testing.T) { } } -func TestGenerateMIRLowersReturnCleanupBeforeTerminator(t *testing.T) { +// A return computes its value before unwinding the scopes it exits, which is +// why return cleanup is planned separately from CFG scope-exit sites: a +// scope-exit site emits its drops before the terminator runs, so a returned +// value read from a dropped local would be freed first. +func TestGenerateMIRComputesReturnValueBeforePlannedCleanup(t *testing.T) { mod := &hir.Module{ Name: "test", Types: mirTypes.table, Funcs: []*hir.Function{{ Name: "release", + Params: []ir.Param{{Name: "owner", Type: mirTypes.ownedI32, SymbolID: 1}}, ReturnType: mirTypes.i32, Body: &hir.Block{Stmts: []hir.Stmt{&hir.Return{ - Value: &ir.IntLit{Value: "7", Type: mirTypes.i32}, - Cleanup: []ir.Expr{&ir.Drop{Value: &ir.Ident{Name: "owner", Type: mirTypes.ownedI32}}}, + NodeID: 20, + Value: &ir.Binary{ + Op: "+", + Left: &ir.Ident{Name: "left", Type: mirTypes.i32}, + Right: &ir.IntLit{Value: "7", Type: mirTypes.i32}, + Type: mirTypes.i32, + }, }}}, }}, } - out := GenerateMIR(mod, cfgForHIR(mod), nil, nil, nil) + // CFG construction assigns function identity, so the plan is keyed after it. + graphs := cfgForHIR(mod) + plans := ownershipresult.Result{mod.Funcs[0].NodeID: &ownershipresult.CleanupPlan{ + BeforeReturn: map[ir.NodeID][]symbols.SymbolID{20: {1}}, + }} + + out := GenerateMIR(mod, graphs, plans, nil, nil) block := out.Funcs[0].Blocks[0] - if len(block.Instrs) != 1 { - t.Fatalf("expected one cleanup instruction, got %#v", block.Instrs) + value, drop := -1, -1 + for index, instr := range block.Instrs { + switch instr.(type) { + case *Assign: + if value < 0 { + value = index + } + case *Drop: + if drop < 0 { + drop = index + } + } + } + if value < 0 || drop < 0 { + t.Fatalf("instructions = %#v, want the return value and its planned drop", block.Instrs) } - if _, ok := block.Instrs[0].(*Drop); !ok { - t.Fatalf("expected MIR drop, got %#v", block.Instrs[0]) + if value > drop { + t.Fatalf("planned drop at %d precedes return value at %d: %#v", drop, value, block.Instrs) } - ret, ok := block.Term.(*Ret) - if !ok || ret.Value.Text() != "7" { - t.Fatalf("expected preserved return value, got %#v", block.Term) + if _, ok := block.Term.(*Ret); !ok { + t.Fatalf("terminator = %#v, want return", block.Term) } } diff --git a/internal/ir/mir/validate.go b/internal/ir/mir/validate.go new file mode 100644 index 00000000..329738a2 --- /dev/null +++ b/internal/ir/mir/validate.go @@ -0,0 +1,119 @@ +package mir + +import ( + "errors" + "fmt" + "sort" + "strings" + + "compiler/pkg/typednil" +) + +const maxReportedProblems = 10 + +// Validate checks the shape of a lowered module: that every block ends, that +// every transfer names a block that exists, and that identity is unambiguous. +// +// It deliberately does not re-derive meaning. Whether the right instruction was +// emitted for a construct is lowering's decision, and re-deciding it here would +// be a second implementation of the thing being validated. That a node kind is +// classified at all is held by the dispatch contract in internal/contracts, not +// here. +// +// A failure is a compiler bug rather than a source error: MIR is built from +// evidence that earlier phases already accepted. +func (m *Module) Validate() error { + if m == nil || len(m.Funcs) == 0 { + return nil + } + problems := make([]string, 0) + for _, fn := range m.Funcs { + if fn == nil { + problems = append(problems, "module holds a nil function") + continue + } + problems = append(problems, validateFunction(fn)...) + } + if len(problems) == 0 { + return nil + } + // Function and block order is stable, but a report that grows from map + // iteration elsewhere would not be; sorting keeps a broken artifact + // reproducible between runs. + sort.Strings(problems) + if len(problems) > maxReportedProblems { + return fmt.Errorf("%s (%d more)", strings.Join(problems[:maxReportedProblems], "; "), len(problems)-maxReportedProblems) + } + return errors.New(strings.Join(problems, "; ")) +} + +func validateFunction(fn *Function) []string { + problems := make([]string, 0) + blocks := make(map[int]bool, len(fn.Blocks)) + for _, block := range fn.Blocks { + if block == nil { + problems = append(problems, fmt.Sprintf("function %s holds a nil block", fn.Name)) + continue + } + if blocks[block.ID] { + problems = append(problems, fmt.Sprintf("function %s declares block b%d twice", fn.Name, block.ID)) + continue + } + blocks[block.ID] = true + } + // Identity has to hold before transfers can be checked against it. + if len(problems) > 0 { + return problems + } + if len(fn.Blocks) > 0 && !blocks[fn.EntryID] { + problems = append(problems, fmt.Sprintf("function %s enters at b%d, which it does not contain", fn.Name, fn.EntryID)) + } + for _, block := range fn.Blocks { + for index, instr := range block.Instrs { + if typednil.IsNil(instr) { + problems = append(problems, fmt.Sprintf("function %s block b%d holds a nil instruction at %d", fn.Name, block.ID, index)) + } + } + if typednil.IsNil(block.Term) { + // Emission would otherwise fall off the end of a block. + problems = append(problems, fmt.Sprintf("function %s block b%d has no terminator", fn.Name, block.ID)) + continue + } + problems = append(problems, validateTransfers(fn, block, blocks)...) + } + return problems +} + +// validateTransfers checks that every block a terminator names exists. A +// transfer to a block that was never emitted becomes a label the backend cannot +// resolve. +func validateTransfers(fn *Function, block *Block, blocks map[int]bool) []string { + problems := make([]string, 0) + report := func(target int, role string) { + if !blocks[target] { + problems = append(problems, fmt.Sprintf("function %s block b%d transfers to b%d as its %s, which it does not contain", + fn.Name, block.ID, target, role)) + } + } + switch term := block.Term.(type) { + case *Jump: + report(term.TargetID, "jump target") + case *Branch: + report(term.ThenID, "true target") + report(term.ElseID, "false target") + case *SwitchVariant: + seen := make(map[int]bool, len(term.Targets)) + for _, target := range term.Targets { + report(target.TargetID, fmt.Sprintf("case %d target", target.Case)) + if seen[target.Case] { + problems = append(problems, fmt.Sprintf("function %s block b%d selects case %d twice", fn.Name, block.ID, target.Case)) + } + seen[target.Case] = true + } + case *Ret: + // A return leaves the function and names no block. + default: + problems = append(problems, fmt.Sprintf("function %s block b%d ends with unknown terminator %T", fn.Name, block.ID, block.Term)) + } + return problems +} diff --git a/internal/ir/mir/validate_test.go b/internal/ir/mir/validate_test.go new file mode 100644 index 00000000..85826da5 --- /dev/null +++ b/internal/ir/mir/validate_test.go @@ -0,0 +1,126 @@ +package mir + +import ( + "strings" + "testing" +) + +// wellFormed is one function with a branch and a join, the smallest shape that +// exercises every kind of transfer check. +func wellFormed() *Module { + return &Module{ + Name: "probe", + Funcs: []*Function{{ + Name: "choose", + EntryID: 0, + Blocks: []*Block{ + {ID: 0, Term: &Branch{ThenID: 1, ElseID: 2}}, + {ID: 1, Term: &Jump{TargetID: 3}}, + {ID: 2, Term: &Jump{TargetID: 3}}, + {ID: 3, Term: &Ret{}}, + }, + }}, + } +} + +// The positive case has to exist, or every negative case below could pass +// against a fixture that was already broken. +func TestValidateAcceptsWellFormedModule(t *testing.T) { + if err := wellFormed().Validate(); err != nil { + t.Fatalf("Validate() = %v, want nil for a well-formed module", err) + } +} + +func TestValidateReportsDefects(t *testing.T) { + tests := []struct { + name string + damage func(*Module) + want string + }{ + { + name: "block without a terminator", + damage: func(m *Module) { m.Funcs[0].Blocks[1].Term = nil }, + want: "block b1 has no terminator", + }, + { + name: "jump to a block that does not exist", + damage: func(m *Module) { m.Funcs[0].Blocks[1].Term = &Jump{TargetID: 99} }, + want: "transfers to b99 as its jump target", + }, + { + name: "branch with a missing false target", + damage: func(m *Module) { m.Funcs[0].Blocks[0].Term = &Branch{ThenID: 1, ElseID: 42} }, + want: "transfers to b42 as its false target", + }, + { + name: "duplicate block identity", + damage: func(m *Module) { m.Funcs[0].Blocks[2].ID = 1 }, + want: "declares block b1 twice", + }, + { + name: "entry naming a block the function does not contain", + damage: func(m *Module) { m.Funcs[0].EntryID = 7 }, + want: "enters at b7, which it does not contain", + }, + { + name: "nil instruction", + damage: func(m *Module) { m.Funcs[0].Blocks[0].Instrs = []Instr{nil} }, + want: "holds a nil instruction at 0", + }, + { + name: "typed-nil terminator", + damage: func(m *Module) { m.Funcs[0].Blocks[1].Term = (*Jump)(nil) }, + want: "block b1 has no terminator", + }, + { + name: "typed-nil instruction", + damage: func(m *Module) { m.Funcs[0].Blocks[0].Instrs = []Instr{(*Store)(nil)} }, + want: "holds a nil instruction at 0", + }, + { + name: "one case selected twice", + damage: func(m *Module) { + m.Funcs[0].Blocks[0].Term = &SwitchVariant{Targets: []VariantTarget{ + {Case: 0, TargetID: 1}, {Case: 0, TargetID: 2}, + }} + }, + want: "selects case 0 twice", + }, + } + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + module := wellFormed() + test.damage(module) + err := module.Validate() + if err == nil { + t.Fatalf("Validate() = nil, want a report containing %q", test.want) + } + if !strings.Contains(err.Error(), test.want) { + t.Fatalf("Validate() = %v, want a report containing %q", err, test.want) + } + }) + } +} + +// An unsorted report differs between runs of the same broken artifact, which is +// useless to whoever has to fix it. +func TestValidateReportsDefectsDeterministically(t *testing.T) { + module := wellFormed() + module.Funcs[0].Blocks[1].Term = nil + module.Funcs[0].Blocks[2].Term = nil + first := module.Validate() + for range 8 { + if got := module.Validate(); got.Error() != first.Error() { + t.Fatalf("Validate() = %v, want the stable report %v", got, first) + } + } +} + +func TestValidateAcceptsEmptyModule(t *testing.T) { + if err := (*Module)(nil).Validate(); err != nil { + t.Fatalf("Validate() = %v, want nil for an empty artifact", err) + } + if err := (&Module{}).Validate(); err != nil { + t.Fatalf("Validate() = %v, want nil for a module with no functions", err) + } +} diff --git a/internal/ir/nodes.go b/internal/ir/nodes.go index dcb4b3a0..51ccfbd1 100644 --- a/internal/ir/nodes.go +++ b/internal/ir/nodes.go @@ -2,12 +2,12 @@ package ir import ( "fmt" - "reflect" "strings" "compiler/internal/semantics/symbols" "compiler/internal/source" "compiler/pkg/ascii" + "compiler/pkg/typednil" ) // NodeID identifies source syntax without retaining an AST object in IR. @@ -433,11 +433,7 @@ func (p *Place) forEachChild(visit func(Expr)) { // WithOrigin applies provenance at compiler phase boundaries, including // synthetic expressions returned by helper lowerers. func WithOrigin(expr Expr, info SourceInfo) Expr { - if expr == nil { - return nil - } - value := reflect.ValueOf(expr) - if value.Kind() == reflect.Pointer && value.IsNil() { + if typednil.IsNil(expr) { return expr } expr.setOrigin(info) diff --git a/internal/lsp/completion.go b/internal/lsp/completion.go index 2a7db7b9..6f572187 100644 --- a/internal/lsp/completion.go +++ b/internal/lsp/completion.go @@ -88,7 +88,7 @@ func (s *ServerState) HandleCompletion(params CompletionParams) ([]CompletionIte return []CompletionItem{}, nil } ctx, module := s.currentCompiledModule(filePath) - if ctx == nil || module == nil || module.Semantics == nil { + if ctx == nil || module == nil { return []CompletionItem{}, nil } parsed := parseCompletionContext(sourceText, params.Position) @@ -104,7 +104,7 @@ func (s *ServerState) HandleCompletion(params CompletionParams) ([]CompletionIte return qualifiedCompletionItems(ctx, module, parsed.qualifier, parsed.prefix, replacement), nil case completionOperation: sentinelCtx, sentinelModule := compileCompletionSource(ctx.Config, s.completionOverlays(filePath), filePath, parsed.sentinel) - if sentinelCtx == nil || sentinelModule == nil || sentinelModule.Semantics == nil { + if sentinelCtx == nil || sentinelModule == nil || sentinelModule.Bindings == nil { return []CompletionItem{}, nil } rewrite := Range{Start: positionAtOffset(sourceText, parsed.rewriteStart), End: replacement.End} @@ -379,7 +379,7 @@ func isCompletionBoundary(ch byte) bool { } func lexicalCompletionItems(module *project.Module, cursor source.Position, prefix string, replacement Range) []CompletionItem { - if module == nil || module.ModuleScope == nil || module.Semantics == nil { + if module == nil || module.ModuleScope == nil || module.Bindings == nil { return []CompletionItem{} } scope := completionScope(module, cursor.Line, cursor.Column) @@ -414,7 +414,7 @@ func completionScope(module *project.Module, line, col int) *symbols.Scope { if !ok || !locContains(ast.LocOf(block), line, col) { return true } - if blockScope := module.Semantics.BlockScopes[block.ID()]; blockScope != nil { + if blockScope := module.Bindings.BlockScopes[block.ID()]; blockScope != nil { scope = blockScope } return true @@ -444,10 +444,10 @@ func qualifiedCompletionItems(ctx *project.CompilerContext, module *project.Modu return sortCompletionItems(items) } resolved, ok := module.Imports[qualifier] - if !ok || resolved.DependencyAlias != "" { + if !ok { return []CompletionItem{} } - imported, ok := ctx.ModuleByKey(resolved.Key) + imported, ok := ctx.ModuleByID(resolved.ID) if !ok || imported == nil || imported.ModuleScope == nil { return []CompletionItem{} } @@ -528,7 +528,7 @@ func completionQualifierSegments(qualifier string) []string { } func matchArmCompletionItems(ctx *project.CompilerContext, module *project.Module, cursor source.Position, replacement Range) ([]CompletionItem, bool) { - if module == nil || module.Semantics == nil { + if module == nil || module.Typechecking == nil { return nil, false } var match *ast.MatchStmt @@ -561,7 +561,7 @@ func matchArmCompletionItems(ctx *project.CompilerContext, module *project.Modul if owner != module { aliases := make([]string, 0) for alias, imported := range module.Imports { - if imported.Key == owner.Key { + if imported.ID == owner.ID { aliases = append(aliases, alias) } } @@ -686,7 +686,7 @@ func operationCompletionItems(ctx *project.CompilerContext, module *project.Modu } } for _, key := range typeinfo.GetMethodLookupKeys(baseType) { - for _, method := range module.Semantics.MethodSets[key] { + for _, method := range module.Bindings.MethodsByReceiver[key] { if method == nil { continue } @@ -703,7 +703,7 @@ func operationCompletionItems(ctx *project.CompilerContext, module *project.Modu items = appendOperationCompletion(items, seen, function, function.Name, fnType, replacement, rewrite, pipe, preserveArguments) } } - for _, function := range operationFunctionsWithPrefix(module.Semantics.OperationFunctions, prefix) { + for _, function := range operationFunctionsWithPrefix(module.Bindings.OperationFunctions, prefix) { fnType, callable := function.Type.(*typeinfo.FuncType) if !callable { continue @@ -713,14 +713,11 @@ func operationCompletionItems(ctx *project.CompilerContext, module *project.Modu } } for alias, resolved := range module.Imports { - if resolved.DependencyAlias != "" { - continue - } - imported, found := ctx.ModuleByKey(resolved.Key) - if !found || imported == nil || imported.Semantics == nil { + imported, found := ctx.ModuleByID(resolved.ID) + if !found || imported == nil || imported.Bindings == nil { continue } - for _, function := range operationFunctionsWithPrefix(imported.Semantics.OperationFunctions, prefix) { + for _, function := range operationFunctionsWithPrefix(imported.Bindings.OperationFunctions, prefix) { fnType, callable := function.Type.(*typeinfo.FuncType) if !function.IsPub || !callable { continue diff --git a/internal/lsp/cursor.go b/internal/lsp/cursor.go index bfcd46e8..0e7cea78 100644 --- a/internal/lsp/cursor.go +++ b/internal/lsp/cursor.go @@ -88,18 +88,20 @@ func buildCursorContext(ctx *project.CompilerContext, module *project.Module, po } func resolveIdentSymbol(ident *ast.Ident, parents map[ast.NodeID]ast.Node, module *project.Module, ctx *project.CompilerContext) *symbols.Symbol { - if ident == nil { + if ident == nil || module == nil { return nil } - if module != nil && module.Semantics != nil { - if sym := module.Semantics.ResolvedSymbols[ident.ID()]; sym != nil { + if module.Bindings != nil { + if sym := module.Bindings.NodeSymbols[ident.ID()]; sym != nil { return sym } } parent := parents[ident.ID()] if parent == nil { - if sym, ok := module.ModuleScope.Lookup(ident.Name); ok { - return sym + if module.ModuleScope != nil { + if sym, ok := module.ModuleScope.Lookup(ident.Name); ok { + return sym + } } return nil } @@ -118,7 +120,7 @@ func resolveIdentSymbol(ident *ast.Ident, parents map[ast.NodeID]ast.Node, modul if imported && memberNode == ident { qualifier := qualifierNode.Name if imp, ok := module.Imports[qualifier]; ok { - if mod, ok := ctx.ModuleByKey(imp.Key); ok && mod.ModuleScope != nil { + if mod, ok := ctx.ModuleByID(imp.ID); ok && mod.ModuleScope != nil { if sym, ok := mod.ModuleScope.LookupLocal(ident.Name); ok { return sym } @@ -145,8 +147,8 @@ func resolveIdentSymbol(ident *ast.Ident, parents map[ast.NodeID]ast.Node, modul var scope *symbols.Scope curr := parent for curr != nil { - if block, ok := curr.(*ast.BlockStmt); ok { - if s, ok := module.Semantics.BlockScopes[block.ID()]; ok && s != nil { + if block, ok := curr.(*ast.BlockStmt); ok && module.Bindings != nil { + if s, ok := module.Bindings.BlockScopes[block.ID()]; ok && s != nil { scope = s break } @@ -163,7 +165,7 @@ func resolveIdentSymbol(ident *ast.Ident, parents map[ast.NodeID]ast.Node, modul } curr = parents[curr.ID()] } - if containingFn != nil { + if containingFn != nil && module.ModuleScope != nil { if sym, ok := module.ModuleScope.Lookup(containingFn.Name.Name); ok && sym != nil && sym.Scope != nil { scope = sym.Scope } @@ -181,7 +183,7 @@ func resolveIdentSymbol(ident *ast.Ident, parents map[ast.NodeID]ast.Node, modul } func resolveSelectorMemberSymbol(sel *ast.SelectorExpr, ident *ast.Ident, parents map[ast.NodeID]ast.Node, module *project.Module, ctx *project.CompilerContext) *symbols.Symbol { - if sel == nil || ident == nil || module == nil || ctx == nil || module.Semantics == nil { + if sel == nil || ident == nil || module == nil || ctx == nil { return nil } baseType, ok := selectorBaseType(sel.Expr, parents, module, ctx) @@ -191,8 +193,11 @@ func resolveSelectorMemberSymbol(sel *ast.SelectorExpr, ident *ast.Ident, parent if fieldSym := lookupStructFieldSymbol(baseType, ident.Name, ctx); fieldSym != nil { return fieldSym } + if module.Bindings == nil { + return nil + } for _, key := range typeinfo.GetMethodLookupKeys(baseType) { - if methods, ok := module.Semantics.MethodSets[key]; ok { + if methods, ok := module.Bindings.MethodsByReceiver[key]; ok { for _, method := range methods { if method != nil && method.Name == ident.Name { return method @@ -204,7 +209,7 @@ func resolveSelectorMemberSymbol(sel *ast.SelectorExpr, ident *ast.Ident, parent } func selectorBaseType(expr ast.Expr, parents map[ast.NodeID]ast.Node, module *project.Module, ctx *project.CompilerContext) (typeinfo.Type, bool) { - if expr == nil || module == nil || module.Semantics == nil { + if expr == nil || module == nil { return nil, false } baseType, ok := normalizedSelectorBaseType(module.EffectiveExprType(expr.ID())) diff --git a/internal/lsp/hover.go b/internal/lsp/hover.go index b0c25b13..ea74e804 100644 --- a/internal/lsp/hover.go +++ b/internal/lsp/hover.go @@ -419,12 +419,12 @@ func documentedDeclAncestor(node ast.Node, parents map[ast.NodeID]ast.Node) ast. } func resolveDeclNameSymbol(ident *ast.Ident, parents map[ast.NodeID]ast.Node, module *project.Module) *symbols.Symbol { - if ident == nil || module == nil || module.Semantics == nil { + if ident == nil || module == nil || module.Bindings == nil { return nil } parent := parents[ident.ID()] if fn, ok := parent.(*ast.FnDecl); ok && fn != nil && fn.Name == ident && fn.Receiver != nil { - if sym, ok := module.Semantics.MethodSymbol[fn.ID()]; ok && sym != nil { + if sym, ok := module.Bindings.MethodsByDecl[fn.ID()]; ok && sym != nil { return sym } } @@ -479,11 +479,11 @@ func lookupMethodSet(ctx *project.CompilerContext, typ typeinfo.Type, keys []str seen := make(map[string]struct{}) var methods []*symbols.Symbol for _, module := range ctx.Modules() { - if module == nil || module.Semantics == nil { + if module == nil || module.Bindings == nil { continue } for key := range keySet { - for _, sym := range module.Semantics.MethodSets[key] { + for _, sym := range module.Bindings.MethodsByReceiver[key] { if sym == nil { continue } @@ -506,7 +506,7 @@ func lookupMethodSet(ctx *project.CompilerContext, typ typeinfo.Type, keys []str } func resolveExprHoverSubject(cc *cursorContext) *hoverSubject { - if cc == nil || cc.node == nil || cc.module == nil || cc.module.Semantics == nil { + if cc == nil || cc.node == nil || cc.module == nil || cc.module.Typechecking == nil { return nil } if _, ok := cc.node.(ast.Expr); !ok { @@ -559,12 +559,12 @@ func renderHoverSubject(subject *hoverSubject) string { if subject.ResolvedImport == nil { return "" } - name := subject.ResolvedImport.ImportPath + name := subject.ResolvedImport.ID.ImportPath if ident, ok := subject.Node.(*ast.Ident); ok && ident != nil && ident.Name != "" { name = ident.Name } importSymbol := &symbols.Symbol{Name: name, Kind: symbols.SymbolImport} - text = renderSymbol(importSymbol, symbolRenderContext{ImportPath: subject.ResolvedImport.ImportPath}) + text = renderSymbol(importSymbol, symbolRenderContext{ImportPath: subject.ResolvedImport.ID.ImportPath}) case hoverSubjectAttribute: if subject.Attribute == nil { return "" diff --git a/internal/lsp/server_test.go b/internal/lsp/server_test.go index 78e2fe0a..ac31e2cf 100644 --- a/internal/lsp/server_test.go +++ b/internal/lsp/server_test.go @@ -15,6 +15,7 @@ import ( "compiler/internal/diagnostics" "compiler/internal/driver" + "compiler/internal/prelude" "compiler/internal/project" "compiler/internal/semantics/symbols" "compiler/internal/semantics/typeinfo" @@ -348,17 +349,8 @@ func TestParseBundledPreludeFileKeepsStdlibIdentity(t *testing.T) { if mod == nil { t.Fatalf("expected compiled bundled library module") } - if mod.Origin != project.ModuleOriginStdlib { - t.Fatalf("origin = %q, want %q", mod.Origin, project.ModuleOriginStdlib) - } - if mod.Namespace != "core" { - t.Fatalf("namespace = %q, want %q", mod.Namespace, "core") - } - if mod.Key != "core:prelude/global" { - t.Fatalf("key = %q, want %q", mod.Key, "core:prelude/global") - } - if mod.ImportPath != "prelude/global" { - t.Fatalf("import path = %q, want %q", mod.ImportPath, "prelude/global") + if mod.ID != prelude.ModuleID(ctx) { + t.Fatalf("module ID = %#v, want canonical prelude ID %#v", mod.ID, prelude.ModuleID(ctx)) } } diff --git a/internal/lsp/state.go b/internal/lsp/state.go index e7908e80..edac1dd2 100644 --- a/internal/lsp/state.go +++ b/internal/lsp/state.go @@ -246,7 +246,9 @@ func (s *ServerState) scheduleDiagnosticRefresh(filePath string, delay time.Dura version := s.diagVersion[filePath] s.mu.Unlock() - s.diagWG.Go(func() { + s.diagWG.Add(1) + go func() { + defer s.diagWG.Done() // Full-sync edits arrive as whole-file snapshots. Delay diagnostics so a // burst of keystrokes collapses into one recompile instead of one per edit. time.Sleep(delay) @@ -263,7 +265,7 @@ func (s *ServerState) scheduleDiagnosticRefresh(filePath string, delay time.Dura } s.mu.Unlock() } - }) + }() } func (s *ServerState) waitForScheduledDiagnostics() error { @@ -282,7 +284,7 @@ func (s *ServerState) seedReusableModules(ctx *project.CompilerContext, dirtyFil } for _, module := range s.modules { if module != nil { - ctx.SetSemanticExportBaseline(module.Key, module.SemanticExportFingerprint) + ctx.SetSemanticExportBaseline(module.ID, module.SemanticExportFingerprint) } } reusePhases := map[string]phase.Phase{} @@ -322,10 +324,10 @@ func (s *ServerState) seedReusableModules(ctx *project.CompilerContext, dirtyFil // Cached artifacts may be ahead of this run's project barrier. Keep their // later diagnostics inactive so failures can retain them for a future run // without publishing them in the current one. - ctx.Diagnostics.CopyModuleRange(previousDiagnostics, reused.Key, phase.None, min(retainedPhase, phase.Ownership), true) + ctx.Diagnostics.CopyModuleRange(previousDiagnostics, reused.ID.String(), phase.None, min(retainedPhase, phase.Ownership), true) if retainedPhase > phase.Ownership { - ctx.Diagnostics.CopyModuleRange(previousDiagnostics, reused.Key, phase.Usage, retainedPhase, false) - deferredDiagnostics[reused.Key] = retainedPhase + ctx.Diagnostics.CopyModuleRange(previousDiagnostics, reused.ID.String(), phase.Usage, retainedPhase, false) + deferredDiagnostics[reused.ID.String()] = retainedPhase } } return deferredDiagnostics @@ -335,8 +337,8 @@ func activateReusableDiagnostics(ctx *project.CompilerContext, retainedPhases ma if ctx == nil || ctx.Diagnostics == nil || ctx.CompletedProjectPhase < phase.Usage { return } - for moduleKey, retainedPhase := range retainedPhases { - ctx.Diagnostics.ActivateModuleRange(moduleKey, phase.Usage, retainedPhase) + for moduleScope, retainedPhase := range retainedPhases { + ctx.Diagnostics.ActivateModuleRange(moduleScope, phase.Usage, retainedPhase) } } @@ -369,8 +371,8 @@ func (s *ServerState) captureModules(ctx *project.CompilerContext) { } } if existing := s.modules[module.FilePath]; existing != nil && - existing.Origin == project.ModuleOriginStdlib && - module.Origin == project.ModuleOriginLocal { + existing.ID.Origin == string(project.ModuleOriginStdlib) && + module.ID.Origin == string(project.ModuleOriginLocal) { continue } s.modules[module.FilePath] = module diff --git a/internal/lsp/workspace.go b/internal/lsp/workspace.go index 0dc9ce4b..70f93ac6 100644 --- a/internal/lsp/workspace.go +++ b/internal/lsp/workspace.go @@ -150,7 +150,7 @@ func (w *workspaceIndex) rebuild(cache map[string]string) error { continue } resolved, err := ctx.ResolveImportPath(rawPath) - if err != nil || resolved == nil || resolved.Origin != project.ModuleOriginLocal { + if err != nil || resolved == nil || resolved.ID.Origin != string(project.ModuleOriginLocal) { continue } target := resolved.FilePath @@ -212,7 +212,7 @@ func (w *workspaceIndex) syntheticEntry(filePath string) (string, string, bool) } builder.WriteString("fn WorkspaceEntry() {}\n") - virtualPath := filepath.Join(w.rootDir, ".peeper-lsp", "__workspace__"+peeper.SourceExt) + virtualPath := filepath.Join(manifest.SourceDir(w.rootDir), ".peeper-lsp", "__workspace__"+peeper.SourceExt) return virtualPath, builder.String(), true } diff --git a/internal/lsp/workspace_test.go b/internal/lsp/workspace_test.go index 0ba66153..a4229478 100644 --- a/internal/lsp/workspace_test.go +++ b/internal/lsp/workspace_test.go @@ -562,6 +562,85 @@ func TestWorkspaceReusePhasesDowngradesDependentToParsed(t *testing.T) { } } +func TestServerStateParsedResetRebuildsImportedDefaultProvenance(t *testing.T) { + root := t.TempDir() + writeWorkspaceProjectConfig(t, root, "app") + fileMain := filepath.Join(root, peeper.SourceDirName, peeper.MainFileName) + fileExternal := filepath.Join(root, peeper.SourceDirName, "external"+peeper.SourceExt) + writeWorkspaceFile(t, fileMain, `import "app/external"; + +fn main() -> i32 { + return external::Read(); +} +`) + const external = `const value: i32 = 7; + +fn Read(input: i32 = value) -> i32 { + return input; +} +` + writeWorkspaceFile(t, fileExternal, external) + + state := NewServerState() + state.RootDir = root + ctx, mod := state.recompile(fileMain) + if ctx == nil || mod == nil { + t.Fatal("initial compile returned nil context or module") + } + if ctx.Diagnostics.HasErrors() { + t.Fatalf("initial compile diagnostics:\n%s", ctx.Diagnostics.EmitAllToString()) + } + + updated := external + "\nfn Added() {}\n" + state.applyDocumentSnapshot(fileExternal, &updated, nil) + ctx, mod = state.recompile(fileExternal) + if ctx == nil || mod == nil { + t.Fatal("incremental compile returned nil context or module") + } + if got := state.LastMetrics.ModulesDowngraded; got != 1 { + t.Fatalf("modules downgraded = %d, want 1 dependent reset to Parsed", got) + } + if ctx.Diagnostics.HasErrors() { + t.Fatalf("unexpected diagnostics after dependent Parsed reset:\n%s", ctx.Diagnostics.EmitAllToString()) + } + + mainModule := state.modules[project.CanonicalPath(fileMain)] + if mainModule == nil { + t.Fatal("missing recompiled dependent module") + } + if mainModule.Phase != phase.Backend { + t.Fatalf("dependent phase = %v, want %v", mainModule.Phase, phase.Backend) + } + var call *ast.CallExpr + for _, stmt := range mainModule.AST.Stmts { + ast.Inspect(stmt, func(node ast.Node) bool { + candidate, ok := node.(*ast.CallExpr) + if ok && ast.ExprText(candidate.Callee) == "external::Read" { + call = candidate + return false + } + return call == nil + }) + if call != nil { + break + } + } + if call == nil || len(call.Args) != 0 { + t.Fatalf("source call after reset = %#v, want zero source arguments", call) + } + effectiveArgs := mainModule.Typechecking.EffectiveCallArguments[call.ID()] + if len(effectiveArgs) != 1 { + t.Fatalf("effective arguments after reset = %#v, want one rebuilt default", effectiveArgs) + } + ident, ok := effectiveArgs[0].(*ast.Ident) + if !ok || mainModule.Bindings == nil || mainModule.Bindings.NodeSymbols[ident.ID()] == nil { + t.Fatalf("rebuilt default = %#v, want resolved imported identifier", effectiveArgs[0]) + } + if _, ok := mainModule.Typechecking.ExpandedDefaultBindings[ident.ID()]; !ok { + t.Fatalf("rebuilt default identifier %d missing declaration-binding provenance", ident.ID()) + } +} + func TestWorkspaceIndexRebuildParsesOnlyChangedFiles(t *testing.T) { root := t.TempDir() writeWorkspaceProjectConfig(t, root, "app") diff --git a/internal/moduleid/identity.go b/internal/moduleid/identity.go new file mode 100644 index 00000000..b92690d8 --- /dev/null +++ b/internal/moduleid/identity.go @@ -0,0 +1,38 @@ +// Package moduleid defines canonical compiler module identity. +package moduleid + +import ( + "encoding/hex" + "strconv" + "strings" +) + +// ID is stable across filesystem relocation and shared by imports, symbols, +// compiler registries, graph boundaries, and linkage naming. +type ID struct { + Origin string + Namespace string + Dependency string + ImportPath string +} + +func (id ID) Valid() bool { + return id.Origin != "" && id.ImportPath != "" +} + +// String returns collision-safe deterministic encoding for string-only boundaries. +func (id ID) String() string { + return Frame(id.Origin, id.Namespace, id.Dependency, id.ImportPath) +} + +// Frame encodes ordered identity and linkage components without delimiter collisions. +func Frame(components ...string) string { + var b strings.Builder + for _, component := range components { + b.WriteString(strconv.Itoa(len(component))) + b.WriteByte('_') + b.WriteString(hex.EncodeToString([]byte(component))) + b.WriteByte('_') + } + return b.String() +} diff --git a/internal/moduleid/identity_test.go b/internal/moduleid/identity_test.go new file mode 100644 index 00000000..f1801e3f --- /dev/null +++ b/internal/moduleid/identity_test.go @@ -0,0 +1,27 @@ +package moduleid + +import "testing" + +func TestIDStringFramesComponentsWithoutCollisions(t *testing.T) { + first := ID{Origin: "local", Namespace: "ab", Dependency: "c", ImportPath: "sample/value"} + second := ID{Origin: "local", Namespace: "a", Dependency: "bc", ImportPath: "sample/value"} + if first.String() == second.String() { + t.Fatalf("length-ambiguous module identities collide: %q", first.String()) + } +} + +func TestIDDependsOnLogicalIdentityOnly(t *testing.T) { + id := ID{Origin: "local", ImportPath: "app/math/counter"} + if id.String() == "" || !id.Valid() { + t.Fatalf("valid module identity = %#v", id) + } + if (ID{}).Valid() { + t.Fatal("zero module identity accepted as valid") + } + if (ID{Origin: "local"}).Valid() { + t.Fatal("module identity without import path accepted as valid") + } + if (ID{ImportPath: "app/math/counter"}).Valid() { + t.Fatal("module identity without origin accepted as valid") + } +} diff --git a/internal/phase/phase.go b/internal/phase/phase.go index 89ba27da..c3e1e441 100644 --- a/internal/phase/phase.go +++ b/internal/phase/phase.go @@ -23,6 +23,9 @@ const ( CFG // FlowTyped includes CFG-refined expression types and place origins. FlowTyped + // Effects publishes the ordered semantic meaning of each CFG site, which + // the dataflow analyses consume instead of re-reading syntax. + Effects // DefiniteInit records completion of diagnostic-only initialization checks. DefiniteInit // Ownership includes ownership cleanup results. @@ -60,6 +63,8 @@ func (phase Phase) String() string { return "CFG" case FlowTyped: return "flow-typed" + case Effects: + return "effects" case DefiniteInit: return "definite-init" case Ownership: diff --git a/internal/pipeline/loader.go b/internal/pipeline/loader.go index d7582784..750e5509 100644 --- a/internal/pipeline/loader.go +++ b/internal/pipeline/loader.go @@ -11,14 +11,16 @@ import ( "compiler/internal/frontend/lexer" "compiler/internal/frontend/parser" "compiler/internal/graph" + "compiler/internal/moduleid" "compiler/internal/phase" + "compiler/internal/prelude" "compiler/internal/project" ) type moduleLoader struct { ctx *project.CompilerContext mu sync.Mutex - scheduled map[string]struct{} + scheduled map[moduleid.ID]string wg sync.WaitGroup } @@ -38,20 +40,26 @@ func (l *moduleLoader) enqueue(module *project.Module) { if l == nil || l.ctx == nil || module == nil { return } - l.ensureModuleIdentity(module) - if module.Key == "" { + if !module.ID.Valid() { return } l.mu.Lock() - if _, ok := l.scheduled[module.Key]; ok { + scheduledPath, ok := l.scheduled[module.ID] + if ok { l.mu.Unlock() + // A same-identity enqueue from a different file must reach the registry + // so its conflict policy emits ErrAmbiguousImport instead of a silent + // dedupe hiding the identity conflict. + if module.FilePath != "" && scheduledPath != "" && scheduledPath != module.FilePath { + l.ctx.AddModule(module) + } return } - l.scheduled[module.Key] = struct{}{} + l.scheduled[module.ID] = module.FilePath l.mu.Unlock() - if existing, ok := l.ctx.ModuleByKey(module.Key); ok && existing != module { + if existing, ok := l.ctx.ModuleByID(module.ID); ok { module = existing } else { l.ctx.AddModule(module) @@ -61,26 +69,12 @@ func (l *moduleLoader) enqueue(module *project.Module) { go l.loadModule(module) } -func (l *moduleLoader) ensureModuleIdentity(module *project.Module) { - if module == nil || l == nil || l.ctx == nil { - return - } - if module.Key == "" && module.FilePath != "" { - module.Key = project.ModuleKeyFor(module.Origin, module.FilePath) - } - if module.ImportPath == "" && module.FilePath != "" { - if importPath, err := l.ctx.ImportPathForFile(module.Origin, module.Namespace, module.FilePath); err == nil { - module.ImportPath = importPath - } - } -} - func (l *moduleLoader) loadModule(module *project.Module) { defer l.wg.Done() if module == nil || l == nil { return } - loadDiag := l.ctx.Diagnostics.BeginPhase(phase.Load, module.Key) + loadDiag := l.ctx.Diagnostics.BeginPhase(phase.Load, module.ID.String()) if module.AST != nil { if module.ImportFingerprint == "" { module.ImportFingerprint = module.AST.ImportFingerprint @@ -107,7 +101,7 @@ func (l *moduleLoader) loadModule(module *project.Module) { l.ctx.Diagnostics.AddSourceContent(module.FilePath, module.Content) } module.ContentHash = ast.HashText(module.Content) - parseDiag := l.ctx.Diagnostics.BeginPhase(phase.Parsed, module.Key) + parseDiag := l.ctx.Diagnostics.BeginPhase(phase.Parsed, module.ID.String()) toks := lexer.New(module.FilePath, module.Content, parseDiag).Tokenize() // Content is no longer needed after lexing; free the string. module.Content = "" @@ -137,33 +131,47 @@ func (l *moduleLoader) resolveImports(module *project.Module, diag *diagnostics. l.addImportResolveError(diag, imp, err) continue } - alias := importAlias(imp, resolved.ImportPath) + alias := importAlias(imp, resolved.ID.ImportPath) if alias == "" { l.addImportError(diag, imp, diagnostics.ErrInvalidImportPath, "missing import alias") continue } - if existing, ok := module.Imports[alias]; ok && existing.Key != resolved.Key { + if existing, ok := module.Imports[alias]; ok && existing.ID != resolved.ID { l.addImportError(diag, imp, diagnostics.ErrAmbiguousImport, "import alias already in use") continue } + // The prelude is injected into global scope for every module, so naming it + // in an import list adds nothing. Style note only: the import still works. + if resolved.ID == prelude.ModuleID(l.ctx) { + diag.Add(diagnostics.NewInfo("`"+rawPath+"` is imported automatically"). + WithCode(diagnostics.InfoRedundantPreludeImport). + WithPrimaryLabel(ast.LocOf(imp), "remove this import"). + WithNote("global symbols are always in scope without an import")) + } resolvedImport := *resolved resolvedImport.Decl = imp module.Imports[alias] = resolvedImport if l.ctx.Graph != nil { - l.ctx.Graph.AddEdge(graph.NodeID(module.Key), graph.NodeID(resolved.Key)) + l.ctx.Graph.AddEdge(graph.NodeID(module.ID.String()), graph.NodeID(resolved.ID.String())) } - if existing, ok := l.ctx.ModuleByKey(resolved.Key); ok { + if existing, ok := l.ctx.ModuleByID(resolved.ID); ok { + // Two distinct files deriving one identity must not silently resolve + // to whichever loaded first. Extensions compare case-insensitively + // while the import path keeps the file's own case, so foo.peep and + // foo.PEEP can both reduce to the same logical identity. The registry + // owns the conflict policy; only this site knows which import caused + // it, so it labels the diagnostic the registry recorded. + if existing.FilePath != "" && existing.FilePath != project.CanonicalPath(resolved.FilePath) { + if conflict := l.ctx.AddModule(&project.Module{ID: resolved.ID, FilePath: resolved.FilePath}); conflict != nil { + conflict.WithPrimaryLabel(ast.LocOf(imp), "conflicting import") + } + continue + } l.enqueue(existing) continue } - l.enqueue(&project.Module{ - Key: resolved.Key, - ImportPath: resolved.ImportPath, - FilePath: resolved.FilePath, - Namespace: resolved.Namespace, - Origin: resolved.Origin, - }) + l.enqueue(&project.Module{ID: resolved.ID, FilePath: resolved.FilePath}) } } diff --git a/internal/pipeline/pipeline.go b/internal/pipeline/pipeline.go index 76fa76d3..9bfe49c2 100644 --- a/internal/pipeline/pipeline.go +++ b/internal/pipeline/pipeline.go @@ -15,13 +15,16 @@ import ( "compiler/internal/ir/hir/fold" "compiler/internal/ir/hir/lower" "compiler/internal/ir/mir" + "compiler/internal/moduleid" "compiler/internal/phase" + preludepkg "compiler/internal/prelude" "compiler/internal/problems" "compiler/internal/project" "compiler/internal/semantics/binder" "compiler/internal/semantics/collector" "compiler/internal/semantics/consteval" "compiler/internal/semantics/definiteinit" + "compiler/internal/semantics/effect" "compiler/internal/semantics/ownership" "compiler/internal/semantics/resolver" "compiler/internal/semantics/symbols" @@ -37,6 +40,7 @@ func Run(ctx *project.CompilerContext, entry *project.Module) error { } entry.IsEntry = true + // Explicit entry content must replace any overlay stub registered for same ID. ctx.AddModule(entry) ctx.CompletedProjectPhase = phase.Load diag := ctx.Diagnostics @@ -45,14 +49,14 @@ func Run(ctx *project.CompilerContext, entry *project.Module) error { loader := &moduleLoader{ ctx: ctx, - scheduled: make(map[string]struct{}), + scheduled: make(map[moduleid.ID]string), } - preludeKey := "" - if preludeMod, ok := ctx.ModuleByKey("core:prelude/global"); ok { + preludeID := moduleid.ID{} + if preludeMod, ok := ctx.ModuleByID(preludepkg.ModuleID(ctx)); ok { if err := loader.Load(preludeMod); err != nil { return err } - preludeKey = preludeMod.Key + preludeID = preludeMod.ID } if err := loader.Load(entry); err != nil { return err @@ -60,11 +64,11 @@ func Run(ctx *project.CompilerContext, entry *project.Module) error { // Ensure topo-sort puts prelude first by making all non-prelude modules // depend on it. This removes the need for any special-case ordering logic. - if preludeKey != "" { + if preludeID.Valid() { for _, mod := range ctx.Modules() { - if mod != nil && mod.Key != preludeKey { + if mod != nil && mod.ID != preludeID { if ctx.Graph != nil { - ctx.Graph.AddEdge(graph.NodeID(mod.Key), graph.NodeID(preludeKey)) + ctx.Graph.AddEdge(graph.NodeID(mod.ID.String()), graph.NodeID(preludeID.String())) } } } @@ -74,10 +78,10 @@ func Run(ctx *project.CompilerContext, entry *project.Module) error { moduleIndex := make(map[graph.NodeID]*project.Module, len(modules)) moduleIDs := make([]graph.NodeID, 0, len(modules)) for _, mod := range modules { - if mod == nil || mod.Key == "" { + if mod == nil || !mod.ID.Valid() { continue } - id := graph.NodeID(mod.Key) + id := graph.NodeID(mod.ID.String()) moduleIDs = append(moduleIDs, id) moduleIndex[id] = mod } @@ -95,9 +99,15 @@ func Run(ctx *project.CompilerContext, entry *project.Module) error { if len(cycle) > 0 { parts := make([]string, 0, len(cycle)) for _, id := range cycle { - if id != "" { - parts = append(parts, string(id)) + module := moduleIndex[id] + if module == nil { + continue } + name := module.FilePath + if name == "" { + name = module.ID.ImportPath + } + parts = append(parts, name) } msg = "cyclic import detected: " + strings.Join(parts, " -> ") } @@ -109,13 +119,13 @@ func Run(ctx *project.CompilerContext, entry *project.Module) error { orderedModules := make([]*project.Module, 0, len(orderedIDs)) for _, id := range orderedIDs { module := moduleIndex[id] - if module != nil && module.Key != "" { + if module != nil && module.ID.Valid() { orderedModules = append(orderedModules, module) } } var prelude *project.Module - if preludeKey != "" { - prelude = moduleIndex[graph.NodeID(preludeKey)] + if preludeID.Valid() { + prelude = moduleIndex[graph.NodeID(preludeID.String())] } preludeInjected := advanceModulesThrough(ctx, orderedModules, prelude, prelude == nil, phase.Ownership, diag) ctx.CompletedProjectPhase = phase.Ownership @@ -129,7 +139,7 @@ func Run(ctx *project.CompilerContext, entry *project.Module) error { if module == nil || module.Phase < phase.Ownership || module.Phase >= phase.Usage { continue } - usageDiag := diag.BeginPhase(phase.Usage, module.Key) + usageDiag := diag.BeginPhase(phase.Usage, module.ID.String()) usage.Analyze(ctx.WithDiagnostics(usageDiag), module) module.Phase = phase.Usage ctx.Metrics.AddPhaseAdvance() @@ -139,7 +149,7 @@ func Run(ctx *project.CompilerContext, entry *project.Module) error { } ctx.CompletedProjectPhase = phase.Usage if ctx.Config.RequireEntrypoint { - validateProgramEntrypoint(entry, diag.AppendPhase(phase.Usage, entry.Key)) + validateProgramEntrypoint(entry, diag.AppendPhase(phase.Usage, entry.ID.String())) if diag.HasErrors() { return nil } @@ -239,7 +249,7 @@ func injectPreludeSymbols(ctx *project.CompilerContext, prelude *project.Module, if ctx == nil || ctx.GlobalScope == nil || prelude == nil || prelude.ModuleScope == nil { return } - preludeDiag := diag.AppendPhase(phase.Collected, prelude.Key) + preludeDiag := diag.AppendPhase(phase.Collected, prelude.ID.String()) for _, sym := range prelude.ModuleScope.Symbols() { if err := ctx.GlobalScope.Declare(sym); err == nil { continue @@ -254,15 +264,15 @@ func injectPreludeSymbols(ctx *project.CompilerContext, prelude *project.Module, // requireScheduledModulesAtLeast reports scheduled modules that stalled before // a required project-wide phase barrier without user diagnostics. -func requireScheduledModulesAtLeast(modules []*project.Module, scheduled map[string]struct{}, phase phase.Phase) error { +func requireScheduledModulesAtLeast(modules []*project.Module, scheduled map[moduleid.ID]string, phase phase.Phase) error { for _, module := range modules { if module == nil || module.Phase >= phase { continue } - if _, ok := scheduled[module.Key]; !ok { + if _, ok := scheduled[module.ID]; !ok { continue } - name := module.Key + name := module.ID.ImportPath if name == "" { name = module.FilePath } @@ -287,7 +297,7 @@ func moduleReadyForNextPhase(ctx *project.CompilerContext, module, prelude *proj return true } for _, imp := range module.Imports { - imported, ok := ctx.ModuleByKey(imp.Key) + imported, ok := ctx.ModuleByID(imp.ID) if !ok || imported == nil || imported.Phase < required { return false } @@ -296,14 +306,16 @@ func moduleReadyForNextPhase(ctx *project.CompilerContext, module, prelude *proj } func preludeReadyForPhase(module, prelude *project.Module, preludeInjected bool, next phase.Phase) bool { - if module == nil || prelude == nil || module.Key == prelude.Key { + if module == nil || prelude == nil || module.ID == prelude.ID { return true } switch next { case phase.Collected, phase.Bound: return true - default: + case phase.Resolved: return preludeInjected && prelude.Phase >= phase.Resolved + default: + return preludeInjected && prelude.Phase >= phase.Typechecked } } @@ -324,6 +336,8 @@ func nextModulePhase(current phase.Phase) phase.Phase { case phase.CFG: return phase.FlowTyped case phase.FlowTyped: + return phase.Effects + case phase.Effects: return phase.DefiniteInit case phase.DefiniteInit: return phase.Ownership @@ -347,7 +361,7 @@ func importPrerequisitePhase(next phase.Phase) phase.Phase { case phase.Bound: return phase.Bound case phase.ConstEval: - return phase.ConstEval + return phase.Typechecked case phase.Typechecked: return phase.Typechecked case phase.Resolved: @@ -356,8 +370,10 @@ func importPrerequisitePhase(next phase.Phase) phase.Phase { return phase.Typechecked case phase.FlowTyped: return phase.CFG - case phase.DefiniteInit: + case phase.Effects: return phase.FlowTyped + case phase.DefiniteInit: + return phase.Effects case phase.Ownership: return phase.DefiniteInit case phase.Usage: @@ -383,7 +399,7 @@ func advanceModulePhase(ctx *project.CompilerContext, module *project.Module, di if next == phase.None || next == phase.Usage { return false } - phaseDiag := diag.BeginPhase(next, module.Key) + phaseDiag := diag.BeginPhase(next, module.ID.String()) phaseCtx := ctx.WithDiagnostics(phaseDiag) if module.Phase < phase.Collected { collector.Collect(phaseCtx, module) @@ -412,17 +428,25 @@ func advanceModulePhase(ctx *project.CompilerContext, module *project.Module, di if module.Phase < phase.Typechecked { typechecker.Check(phaseCtx, module) consteval.FinalizeValues(phaseCtx, module) - module.TypedASTNodes = ast.Index(module.AST) - module.SemanticExportFingerprint = project.SemanticExportFingerprint(module) + module.RebuildTypedASTIndex() + module.SemanticExportFingerprint = project.SemanticExportFingerprint(ctx, module) module.Phase = phase.Typechecked ctx.Metrics.AddPhaseAdvance() return true } if module.Phase < phase.CFG { module.CFG = cfg.BuildModule(module.AST, cfg.BuildQueries{ - MatchCases: module.Semantics.MatchCases, - LoopGuaranteedEntry: module.Semantics.ForLoopGuaranteedEntry, + MatchCases: module.Typechecking.MatchCases, + LoopGuaranteedEntry: module.Typechecking.ForLoopGuaranteedEntry, }) + // Structure is checkable regardless of source validity: CFG construction + // promises the same topology for a program that will not compile, and a + // malformed graph misleads every phase that reads it, including Analyze + // just below. + if err := module.CFG.Validate(); err != nil { + phaseDiag.AddError(diagnostics.ErrInvalidTopology, + "control-flow topology is malformed: "+err.Error(), nil, "") + } cfg.Analyze(module.CFG, phaseDiag, func(conditionID, scopeID ir.NodeID) (bool, bool) { node := module.TypedASTNodes[ast.NodeID(conditionID)] expr, ok := node.(ast.Expr) @@ -432,7 +456,7 @@ func advanceModulePhase(ctx *project.CompilerContext, module *project.Module, di value, ok := consteval.EvaluateExpr( phaseCtx, module, - module.Semantics.BlockScopes[ast.NodeID(scopeID)], + module.Bindings.BlockScopes[ast.NodeID(scopeID)], expr, &typeinfo.BoolType{}, ) @@ -451,21 +475,43 @@ func advanceModulePhase(ctx *project.CompilerContext, module *project.Module, di ctx.Metrics.AddPhaseAdvance() return true } + if module.Phase < phase.Effects { + module.Effects = effect.Build(module.CFG, module.TypedASTNodes, effect.BuildQueries{ + Symbols: module.Bindings.NodeSymbols, + Scopes: module.Bindings.BlockScopes, + CallArguments: module.Typechecking.CallArgumentsOrSource, + ArmBindings: module.Typechecking.ArmBindings, + StringConcatenation: module.Typechecking.StringConcatenation, + ValueUse: module.Typechecking.ValueUse, + ExprType: module.EffectiveExprType, + ReferenceArgument: module.Typechecking.ReferenceArgument, + SequenceCarrier: module.Typechecking.SequenceCarrier, + }) + if err := module.Effects.Validate(module.CFG, module.TypedASTNodes); err != nil { + phaseDiag.AddError(diagnostics.ErrInvalidEvidence, + "published semantic effects are malformed: "+err.Error(), nil, "") + } + module.Phase = phase.Effects + ctx.Metrics.AddPhaseAdvance() + return true + } if module.Phase < phase.DefiniteInit { - definiteinit.Check( - module.CFG, - module.TypedASTNodes, - module.Semantics.BlockScopes, - module.Semantics.ResolvedSymbols, - module.Semantics.Matches, - phaseDiag, - ) + definiteinit.Check(module.CFG, module.Effects, phaseDiag) module.Phase = phase.DefiniteInit ctx.Metrics.AddPhaseAdvance() return true } if module.Phase < phase.Ownership { module.Ownership = ownership.Check(phaseCtx, module) + // Published evidence is only checkable once the module is otherwise + // error-free: broken source legitimately leaves evidence incomplete, + // and reporting that as a compiler bug would bury the real diagnostic. + if !phaseDiag.HasErrors() { + if err := module.Ownership.Validate(module.Typechecking, module.Bindings, module.CFG); err != nil { + phaseDiag.AddError(diagnostics.ErrInvalidEvidence, + "ownership evidence is inconsistent: "+err.Error(), nil, "") + } + } module.Phase = phase.Ownership ctx.Metrics.AddPhaseAdvance() return true @@ -482,6 +528,10 @@ func advanceModulePhase(ctx *project.CompilerContext, module *project.Module, di return false } module.HIR = fold.ApplyTypedExpressionFolding(modhir) + if err := module.HIR.Validate(); err != nil { + phaseDiag.AddError(diagnostics.ErrInvalidEvidence, + "lowered HIR is malformed: "+err.Error(), nil, "") + } module.Phase = phase.HIR ctx.Metrics.AddPhaseAdvance() return true @@ -493,7 +543,11 @@ func advanceModulePhase(ctx *project.CompilerContext, module *project.Module, di if diag != nil && diag.HasErrors() { return false } - module.MIR = mir.GenerateMIR(module.HIR, module.CFG, module.Ownership, module.ModuleScope, module.Semantics.ConstValues) + module.MIR = mir.GenerateMIR(module.HIR, module.CFG, module.Ownership, module.ModuleScope, module.Constants.ModuleValues) + if err := module.MIR.Validate(); err != nil { + phaseDiag.AddError(diagnostics.ErrInvalidEvidence, + "lowered MIR is malformed: "+err.Error(), nil, "") + } module.Phase = phase.MIR ctx.Metrics.AddPhaseAdvance() return true @@ -504,6 +558,12 @@ func advanceModulePhase(ctx *project.CompilerContext, module *project.Module, di if module.Phase >= phase.Backend { return false } + // Emission assumes the MIR it is handed is well formed, and says so by + // panicking. The validator above is what makes that assumption safe, so + // nothing may reach emission once an error is recorded. + if diag != nil && diag.HasErrors() { + return false + } module.LLVMIR = llvm.GenerateLLVMIR(module.MIR, phaseDiag, ctx.Target, ctx.Config.BuildDebug) module.Phase = phase.Backend ctx.Metrics.AddPhaseAdvance() @@ -518,15 +578,21 @@ func invalidateSemanticDependents(ctx *project.CompilerContext, advanced []*proj } queue := make([]graph.NodeID, 0) seen := make(map[graph.NodeID]struct{}) + modules := make(map[graph.NodeID]*project.Module) + for _, module := range ctx.Modules() { + if module != nil && module.ID.Valid() { + modules[graph.NodeID(module.ID.String())] = module + } + } for _, module := range advanced { if module == nil || module.Phase != phase.Typechecked { continue } - baseline, ok := ctx.SemanticExportBaseline(module.Key) + baseline, ok := ctx.SemanticExportBaseline(module.ID) if !ok || baseline == module.SemanticExportFingerprint { continue } - id := graph.NodeID(module.Key) + id := graph.NodeID(module.ID.String()) queue = append(queue, id) seen[id] = struct{}{} } @@ -539,7 +605,7 @@ func invalidateSemanticDependents(ctx *project.CompilerContext, advanced []*proj } seen[dependentID] = struct{}{} queue = append(queue, dependentID) - dependent, found := ctx.ModuleByKey(string(dependentID)) + dependent, found := modules[dependentID] if !found || dependent == nil || dependent.Phase < phase.Typechecked { continue } diff --git a/internal/pipeline/pipeline_test.go b/internal/pipeline/pipeline_test.go index 92c107fe..d838312f 100644 --- a/internal/pipeline/pipeline_test.go +++ b/internal/pipeline/pipeline_test.go @@ -17,21 +17,26 @@ import ( "compiler/internal/ir/cfg" "compiler/internal/ir/hir" "compiler/internal/ir/mir" + "compiler/internal/moduleid" "compiler/internal/phase" + "compiler/internal/prelude" "compiler/internal/project" "compiler/internal/semantics/intrinsics" "compiler/internal/semantics/symbols" "compiler/internal/target" + "compiler/pkg/manifest" "compiler/pkg/peeper" ) func parseModuleSource(filePath, src string, diag *diagnostics.DiagnosticBag) *project.Module { return &project.Module{ - Key: project.ModuleKeyFor(project.ModuleOriginLocal, filePath), - ImportPath: strings.TrimSuffix(filePath, peeper.SourceExt), - FilePath: filePath, - AST: parser.New(filePath, lexer.New(filePath, src, diag).Tokenize(), diag).ParseModule(), - Imports: make(map[string]project.ResolvedImport), + ID: moduleid.ID{ + Origin: string(project.ModuleOriginLocal), + ImportPath: strings.TrimSuffix(filePath, peeper.SourceExt), + }, + FilePath: filePath, + AST: parser.New(filePath, lexer.New(filePath, src, diag).Tokenize(), diag).ParseModule(), + Imports: make(map[string]project.ResolvedImport), } } @@ -46,15 +51,11 @@ func buildPipelineTestWithConfig(t *testing.T, cfg project.Config, preludeSrc, e ctx := project.NewWithConfig(cfg, diag) // Register the prelude so the pipeline loader can find it. - prelude := parseModuleSource(preludePath, preludeSrc, diag) - prelude.Key = "core:prelude/global" - prelude.ImportPath = "prelude/global" - prelude.Namespace = "core" - prelude.Origin = project.ModuleOriginStdlib - ctx.AddModule(prelude) + preludeModule := parseModuleSource(preludePath, preludeSrc, diag) + preludeModule.ID = prelude.ModuleID(ctx) + ctx.AddModule(preludeModule) entry := parseModuleSource(entryPath, entrySrc, diag) - entry.Origin = project.ModuleOriginLocal if err := Run(ctx, entry); err != nil { t.Fatalf("pipeline.Run returned error: %v", err) @@ -88,10 +89,8 @@ func runImportedRuntimeSymbolPipeline(t *testing.T, entrySrc, runtimeSrc string) Extension: peeper.SourceExt, }, diag) entry := &project.Module{ - Key: project.ModuleKeyFor(project.ModuleOriginLocal, entryPath), - ImportPath: "app/main", - FilePath: entryPath, - Origin: project.ModuleOriginLocal, + ID: moduleid.ID{Origin: string(project.ModuleOriginLocal), ImportPath: "app/main"}, + FilePath: entryPath, } if err := Run(ctx, entry); err != nil { t.Fatalf("pipeline.Run returned error: %v", err) @@ -456,12 +455,10 @@ fn main() -> i32 { LibraryBaseDir: libraryBase, }, diag) entry := &project.Module{ - Key: project.ModuleKeyFor(project.ModuleOriginLocal, entryPath), - ImportPath: "entry", - FilePath: entryPath, - Content: entrySrc, - Origin: project.ModuleOriginLocal, - Imports: make(map[string]project.ResolvedImport), + ID: moduleid.ID{Origin: string(project.ModuleOriginLocal), ImportPath: "entry"}, + FilePath: entryPath, + Content: entrySrc, + Imports: make(map[string]project.ResolvedImport), } if err := Run(ctx, entry); err != nil { @@ -478,6 +475,93 @@ fn main() -> i32 { } } +// preludeQualifierPipeline compiles one entry against a real temp library root so +// `core:` imports resolve for real, and returns the resulting diagnostics. +func preludeQualifierPipeline(t *testing.T, libraryFile, librarySrc, entrySrc string) *diagnostics.DiagnosticBag { + t.Helper() + root := t.TempDir() + libraryBase := filepath.Join(root, "libs") + libraryPath := filepath.Join(libraryBase, "core", peeper.SourceDirName, libraryFile+peeper.SourceExt) + if err := os.MkdirAll(filepath.Dir(libraryPath), 0o755); err != nil { + t.Fatalf("mkdir library: %v", err) + } + if err := os.WriteFile(libraryPath, []byte(librarySrc), 0o644); err != nil { + t.Fatalf("write library: %v", err) + } + entryPath := filepath.Join(root, "entry"+peeper.SourceExt) + diag := diagnostics.NewDiagnosticBag() + ctx := project.NewWithConfig(project.Config{ + RootDir: root, + Extension: peeper.SourceExt, + LibraryBaseDir: libraryBase, + }, diag) + // Production loads the prelude in the driver; do the same so global symbols + // reach global scope and redundant qualification is observable. + if err := prelude.Load(ctx); err != nil { + t.Fatalf("load prelude: %v", err) + } + entry := &project.Module{ + ID: moduleid.ID{Origin: string(project.ModuleOriginLocal), ImportPath: "entry"}, + FilePath: entryPath, + Content: entrySrc, + Imports: make(map[string]project.ResolvedImport), + } + if err := Run(ctx, entry); err != nil { + t.Fatalf("pipeline.Run returned error: %v", err) + } + return diag +} + +func hasDiagnosticCode(diag *diagnostics.DiagnosticBag, code string) bool { + for _, item := range diag.Diagnostics() { + if item != nil && item.Code == code { + return true + } + } + return false +} + +func TestPipelineNotesRedundantPreludeImportAndQualifier(t *testing.T) { + diag := preludeQualifierPipeline(t, "global", `fn Ping() -> i32 { + return 7; +} +`, `import "core:global"; + +fn main() -> i32 { + return global::Ping() - 7; +}`) + if diag.HasErrors() { + t.Fatalf("redundant prelude use must stay compilable:\n%s", diag.EmitAllToString()) + } + if !hasDiagnosticCode(diag, diagnostics.InfoRedundantPreludeImport) { + t.Fatalf("explicit prelude import produced no style note:\n%s", diag.EmitAllToString()) + } + if !hasDiagnosticCode(diag, diagnostics.InfoRedundantGlobalQualifier) { + t.Fatalf("global-qualified prelude symbol produced no style note:\n%s", diag.EmitAllToString()) + } +} + +func TestPipelineKeepsOrdinaryLibraryQualifierQuiet(t *testing.T) { + // A non-prelude library is qualified legitimately, so neither style note applies. + diag := preludeQualifierPipeline(t, "util", `fn Helper() -> i32 { + return 3; +} +`, `import "core:util"; + +fn main() -> i32 { + return util::Helper() - 3; +}`) + if diag.HasErrors() { + t.Fatalf("ordinary library import failed:\n%s", diag.EmitAllToString()) + } + if hasDiagnosticCode(diag, diagnostics.InfoRedundantPreludeImport) { + t.Fatalf("ordinary import reported as automatic:\n%s", diag.EmitAllToString()) + } + if hasDiagnosticCode(diag, diagnostics.InfoRedundantGlobalQualifier) { + t.Fatalf("ordinary qualifier reported as redundant:\n%s", diag.EmitAllToString()) + } +} + func TestPipelineScalarShrinkOwnedParameterReservesForeignFree(t *testing.T) { diag := runImportedRuntimeSymbolPipeline(t, `import "app/runtime"; @@ -566,16 +650,12 @@ fn main() -> i32 { Extension: peeper.SourceExt, }, diag) - prelude := parseModuleSource("core/global"+peeper.SourceExt, preludeSrc, diag) - prelude.Key = "core:prelude/global" - prelude.ImportPath = "prelude/global" - prelude.Namespace = "core" - prelude.Origin = project.ModuleOriginStdlib - ctx.AddModule(prelude) + preludeModule := parseModuleSource("core/global"+peeper.SourceExt, preludeSrc, diag) + preludeModule.ID = prelude.ModuleID(ctx) + ctx.AddModule(preludeModule) entry := parseModuleSource("entry"+peeper.SourceExt, entrySrc, diag) - entry.ImportPath = "entry" - entry.Origin = project.ModuleOriginLocal + entry.ID.ImportPath = "entry" if err := Run(ctx, entry); err != nil { t.Fatalf("pipeline.Run returned error: %v", err) @@ -612,16 +692,12 @@ fn main() -> i32 { Extension: peeper.SourceExt, }, diag) - prelude := parseModuleSource("core/global"+peeper.SourceExt, preludeSrc, diag) - prelude.Key = "core:prelude/global" - prelude.ImportPath = "prelude/global" - prelude.Namespace = "core" - prelude.Origin = project.ModuleOriginStdlib - ctx.AddModule(prelude) + preludeModule := parseModuleSource("core/global"+peeper.SourceExt, preludeSrc, diag) + preludeModule.ID = prelude.ModuleID(ctx) + ctx.AddModule(preludeModule) entry := parseModuleSource("entry"+peeper.SourceExt, entrySrc, diag) - entry.ImportPath = "entry" - entry.Origin = project.ModuleOriginLocal + entry.ID.ImportPath = "entry" if err := Run(ctx, entry); err != nil { t.Fatalf("pipeline.Run returned error: %v", err) @@ -674,16 +750,12 @@ fn main() -> i32 { Extension: peeper.SourceExt, }, diag) - prelude := parseModuleSource("core/global"+peeper.SourceExt, preludeSrc, diag) - prelude.Key = "core:prelude/global" - prelude.ImportPath = "prelude/global" - prelude.Namespace = "core" - prelude.Origin = project.ModuleOriginStdlib - ctx.AddModule(prelude) + preludeModule := parseModuleSource("core/global"+peeper.SourceExt, preludeSrc, diag) + preludeModule.ID = prelude.ModuleID(ctx) + ctx.AddModule(preludeModule) entry := parseModuleSource("entry"+peeper.SourceExt, entrySrc, diag) - entry.ImportPath = "entry" - entry.Origin = project.ModuleOriginLocal + entry.ID.ImportPath = "entry" if err := Run(ctx, entry); err != nil { t.Fatalf("pipeline.Run returned error: %v", err) @@ -707,7 +779,6 @@ fn main() -> i32 { return 0; }`) ctx := project.New(".", peeper.SourceExt, diag) entry := parseModuleSource(filePath, `fn unused() {} fn main() -> i32 { return 0; }`, diag) - entry.Origin = project.ModuleOriginLocal if err := Run(ctx, entry); err != nil { t.Fatalf("first Pipeline.Run: %v", err) @@ -744,7 +815,6 @@ func TestPipelineRunReplacesStaleFinalizeDiagnostics(t *testing.T) { diag.AddSourceContent(filePath, sourceText) ctx := project.New(".", peeper.SourceExt, diag) entry := parseModuleSource(filePath, sourceText, diag) - entry.Origin = project.ModuleOriginLocal if err := Run(ctx, entry); err != nil { t.Fatalf("Pipeline.Run: %v", err) @@ -777,16 +847,12 @@ func TestPipelineDebugBuildEmitsLLVMMetadata(t *testing.T) { diag.AddSourceContent("entry"+peeper.SourceExt, entrySrc) ctx := project.NewWithConfig(cfg, diag) - prelude := parseModuleSource("core/global"+peeper.SourceExt, preludeSrc, diag) - prelude.Key = "core:prelude/global" - prelude.ImportPath = "prelude/global" - prelude.Namespace = "core" - prelude.Origin = project.ModuleOriginStdlib - ctx.AddModule(prelude) + preludeModule := parseModuleSource("core/global"+peeper.SourceExt, preludeSrc, diag) + preludeModule.ID = prelude.ModuleID(ctx) + ctx.AddModule(preludeModule) entry := parseModuleSource("entry"+peeper.SourceExt, entrySrc, diag) - entry.ImportPath = "entry" - entry.Origin = project.ModuleOriginLocal + entry.ID.ImportPath = "entry" if err := Run(ctx, entry); err != nil { t.Fatalf("pipeline.Run returned error: %v", err) @@ -808,7 +874,6 @@ func TestPipelineAdvanceModulePhaseRunsOnePhaseAtATime(t *testing.T) { diag.AddSourceContent(entryPath, entrySrc) ctx := project.NewWithConfig(project.Config{RootDir: ".", Extension: peeper.SourceExt}, diag) entry := parseModuleSource(entryPath, entrySrc, diag) - entry.Origin = project.ModuleOriginLocal entry.Phase = phase.Parsed ctx.AddModule(entry) @@ -820,6 +885,7 @@ func TestPipelineAdvanceModulePhaseRunsOnePhaseAtATime(t *testing.T) { phase.Typechecked, phase.CFG, phase.FlowTyped, + phase.Effects, phase.DefiniteInit, phase.Ownership, } @@ -836,6 +902,9 @@ func TestPipelineAdvanceModulePhaseRunsOnePhaseAtATime(t *testing.T) { if wantPhase == phase.FlowTyped && entry.Flow == nil { t.Fatal("flow-typed phase must retain canonical result") } + if wantPhase == phase.Effects && entry.Effects == nil { + t.Fatal("effects phase must retain published site effects") + } if wantPhase < phase.HIR && entry.HIR != nil { t.Fatalf("phase %v produced HIR before mandatory semantics completed", wantPhase) } @@ -864,13 +933,12 @@ func TestPipelineAdvanceModulePhaseRunsOnePhaseAtATime(t *testing.T) { } } -func TestTypecheckedPhaseFinalizesModuleConstValues(t *testing.T) { +func TestTypecheckedPhasePublishesModuleConstants(t *testing.T) { diag := diagnostics.NewDiagnosticBag() const entryPath = "entry" + peeper.SourceExt entry := parseModuleSource(entryPath, `const Value = 1; fn main() -> i32 { return Value; } `, diag) - entry.Origin = project.ModuleOriginLocal entry.Phase = phase.Parsed ctx := project.NewWithConfig(project.Config{RootDir: ".", Extension: peeper.SourceExt}, diag) ctx.AddModule(entry) @@ -887,13 +955,16 @@ fn main() -> i32 { return Value; } if !ok { t.Fatal("failed to construct stale const value") } - entry.Semantics.ConstValues[sym.ID] = stale + entry.Constants.QueryCache[sym.ID] = stale if !advanceModulePhase(ctx, entry, diag) || entry.Phase != phase.Typechecked { t.Fatalf("phase = %v, want typechecked", entry.Phase) } - if got := entry.Semantics.ConstValues[sym.ID]; got == nil || got.TypeText() != "i32" { + if got := entry.Constants.ModuleValues[sym.ID]; got == nil || got.TypeText() != "i32" { t.Fatalf("final const value = %#v, want i32", got) } + if _, found := entry.Constants.QueryCache[sym.ID]; found { + t.Fatal("published module constant remains duplicated in query cache") + } } func TestTypecheckedPhaseFinalizesNamedVariantConstants(t *testing.T) { @@ -908,7 +979,6 @@ const Waiting: Status = Status::Waiting; const ReadyIsReady: bool = Ready is Status::Ready; const WaitingIsReady: bool = Waiting is Status::Ready; `, diag) - entry.Origin = project.ModuleOriginLocal entry.Phase = phase.Parsed ctx := project.NewWithConfig(project.Config{RootDir: ".", Extension: peeper.SourceExt}, diag) ctx.AddModule(entry) @@ -924,9 +994,9 @@ const WaitingIsReady: bool = Waiting is Status::Ready; if !found || readySymbol == nil { t.Fatal("missing const symbol Ready") } - ready, ok := entry.Semantics.ConstValues[readySymbol.ID].(*constvalue.VariantConst) + ready, ok := entry.Constants.ModuleValues[readySymbol.ID].(*constvalue.VariantConst) if !ok || ready == nil || ready.NominalIdentity() == "" || ready.CaseIndex() != 0 || len(ready.FieldValues()) != 2 { - t.Fatalf("Ready constant = %#v, want named case 0 with two fields", entry.Semantics.ConstValues[readySymbol.ID]) + t.Fatalf("Ready constant = %#v, want named case 0 with two fields", entry.Constants.ModuleValues[readySymbol.ID]) } code, ok := ready.FieldValues()[0].(*constvalue.IntConst) if !ok || code.Text() != "7" { @@ -949,7 +1019,6 @@ fn main() -> i32 { return 0; } `, diag) - entry.Origin = project.ModuleOriginLocal ctx := project.NewWithConfig(project.Config{ RootDir: ".", Extension: peeper.SourceExt, @@ -975,9 +1044,9 @@ func assertPipelineBoolConst(t *testing.T, module *project.Module, name string, if !found || sym == nil { t.Fatalf("missing const symbol %s", name) } - value, ok := module.Semantics.ConstValues[sym.ID].(*constvalue.BoolConst) + value, ok := module.Constants.ModuleValues[sym.ID].(*constvalue.BoolConst) if !ok || value == nil || value.Bool() != want { - t.Fatalf("%s = %#v, want bool %t", name, module.Semantics.ConstValues[sym.ID], want) + t.Fatalf("%s = %#v, want bool %t", name, module.Constants.ModuleValues[sym.ID], want) } } @@ -989,7 +1058,6 @@ func TestPipelineFinalizesMissingReturnDiagnosticInCFGPhase(t *testing.T) { return 7; } }`, diag) - entry.Origin = project.ModuleOriginLocal entry.Phase = phase.Parsed ctx := project.NewWithConfig(project.Config{RootDir: ".", Extension: peeper.SourceExt}, diag) ctx.AddModule(entry) @@ -1019,7 +1087,6 @@ func TestPipelineReportsConstantConditionInCFGPhase(t *testing.T) { } return 0; }`, diag) - entry.Origin = project.ModuleOriginLocal entry.Phase = phase.Parsed ctx := project.NewWithConfig(project.Config{RootDir: ".", Extension: peeper.SourceExt}, diag) ctx.AddModule(entry) @@ -1085,22 +1152,22 @@ func TestRequireScheduledModulesAtLeastReportsStoppedPhase(t *testing.T) { module *project.Module want string }{ - {name: "blocked prerequisite", module: &project.Module{Key: "local:main", Phase: phase.Resolved}, want: "resolved phase"}, - {name: "missing HIR", module: &project.Module{Key: "local:main", Phase: phase.Ownership}, want: "ownership phase"}, - {name: "missing MIR", module: &project.Module{Key: "local:main", Phase: phase.HIR}, want: "HIR phase"}, + {name: "blocked prerequisite", module: &project.Module{ID: moduleid.ID{ImportPath: "local:main"}, Phase: phase.Resolved}, want: "resolved phase"}, + {name: "missing HIR", module: &project.Module{ID: moduleid.ID{ImportPath: "local:main"}, Phase: phase.Ownership}, want: "ownership phase"}, + {name: "missing MIR", module: &project.Module{ID: moduleid.ID{ImportPath: "local:main"}, Phase: phase.HIR}, want: "HIR phase"}, } for _, test := range tests { t.Run(test.name, func(t *testing.T) { - err := requireScheduledModulesAtLeast([]*project.Module{test.module}, map[string]struct{}{test.module.Key: {}}, phase.Backend) + err := requireScheduledModulesAtLeast([]*project.Module{test.module}, map[moduleid.ID]string{test.module.ID: ""}, phase.Backend) if err == nil || !strings.Contains(err.Error(), "local:main") || !strings.Contains(err.Error(), test.want) { t.Fatalf("terminal error = %v, want module and %q", err, test.want) } }) } - if err := requireScheduledModulesAtLeast([]*project.Module{{Key: "local:main", Phase: phase.Backend}}, map[string]struct{}{"local:main": {}}, phase.Backend); err != nil { + if err := requireScheduledModulesAtLeast([]*project.Module{{ID: moduleid.ID{ImportPath: "local:main"}, Phase: phase.Backend}}, map[moduleid.ID]string{moduleid.ID{ImportPath: "local:main"}: ""}, phase.Backend); err != nil { t.Fatalf("completed module rejected: %v", err) } - if err := requireScheduledModulesAtLeast([]*project.Module{{Key: "overlay:stub", Phase: phase.None}}, map[string]struct{}{"local:main": {}}, phase.Backend); err != nil { + if err := requireScheduledModulesAtLeast([]*project.Module{{ID: moduleid.ID{ImportPath: "overlay:stub"}, Phase: phase.None}}, map[moduleid.ID]string{moduleid.ID{ImportPath: "local:main"}: ""}, phase.Backend); err != nil { t.Fatalf("unscheduled overlay rejected: %v", err) } } @@ -1108,7 +1175,6 @@ func TestRequireScheduledModulesAtLeastReportsStoppedPhase(t *testing.T) { func TestPipelineDiagnosticStopReturnsNormally(t *testing.T) { diag := diagnostics.NewDiagnosticBag() entry := parseModuleSource("invalid"+peeper.SourceExt, "fn main() -> Missing { return 0; }", diag) - entry.Origin = project.ModuleOriginLocal ctx := project.NewWithConfig(project.Config{RootDir: ".", Extension: peeper.SourceExt}, diag) if err := Run(ctx, entry); err != nil { t.Fatalf("diagnostic-driven stop returned pipeline error: %v", err) @@ -1181,7 +1247,6 @@ fn main() -> i32 { ctx := project.NewWithConfig(project.Config{RootDir: ".", Extension: peeper.SourceExt}, diag) entry := parseModuleSource(entryPath, entrySrc, diag) - entry.Origin = project.ModuleOriginLocal if err := Run(ctx, entry); err != nil { t.Fatalf("pipeline.Run returned error: %v", err) @@ -1205,19 +1270,15 @@ func TestPipelineModuleReadyForNextPhaseFollowsImportContracts(t *testing.T) { ctx := project.NewWithConfig(project.Config{RootDir: "."}, diag) imported := parseModuleSource("util"+peeper.SourceExt, "fn Helper() -> i32 { return 1; }", diag) - imported.Origin = project.ModuleOriginLocal imported.Phase = phase.Parsed ctx.AddModule(imported) entry := parseModuleSource("main"+peeper.SourceExt, "import \"util\";\nfn main() -> i32 { return util::Helper(); }\n", diag) - entry.Origin = project.ModuleOriginLocal entry.Phase = phase.Parsed entry.Imports = map[string]project.ResolvedImport{ "util": { - Key: imported.Key, - ImportPath: "util", - FilePath: imported.FilePath, - Origin: project.ModuleOriginLocal, + ID: imported.ID, + FilePath: imported.FilePath, }, } ctx.AddModule(entry) @@ -1249,12 +1310,17 @@ func TestPipelineModuleReadyForNextPhaseFollowsImportContracts(t *testing.T) { entry.Phase = phase.Resolved if moduleReadyForNextPhase(ctx, entry, nil, true) { - t.Fatalf("resolved importer should wait for const-evaluated import before consteval") + t.Fatal("resolved importer should wait for typechecked import before consteval") } imported.Phase = phase.ConstEval + if moduleReadyForNextPhase(ctx, entry, nil, true) { + t.Fatal("resolved importer should not read provisional import constants") + } + + imported.Phase = phase.Typechecked if !moduleReadyForNextPhase(ctx, entry, nil, true) { - t.Fatalf("resolved importer should be ready for consteval when import is const-evaluated") + t.Fatal("resolved importer should be ready for consteval when import constants are published") } } @@ -1278,10 +1344,8 @@ fn main() -> i32 { } entry := &project.Module{ - Key: project.ModuleKeyFor(project.ModuleOriginLocal, mainPath), - ImportPath: "app/main", - FilePath: mainPath, - Origin: project.ModuleOriginLocal, + ID: moduleid.ID{Origin: string(project.ModuleOriginLocal), ImportPath: "app/main"}, + FilePath: mainPath, } if err := os.WriteFile(utilPath, []byte(utilSrc), 0o644); err != nil { @@ -1349,10 +1413,8 @@ fn Value() -> i32 { mainPath := filepath.Join(srcDir, peeper.MainFileName) ctx := project.NewWithConfig(project.Config{RootDir: root, ProjectName: "app", Extension: peeper.SourceExt}, diag) entry := &project.Module{ - Key: project.ModuleKeyFor(project.ModuleOriginLocal, mainPath), - ImportPath: "app/main", - FilePath: mainPath, - Origin: project.ModuleOriginLocal, + ID: moduleid.ID{Origin: string(project.ModuleOriginLocal), ImportPath: "app/main"}, + FilePath: mainPath, } if err := Run(ctx, entry); err != nil { t.Fatalf("pipeline.Run returned error: %v", err) @@ -1625,7 +1687,6 @@ fn main() -> i32 { RootDir: ".", Extension: peeper.SourceExt, TargetOS: "linux", TargetArch: arch, }, diag) entry := parseModuleSource(filePath, src, diag) - entry.Origin = project.ModuleOriginLocal if err := Run(ctx, entry); err != nil { t.Fatalf("pipeline.Run returned error: %v", err) } @@ -1757,7 +1818,6 @@ fn main() -> i32 { diag.AddSourceContent(entryPath, entrySrc) ctx := project.NewWithConfig(project.Config{RootDir: ".", Extension: peeper.SourceExt}, diag) entry := parseModuleSource(entryPath, entrySrc, diag) - entry.Origin = project.ModuleOriginLocal if err := Run(ctx, entry); err != nil { t.Fatalf("pipeline.Run returned error: %v", err) @@ -1854,7 +1914,6 @@ fn main() -> i32 { diag.AddSourceContent(entryPath, entrySrc) ctx := project.NewWithConfig(project.Config{RootDir: ".", Extension: peeper.SourceExt}, diag) entry := parseModuleSource(entryPath, entrySrc, diag) - entry.Origin = project.ModuleOriginLocal if err := Run(ctx, entry); err != nil { t.Fatalf("pipeline.Run returned error: %v", err) @@ -1938,7 +1997,6 @@ fn main() -> i32 { diag.AddSourceContent(entryPath, entrySrc) ctx := project.NewWithConfig(project.Config{RootDir: ".", Extension: peeper.SourceExt}, diag) entry := parseModuleSource(entryPath, entrySrc, diag) - entry.Origin = project.ModuleOriginLocal if err := Run(ctx, entry); err != nil { t.Fatalf("pipeline.Run returned error: %v", err) @@ -2035,7 +2093,6 @@ fn main() -> i32 { diag.AddSourceContent(entryPath, entrySrc) ctx := project.NewWithConfig(project.Config{RootDir: ".", Extension: peeper.SourceExt}, diag) entry := parseModuleSource(entryPath, entrySrc, diag) - entry.Origin = project.ModuleOriginLocal if err := Run(ctx, entry); err != nil { t.Fatalf("pipeline.Run returned error: %v", err) @@ -2500,7 +2557,6 @@ fn main() -> i32 { TargetArch: compilerTarget.arch, }, diag) entry := parseModuleSource(filePath, src, diag) - entry.Origin = project.ModuleOriginLocal if err := Run(ctx, entry); err != nil { t.Fatalf("pipeline.Run returned error: %v", err) } @@ -2509,7 +2565,7 @@ fn main() -> i32 { } observed := make(map[symbols.CompilerOp]struct{}) - for _, symbol := range entry.Semantics.ResolvedSymbols { + for _, symbol := range entry.Bindings.NodeSymbols { if symbol != nil && symbol.CompilerOp != "" { observed[symbol.CompilerOp] = struct{}{} } @@ -3136,3 +3192,103 @@ fn invalid(holder: &mut Holder) -> i32 { }) } } + +func TestModuleLoaderReportsSameIdentityFromDifferentFiles(t *testing.T) { + root := t.TempDir() + firstPath := filepath.Join(root, "first"+peeper.SourceExt) + secondPath := filepath.Join(root, "second"+peeper.SourceExt) + diag := diagnostics.NewDiagnosticBag() + ctx := project.New(root, peeper.SourceExt, diag) + loader := &moduleLoader{ + ctx: ctx, + scheduled: make(map[moduleid.ID]string), + } + id := moduleid.ID{Origin: string(project.ModuleOriginLocal), ImportPath: "app/shared"} + + loader.enqueue(&project.Module{ID: id, FilePath: firstPath, Content: "fn main() {}\n", ContentProvided: true}) + loader.wg.Wait() + loader.enqueue(&project.Module{ID: id, FilePath: secondPath, Content: "fn helper() {}\n", ContentProvided: true}) + loader.wg.Wait() + + ambiguous := 0 + for _, item := range diag.Diagnostics() { + if item != nil && item.Code == diagnostics.ErrAmbiguousImport { + ambiguous++ + } + } + if ambiguous != 1 { + t.Fatalf("ambiguous-import diagnostics = %d, want 1:\n%s", ambiguous, diag.EmitAllToString()) + } +} + +// An identity conflict reached through an import must name the import that +// caused it, so the reader has somewhere to go. The registry detects the +// conflict; the loader owns the source site. +func TestModuleLoaderLabelsImportSiteOnIdentityConflict(t *testing.T) { + root := t.TempDir() + srcDir := manifest.SourceDir(root) + if err := os.MkdirAll(srcDir, 0o755); err != nil { + t.Fatalf("create source dir: %v", err) + } + sharedPath := filepath.Join(srcDir, "shared"+peeper.SourceExt) + if err := os.WriteFile(sharedPath, []byte("fn Helper() {}\n"), 0o600); err != nil { + t.Fatalf("write shared module: %v", err) + } + + diag := diagnostics.NewDiagnosticBag() + ctx := project.NewWithConfig(project.Config{ + RootDir: root, + ProjectName: "app", + Extension: peeper.SourceExt, + }, diag) + + resolved, err := ctx.ResolveImportPath("app/shared") + if err != nil { + t.Fatalf("resolve import: %v", err) + } + // Claim the identity for a different file so the import below conflicts. + ctx.AddModule(&project.Module{ID: resolved.ID, FilePath: filepath.Join(srcDir, "other"+peeper.SourceExt)}) + + entryPath := filepath.Join(srcDir, "entry"+peeper.SourceExt) + entrySrc := "import \"app/shared\";\n" + diag.AddSourceContent(entryPath, entrySrc) + importer := parseModuleSource(entryPath, entrySrc, diag) + loader := &moduleLoader{ctx: ctx, scheduled: make(map[moduleid.ID]string)} + loader.resolveImports(importer, diag) + loader.wg.Wait() + + for _, item := range diag.Diagnostics() { + if item == nil || item.Code != diagnostics.ErrAmbiguousImport { + continue + } + if len(item.Labels) == 0 || item.Labels[0].Location == nil { + t.Fatalf("ambiguous-import diagnostic carries no primary label:\n%s", diag.EmitAllToString()) + } + if item.FilePath != project.CanonicalPath(entryPath) { + t.Fatalf("diagnostic file = %q, want %q", item.FilePath, project.CanonicalPath(entryPath)) + } + return + } + t.Fatalf("no ambiguous-import diagnostic:\n%s", diag.EmitAllToString()) +} + +func TestModuleLoaderSamePathDoubleEnqueueIsQuietDedupe(t *testing.T) { + root := t.TempDir() + filePath := filepath.Join(root, "shared"+peeper.SourceExt) + diag := diagnostics.NewDiagnosticBag() + ctx := project.New(root, peeper.SourceExt, diag) + loader := &moduleLoader{ + ctx: ctx, + scheduled: make(map[moduleid.ID]string), + } + id := moduleid.ID{Origin: string(project.ModuleOriginLocal), ImportPath: "app/shared"} + + loader.enqueue(&project.Module{ID: id, FilePath: filePath, Content: "fn main() {}\n", ContentProvided: true}) + loader.wg.Wait() + loader.enqueue(&project.Module{ID: id, FilePath: filePath, Content: "fn main() {}\n", ContentProvided: true}) + loader.wg.Wait() + + if diag.HasErrors() { + t.Fatalf("same-path dedupe produced diagnostics:\n%s", diag.EmitAllToString()) + } +} diff --git a/internal/prelude/prelude.go b/internal/prelude/prelude.go index 5fa62100..b3550699 100644 --- a/internal/prelude/prelude.go +++ b/internal/prelude/prelude.go @@ -5,6 +5,7 @@ import ( "os" "path/filepath" + "compiler/internal/moduleid" "compiler/internal/project" "compiler/pkg/manifest" "compiler/pkg/peeper" @@ -13,11 +14,29 @@ import ( // Auto-loaded Peeper prelude file within the stdlib root. const GlobalPreludeFile = "global" + peeper.SourceExt +// Library namespace owning the auto-loaded prelude. +const preludeNamespace = "core" + +// ModuleID derives canonical prelude identity from the resolved prelude file so +// it matches what ResolveImportPath produces for the same source. A hardcoded +// import path would register the file under an identity no import can reproduce. +func ModuleID(ctx *project.CompilerContext) moduleid.ID { + preludePath, ok := globalPreludePath(ctx) + if !ok { + return moduleid.ID{} + } + id, err := ctx.IdentityForFile(project.ModuleOriginStdlib, preludeNamespace, project.CanonicalPath(preludePath)) + if err != nil { + return moduleid.ID{} + } + return id +} + func globalPreludePath(ctx *project.CompilerContext) (string, bool) { if ctx == nil { return "", false } - coreRoot, ok := ctx.LibraryRoot("core") + coreRoot, ok := ctx.LibraryRoot(preludeNamespace) if !ok || coreRoot == "" { return "", false } @@ -26,19 +45,22 @@ func globalPreludePath(ctx *project.CompilerContext) (string, bool) { // ModuleForFile returns the canonical prelude module identity when a file path // points at the auto-loaded global prelude source. Direct-open and overlay -// paths must reuse this exact key/import-path so the same file does not appear -// twice in compiler and LSP caches. +// paths must reuse this exact identity so the same file does not appear twice +// in compiler and LSP caches. func ModuleForFile(ctx *project.CompilerContext, filePath, content string) (*project.Module, bool) { preludePath, ok := globalPreludePath(ctx) if !ok || project.CanonicalPath(preludePath) != project.CanonicalPath(filePath) { return nil, false } + id := ModuleID(ctx) + if !id.Valid() { + // Without derivable identity the module can never register, so reporting + // success here would hand callers a module every path silently drops. + return nil, false + } return &project.Module{ - Key: "core:prelude/global", - ImportPath: "prelude/global", + ID: id, FilePath: preludePath, - Namespace: "core", - Origin: project.ModuleOriginStdlib, Content: content, ContentProvided: true, }, true @@ -60,7 +82,10 @@ func Load(ctx *project.CompilerContext) error { } return fmt.Errorf("load prelude %s: %w", preludePath, err) } - module, _ := ModuleForFile(ctx, preludePath, string(content)) + module, ok := ModuleForFile(ctx, preludePath, string(content)) + if !ok { + return fmt.Errorf("load prelude %s: no derivable module identity", preludePath) + } ctx.AddModule(module) return nil } diff --git a/internal/project/context.go b/internal/project/context.go index 099d14c1..5c1c1b3e 100644 --- a/internal/project/context.go +++ b/internal/project/context.go @@ -11,6 +11,7 @@ import ( "compiler/internal/frontend/ast" "compiler/internal/graph" "compiler/internal/ir" + "compiler/internal/moduleid" "compiler/internal/phase" "compiler/internal/semantics/intrinsics" "compiler/internal/semantics/symbols" @@ -41,12 +42,12 @@ type CompilerContext struct { // Predeclared symbols visible before user/prelude code. GlobalScope *symbols.Scope - // Module key -> module. - modules map[string]*Module - // Canonical file path -> module key. - fileIndex map[string]string + // Canonical module identity -> module. + modules map[moduleid.ID]*Module + // Canonical file path -> module identity. + fileIndex map[string]moduleid.ID // Prior semantic API fingerprints supplied by incremental clients. - semanticExportBaselines map[string]string + semanticExportBaselines map[moduleid.ID]string // Named declaration identity -> collected module declaration index. typeDeclarations map[string]*Module // Concrete semantic application identity -> canonical instance. @@ -170,9 +171,9 @@ func NewWithConfig(cfg Config, diag *diagnostics.DiagnosticBag) *CompilerContext Graph: graph.New(GraphEdgeImport), mu: &sync.RWMutex{}, - modules: make(map[string]*Module), - fileIndex: make(map[string]string), - semanticExportBaselines: make(map[string]string), + modules: make(map[moduleid.ID]*Module), + fileIndex: make(map[string]moduleid.ID), + semanticExportBaselines: make(map[moduleid.ID]string), typeDeclarations: make(map[string]*Module), typeInstances: make(map[string]namedTypeInstance), } @@ -196,7 +197,7 @@ func (ctx *CompilerContext) ResetModule(module *Module, retained phase.Phase) { module.resetToPhase(retained) ctx.mu.Lock() for identity, instance := range ctx.typeInstances { - if instance.ownerModuleKey == module.Key { + if instance.ownerModuleID == module.ID { if !instance.complete && instance.ready != nil { close(instance.ready) } @@ -205,14 +206,14 @@ func (ctx *CompilerContext) ResetModule(module *Module, retained phase.Phase) { } if retained < phase.Collected { for identity, owner := range ctx.typeDeclarations { - if owner != nil && owner.Key == module.Key { + if owner != nil && owner.ID == module.ID { delete(ctx.typeDeclarations, identity) } } } ctx.mu.Unlock() - if ctx.Diagnostics != nil && module.Key != "" { - ctx.Diagnostics.DiscardModuleAfter(module.Key, retained) + if ctx.Diagnostics != nil && module.ID.Valid() { + ctx.Diagnostics.DiscardModuleAfter(module.ID.String(), retained) } } diff --git a/internal/project/context_test.go b/internal/project/context_test.go index 4e0f3a46..9ea3660d 100644 --- a/internal/project/context_test.go +++ b/internal/project/context_test.go @@ -6,6 +6,7 @@ import ( "testing" "compiler/internal/diagnostics" + "compiler/internal/moduleid" "compiler/internal/phase" "compiler/internal/semantics/symbols" "compiler/internal/semantics/typeinfo" @@ -14,13 +15,14 @@ import ( func TestWithDiagnosticsSharesCompilerStateAndLock(t *testing.T) { ctx := New(".", ".peep", diagnostics.NewDiagnosticBag()) - scopedBag := ctx.Diagnostics.BeginPhase(phase.Typechecked, "main") + id := moduleid.ID{Origin: string(ModuleOriginLocal), ImportPath: "main"} + scopedBag := ctx.Diagnostics.BeginPhase(phase.Typechecked, id.String()) scoped := ctx.WithDiagnostics(scopedBag) - module := &Module{Key: "main"} + module := &Module{ID: id} scoped.AddModule(module) scoped.Diagnostics.Add(diagnostics.NewError("typed")) - if got, ok := ctx.ModuleByKey("main"); !ok || got != module { + if got, ok := ctx.ModuleByID(id); !ok || got != module { t.Fatal("scoped context did not share module index") } if got := ctx.Diagnostics.Diagnostics(); len(got) != 1 || got[0].Message != "typed" { diff --git a/internal/project/export_fingerprint.go b/internal/project/export_fingerprint.go index dcb550ab..5c0106ce 100644 --- a/internal/project/export_fingerprint.go +++ b/internal/project/export_fingerprint.go @@ -11,7 +11,9 @@ import ( ) // SemanticExportFingerprint identifies compiler-visible semantic facts exported by module. -func SemanticExportFingerprint(module *Module) string { +// Constant values resolve through their defining module, so an exported default that +// references an imported constant still changes when that constant changes. +func SemanticExportFingerprint(ctx *CompilerContext, module *Module) string { if module == nil || module.ModuleScope == nil { return ast.FingerprintParts(nil) } @@ -24,27 +26,27 @@ func SemanticExportFingerprint(module *Module) string { if sym.Kind == symbols.SymbolVar { part += fmt.Sprintf(":mutable=%t", sym.IsMutable()) } - part += semanticExportMetadata(module, sym) - if sym.Kind == symbols.SymbolConst && module.Semantics != nil { - part += ":value=" + constantKey(module.Semantics.ConstValues[sym.ID]) + part += semanticExportMetadata(ctx, module, sym) + if sym.Kind == symbols.SymbolConst { + part += ":value=" + constantKey(ctx.PublishedConstant(module, sym)) } parts = append(parts, part) } - if module.Semantics != nil { - for receiver, methods := range module.Semantics.MethodSets { + if module.Bindings != nil { + for receiver, methods := range module.Bindings.MethodsByReceiver { for _, method := range methods { if method == nil || !method.IsPub { continue } parts = append(parts, "method:"+receiver+":"+method.Name+":"+ - semanticTypeKey(method.Type, make(map[typeinfo.Type]bool))+semanticExportMetadata(module, method)) + semanticTypeKey(method.Type, make(map[typeinfo.Type]bool))+semanticExportMetadata(ctx, module, method)) } } } return ast.FingerprintParts(parts) } -func semanticExportMetadata(module *Module, sym *symbols.Symbol) string { +func semanticExportMetadata(ctx *CompilerContext, module *Module, sym *symbols.Symbol) string { decl, ok := sym.ASTNode.(ast.Decl) if !ok || decl == nil { return "" @@ -76,16 +78,16 @@ func semanticExportMetadata(module *Module, sym *symbols.Symbol) string { facts := make([]string, 0) ast.Inspect(param.Default, func(node ast.Node) bool { ident, ok := node.(*ast.Ident) - if !ok || ident == nil || module.Semantics == nil { + if !ok || ident == nil || module.Bindings == nil { return true } - resolved := module.Semantics.ResolvedSymbols[ident.ID()] + resolved := module.Bindings.NodeSymbols[ident.ID()] if resolved == nil { return true } fact := resolved.Name + ":" + semanticTypeKey(resolved.Type, make(map[typeinfo.Type]bool)) if resolved.Kind == symbols.SymbolConst { - fact += "=" + constantKey(module.Semantics.ConstValues[resolved.ID]) + fact += "=" + constantKey(ctx.PublishedConstant(module, resolved)) } facts = append(facts, fact) return true @@ -95,18 +97,17 @@ func semanticExportMetadata(module *Module, sym *symbols.Symbol) string { return metadata } -func semanticTypeKey(typ symbols.Type, visiting map[typeinfo.Type]bool) string { - semantic, ok := typ.(typeinfo.Type) - if !ok || semantic == nil { +func semanticTypeKey(typ typeinfo.Type, visiting map[typeinfo.Type]bool) string { + if typ == nil { return "" } - if visiting[semantic] { - return "recursive(" + typeinfo.TypeText(semantic) + ")" + if visiting[typ] { + return "recursive(" + typeinfo.TypeText(typ) + ")" } - visiting[semantic] = true - defer delete(visiting, semantic) + visiting[typ] = true + defer delete(visiting, typ) - switch node := semantic.(type) { + switch node := typ.(type) { case *typeinfo.DefinedType: parameters := make([]string, len(node.TypeParameters)) for index, parameter := range node.TypeParameters { @@ -164,9 +165,9 @@ func semanticTypeKey(typ symbols.Type, visiting map[typeinfo.Type]bool) string { *typeinfo.ByteType, *typeinfo.CharType, *typeinfo.FloatType, *typeinfo.BoolType, *typeinfo.CStrType, *typeinfo.StringType, *typeinfo.NoneType, *typeinfo.AllocatorType, *typeinfo.NamedType, *typeinfo.RawPtrType: - return typeinfo.TypeText(semantic) + return typeinfo.TypeText(typ) default: - panic(fmt.Sprintf("export fingerprint: unhandled semantic type %T", semantic)) + panic(fmt.Sprintf("export fingerprint: unhandled semantic type %T", typ)) } } diff --git a/internal/project/export_fingerprint_test.go b/internal/project/export_fingerprint_test.go index be18ffa7..18c56ffb 100644 --- a/internal/project/export_fingerprint_test.go +++ b/internal/project/export_fingerprint_test.go @@ -5,25 +5,32 @@ import ( "compiler/internal/constvalue" "compiler/internal/frontend/ast" + "compiler/internal/moduleid" + "compiler/internal/semantics/bindingresult" + "compiler/internal/semantics/constantresult" "compiler/internal/semantics/symbols" "compiler/internal/semantics/typeinfo" ) -type unexpectedSemanticType struct{} - -func (*unexpectedSemanticType) TypeNode() {} -func (*unexpectedSemanticType) Text() string { return "unexpected" } - -func fingerprintModule(t *testing.T, exported *symbols.Symbol, semantics *SemanticInfo) *Module { +func fingerprintModule( + t *testing.T, + exported *symbols.Symbol, + bindings *bindingresult.Result, + constValues map[symbols.SymbolID]constvalue.Value, +) *Module { t.Helper() scope := symbols.NewScope(nil) if err := scope.Declare(exported); err != nil { t.Fatalf("declare export: %v", err) } - if semantics == nil { - semantics = NewSemanticInfo() + if bindings == nil { + bindings = bindingresult.New() + } + constants := constantresult.New() + if constValues != nil { + constants.ModuleValues = constValues } - return &Module{ModuleScope: scope, Semantics: semantics} + return &Module{ModuleScope: scope, Bindings: bindings, Constants: constants} } func TestSemanticExportFingerprintChangesWithInferredTypeAndValue(t *testing.T) { @@ -32,9 +39,9 @@ func TestSemanticExportFingerprintChangesWithInferredTypeAndValue(t *testing.T) decl.SetDeclSurface("const:Value::number") sym := symbols.New("Value", symbols.SymbolConst, decl, nil) sym.Type = typ - semantic := NewSemanticInfo() - semantic.ConstValues[sym.ID], _ = constvalue.NewIntText(value, typeinfo.TypeText(typ)) - return SemanticExportFingerprint(fingerprintModule(t, sym, semantic)) + constValues := make(map[symbols.SymbolID]constvalue.Value) + constValues[sym.ID], _ = constvalue.NewIntText(value, typeinfo.TypeText(typ)) + return SemanticExportFingerprint(nil, fingerprintModule(t, sym, nil, constValues)) } i32One := makeConst(&typeinfo.IntegerType{Signed: true, Bits: 32}, "1") @@ -48,13 +55,53 @@ func TestSemanticExportFingerprintChangesWithInferredTypeAndValue(t *testing.T) } } +func TestSemanticExportFingerprintIncludesConstValueWithoutBindings(t *testing.T) { + fingerprint := func(value string) string { + decl := &ast.ConstDecl{Name: &ast.Ident{Name: "Value"}} + decl.SetDeclSurface("const:Value::number") + sym := symbols.New("Value", symbols.SymbolConst, decl, nil) + sym.Type = &typeinfo.IntegerType{Signed: true, Bits: 32} + scope := symbols.NewScope(nil) + if err := scope.Declare(sym); err != nil { + t.Fatalf("declare export: %v", err) + } + constant, _ := constvalue.NewIntText(value, "i32") + constants := constantresult.New() + constants.ModuleValues[sym.ID] = constant + return SemanticExportFingerprint(nil, &Module{ModuleScope: scope, Constants: constants}) + } + if fingerprint("1") == fingerprint("2") { + t.Fatal("binding-independent const value did not change semantic fingerprint") + } +} + +func TestSemanticExportFingerprintIgnoresQueryCache(t *testing.T) { + fingerprint := func(value string) string { + decl := &ast.ConstDecl{Name: &ast.Ident{Name: "Value"}} + decl.SetDeclSurface("const:Value::number") + sym := symbols.New("Value", symbols.SymbolConst, decl, nil) + sym.Type = &typeinfo.IntegerType{Signed: true, Bits: 32} + scope := symbols.NewScope(nil) + if err := scope.Declare(sym); err != nil { + t.Fatalf("declare export: %v", err) + } + constant, _ := constvalue.NewIntText(value, "i32") + constants := constantresult.New() + constants.QueryCache[sym.ID] = constant + return SemanticExportFingerprint(nil, &Module{ModuleScope: scope, Constants: constants}) + } + if fingerprint("1") != fingerprint("2") { + t.Fatal("query-cache-only value changed semantic fingerprint") + } +} + func TestSemanticExportFingerprintIgnoresFunctionBodyChanges(t *testing.T) { makeFunction := func(body *ast.BlockStmt) string { decl := &ast.FnDecl{Name: &ast.Ident{Name: "Read"}, Body: body} decl.SetDeclSurface("fn::Read:::") sym := symbols.New("Read", symbols.SymbolFunc, decl, nil) sym.Type = &typeinfo.FuncType{Return: &typeinfo.IntegerType{Signed: true, Bits: 32}} - return SemanticExportFingerprint(fingerprintModule(t, sym, nil)) + return SemanticExportFingerprint(nil, fingerprintModule(t, sym, nil, nil)) } first := makeFunction(&ast.BlockStmt{}) second := makeFunction(&ast.BlockStmt{Stmts: []ast.Stmt{&ast.ReturnStmt{Value: &ast.NumberLit{Value: "1"}}}}) @@ -76,24 +123,61 @@ func TestSemanticExportFingerprintIncludesPrivateFactsUsedByPublicDefault(t *tes fn.Type = &typeinfo.FuncType{Params: []typeinfo.Type{i32}, ParamNames: []string{"value"}} private := symbols.New("limit", symbols.SymbolConst, nil, nil) private.Type = i32 - semantic := NewSemanticInfo() - semantic.ResolvedSymbols[defaultIdent.ID()] = private - semantic.ConstValues[private.ID], _ = constvalue.NewIntText(value, "i32") - return SemanticExportFingerprint(fingerprintModule(t, fn, semantic)) + bindings := bindingresult.New() + bindings.NodeSymbols[defaultIdent.ID()] = private + constValues := make(map[symbols.SymbolID]constvalue.Value) + constValues[private.ID], _ = constvalue.NewIntText(value, "i32") + return SemanticExportFingerprint(nil, fingerprintModule(t, fn, bindings, constValues)) } if makeFunction("1") == makeFunction("2") { t.Fatal("private const used by public default did not change fingerprint") } } +func TestSemanticExportFingerprintTracksImportedConstantInDefault(t *testing.T) { + // An exported default referencing an imported constant must still change when + // that constant changes. Foreign values live only in the owning module, so the + // fingerprint has to resolve them through the defining identity. + makeFingerprint := func(value string) string { + ctx := New(".", ".peep", nil) + i32 := &typeinfo.IntegerType{Signed: true, Bits: 32} + ownerID := moduleid.ID{Origin: string(ModuleOriginLocal), ImportPath: "lib"} + imported := symbols.New("K", symbols.SymbolConst, nil, nil) + imported.Type = i32 + imported.DefiningModule = ownerID + ownerConstants := constantresult.New() + ownerConstants.ModuleValues[imported.ID], _ = constvalue.NewIntText(value, "i32") + ctx.AddModule(&Module{ID: ownerID, FilePath: "lib.peep", Constants: ownerConstants}) + + defaultIdent := &ast.Ident{Name: "K"} + decl := &ast.FnDecl{ + Name: &ast.Ident{Name: "Read"}, + Params: []ast.Param{{Name: &ast.Ident{Name: "value"}, Default: defaultIdent}}, + } + decl.SetDeclSurface("fn::Read::value:i32=K:") + fn := symbols.New("Read", symbols.SymbolFunc, decl, nil) + fn.Type = &typeinfo.FuncType{Params: []typeinfo.Type{i32}, ParamNames: []string{"value"}} + bindings := bindingresult.New() + bindings.NodeSymbols[defaultIdent.ID()] = imported + + consumer := fingerprintModule(t, fn, bindings, nil) + consumer.ID = moduleid.ID{Origin: string(ModuleOriginLocal), ImportPath: "app"} + consumer.FilePath = "app.peep" + return SemanticExportFingerprint(ctx, consumer) + } + if makeFingerprint("1") == makeFingerprint("2") { + t.Fatal("imported const used by public default did not change fingerprint") + } +} + func TestSemanticExportFingerprintChangesWithPublicMethodSignature(t *testing.T) { makeMethod := func(returnType typeinfo.Type) string { method := symbols.New("Read", symbols.SymbolMethod, nil, nil) method.Type = &typeinfo.FuncType{Return: returnType} - semantics := NewSemanticInfo() - semantics.MethodSets["Buffer"] = []*symbols.Symbol{method} - return SemanticExportFingerprint(fingerprintModule(t, - symbols.New("Buffer", symbols.SymbolType, nil, nil), semantics)) + bindings := bindingresult.New() + bindings.MethodsByReceiver["Buffer"] = []*symbols.Symbol{method} + return SemanticExportFingerprint(nil, fingerprintModule(t, + symbols.New("Buffer", symbols.SymbolType, nil, nil), bindings, nil)) } i32 := &typeinfo.IntegerType{Signed: true, Bits: 32} i64 := &typeinfo.IntegerType{Signed: true, Bits: 64} @@ -113,7 +197,7 @@ func TestSemanticExportFingerprintHandlesRecursiveTypesDeterministically(t *test decl.SetDeclSurface("type:Node:recursive") sym := symbols.New("Node", symbols.SymbolType, decl, nil) sym.Type = defined - return SemanticExportFingerprint(fingerprintModule(t, sym, nil)) + return SemanticExportFingerprint(nil, fingerprintModule(t, sym, nil, nil)) } if first, second := makeType(), makeType(); first == "" || first != second { t.Fatalf("recursive fingerprints unstable: %q, %q", first, second) @@ -178,15 +262,6 @@ func TestSemanticTypeKeyIncludesNamedEnumIdentity(t *testing.T) { } } -func TestSemanticTypeKeyRejectsUnknownType(t *testing.T) { - defer func() { - if recover() == nil { - t.Fatal("semanticTypeKey accepted unknown semantic type") - } - }() - semanticTypeKey(&unexpectedSemanticType{}, make(map[typeinfo.Type]bool)) -} - func TestConstantKeyIncludesTypeAndValue(t *testing.T) { i32, _ := constvalue.NewIntText("1", "i32") i32Two, _ := constvalue.NewIntText("2", "i32") diff --git a/internal/project/generic_types.go b/internal/project/generic_types.go index c37e571e..5dc8a7dd 100644 --- a/internal/project/generic_types.go +++ b/internal/project/generic_types.go @@ -6,6 +6,7 @@ import ( "compiler/internal/diagnostics" "compiler/internal/frontend/ast" + "compiler/internal/moduleid" "compiler/internal/semantics/typeinfo" ) @@ -15,10 +16,10 @@ type namedTypeDeclaration struct { } type namedTypeInstance struct { - ownerModuleKey string - typ *typeinfo.DefinedType - ready chan struct{} - complete bool + ownerModuleID moduleid.ID + typ *typeinfo.DefinedType + ready chan struct{} + complete bool } type typeInstantiationFrame struct { @@ -136,9 +137,9 @@ func (ctx *CompilerContext) instantiateType(base *typeinfo.DefinedType, argument // Cache provisional shell before substitution. Recursive pointer/reference // applications resolve back to this exact object. ctx.typeInstances[identity] = namedTypeInstance{ - ownerModuleKey: declarationModule.Key, - typ: instance, - ready: make(chan struct{}), + ownerModuleID: declarationModule.ID, + typ: instance, + ready: make(chan struct{}), } ctx.mu.Unlock() diff --git a/internal/project/imports.go b/internal/project/imports.go index 34895083..bb7186bb 100644 --- a/internal/project/imports.go +++ b/internal/project/imports.go @@ -9,26 +9,19 @@ import ( "compiler/internal/diagnostics" "compiler/internal/frontend/ast" + "compiler/internal/moduleid" "compiler/pkg/manifest" "compiler/pkg/remotes" ) // Canonical file-backed import after resolver lookup. type ResolvedImport struct { - // Stable graph identity. - Key string - // Module path as written in source. - ImportPath string + // Canonical imported module identity. + ID moduleid.ID // Source import declaration, when resolved from parsed syntax. Decl *ast.ImportDecl // Absolute slash-separated source path. FilePath string - // Local, stdlib, or dependency. - Origin ModuleOrigin - // Optional namespace for packaged libraries such as core/vendor. - Namespace string - // Manifest alias for dependency imports. - DependencyAlias string } // ImportCandidate is one source-level import path visible from a compiler @@ -52,18 +45,6 @@ func (e *ImportError) Error() string { return e.Msg } -// ModuleKeyFor builds a stable module key for a file path and origin. -func ModuleKeyFor(origin ModuleOrigin, filePath string) string { - if filePath == "" { - return "" - } - prefix := string(origin) - if prefix == "" { - prefix = string(ModuleOriginLocal) - } - return prefix + ":" + CanonicalPath(filePath) -} - // ImportCandidates returns immediate import paths matching prefix. Import root // selection stays beside resolution so editor features cannot invent different // project, namespace, source-directory, or extension rules. @@ -148,7 +129,7 @@ func (ctx *CompilerContext) importCandidateRoot(prefix string) (root, sourcePref } func hasHiddenImportSegment(path string) bool { - for segment := range strings.SplitSeq(path, "/") { + for _, segment := range strings.Split(path, "/") { if strings.HasPrefix(segment, ".") { return true } @@ -328,18 +309,12 @@ func (ctx *CompilerContext) ResolveImportPath(rawPath string) (*ResolvedImport, } absPath = CanonicalPath(absPath) - resolvedImportPath, err := ctx.ImportPathForFile(origin, namespace, absPath) + id, err := ctx.IdentityForFile(origin, namespace, absPath) if err != nil { return nil, &ImportError{Code: diagnostics.ErrInvalidImportPath, Msg: err.Error()} } - return &ResolvedImport{ - Key: ModuleKeyFor(origin, absPath), - ImportPath: resolvedImportPath, - FilePath: absPath, - Origin: origin, - Namespace: namespace, - }, nil + return &ResolvedImport{ID: id, FilePath: absPath}, nil } func splitNamespacedImportPath(importPath string) (string, string, bool) { @@ -365,8 +340,7 @@ func validateImportPath(importPath string) error { if filepath.IsAbs(importPath) || strings.HasPrefix(importPath, "./") || strings.HasPrefix(importPath, "../") { return fmt.Errorf("import path must be root-relative") } - parts := strings.SplitSeq(importPath, "/") - for part := range parts { + for _, part := range strings.Split(importPath, "/") { if part == "" || part == "." || part == ".." { return fmt.Errorf("import path must be root-relative") } diff --git a/internal/project/imports_test.go b/internal/project/imports_test.go index b709c5f6..0f7f8ef2 100644 --- a/internal/project/imports_test.go +++ b/internal/project/imports_test.go @@ -5,6 +5,7 @@ import ( "path/filepath" "testing" + "compiler/internal/moduleid" "compiler/pkg/manifest" "compiler/pkg/peeper" ) @@ -30,15 +31,13 @@ func TestResolveImportPathUsesLibraryNamespaceRoots(t *testing.T) { if err != nil { t.Fatalf("ResolveImportPath() error = %v", err) } - if resolved.Namespace != "vendor" { - t.Fatalf("resolved namespace = %q, want %q", resolved.Namespace, "vendor") + wantID := moduleid.ID{Origin: string(ModuleOriginStdlib), Namespace: "vendor", ImportPath: "json"} + if resolved.ID != wantID { + t.Fatalf("resolved ID = %#v, want %#v", resolved.ID, wantID) } if want := CanonicalPath(libraryFile); resolved.FilePath != want { t.Fatalf("resolved file path = %q, want %q", resolved.FilePath, want) } - if resolved.ImportPath != "json" { - t.Fatalf("resolved import path = %q, want %q", resolved.ImportPath, "json") - } } func TestResolveImportPathRequiresProjectConfigForLocalImports(t *testing.T) { @@ -77,12 +76,13 @@ func TestResolveImportPathStripsProjectPrefix(t *testing.T) { if err != nil { t.Fatalf("ResolveImportPath() error = %v", err) } + wantID := moduleid.ID{Origin: string(ModuleOriginLocal), ImportPath: "app/util"} + if resolved.ID != wantID { + t.Fatalf("resolved ID = %#v, want %#v", resolved.ID, wantID) + } if want := CanonicalPath(utilPath); resolved.FilePath != want { t.Fatalf("resolved file path = %q, want %q", resolved.FilePath, want) } - if resolved.ImportPath != "app/util" { - t.Fatalf("resolved import path = %q, want %q", resolved.ImportPath, "app/util") - } } func TestImportCandidatesEnumeratesRootsAndImmediateChildren(t *testing.T) { diff --git a/internal/project/modules.go b/internal/project/modules.go index ea448c8f..276e5adb 100644 --- a/internal/project/modules.go +++ b/internal/project/modules.go @@ -1,20 +1,27 @@ package project import ( + "fmt" "path/filepath" "strings" "compiler/internal/constvalue" + "compiler/internal/diagnostics" "compiler/internal/frontend/ast" "compiler/internal/graph" "compiler/internal/ir/cfg" "compiler/internal/ir/hir" "compiler/internal/ir/mir" + "compiler/internal/moduleid" "compiler/internal/phase" + "compiler/internal/semantics/bindingresult" + "compiler/internal/semantics/constantresult" + "compiler/internal/semantics/effect" "compiler/internal/semantics/flowresult" - "compiler/internal/semantics/intrinsics" "compiler/internal/semantics/ownershipresult" + "compiler/internal/semantics/place" "compiler/internal/semantics/symbols" + "compiler/internal/semantics/typecheckresult" "compiler/internal/semantics/typeinfo" ) @@ -34,20 +41,12 @@ const GraphEdgeImport graph.EdgeKind = "import" // Source unit shared by every compiler phase. type Module struct { - // Unique graph identity. - Key string - // Module path used by imports. - ImportPath string + // Canonical semantic, import, graph, and ownership identity. + ID moduleid.ID // Absolute slash-separated source path. FilePath string - // Optional namespace for packaged libraries such as core/vendor. - Namespace string // User-selected entry module. IsEntry bool - // Local, stdlib, or dependency. - Origin ModuleOrigin - // Dependency alias, when any. - Dependency string // Loaded source text. Content string // ContentProvided distinguishes an explicit empty source from a module that @@ -65,12 +64,15 @@ type Module struct { Phase phase.Phase // Parsed syntax tree. AST *ast.Module - // TypedASTNodes indexes final AST after semantic expansion. + // TypedASTNodes indexes source and typechecker-generated expressions. TypedASTNodes map[ast.NodeID]ast.Node // Canonical IR slots. - HIR *hir.Module - CFG *cfg.Module - Flow *flowresult.Result + HIR *hir.Module + CFG *cfg.Module + Flow *flowresult.Result + // Effects is the published semantic meaning of each CFG site, produced once + // and consumed by the dataflow analyses. + Effects effect.Result Ownership ownershipresult.Result MIR *mir.Module LLVMIR string @@ -79,163 +81,72 @@ type Module struct { // Generic declaration syntax and semantic shells produced by collection. // Fresh incremental contexts reindex this immutable phase artifact. namedTypeDeclarations map[string]namedTypeDeclaration - // Grouped semantic analysis metadata. - Semantics *SemanticInfo + // Staged symbol/scope graph for current semantic generation. + Bindings *bindingresult.Result + // Constant-evaluation artifacts for current semantic generation. + Constants *constantresult.Result + // Base typechecker result for current semantic generation. + Typechecking *typecheckresult.Result // Import alias -> resolved module import. Imports map[string]ResolvedImport } -type SemanticInfo struct { - BlockScopes map[ast.NodeID]*symbols.Scope - ResolvedSymbols map[ast.NodeID]*symbols.Symbol - // ExpandedDefaultBindings marks cloned NodeIDs injected by - // call-site default expansion. These idents must resolve - // through the declaration module's ResolvedSymbols instead of - // caller scope. The Binding.Local gate prevents pointer-escape - // misclassification. - ExpandedDefaultBindings map[ast.NodeID]struct{} - ExprTypes map[ast.NodeID]typeinfo.Type - CaseTests map[ast.NodeID]flowresult.CaseTest - Matches map[ast.NodeID]flowresult.Match - ConstValues map[symbols.SymbolID]constvalue.Value - MethodSets map[string][]*symbols.Symbol - MethodSymbol map[ast.NodeID]*symbols.Symbol - InterfaceImplementations map[ast.NodeID][]InterfaceImplementation - // ImplicitConversions is typechecker proof consumed directly by HIR. - ImplicitConversions map[ast.NodeID]typeinfo.Conversion - ImplicitCallArguments map[ast.NodeID]typeinfo.Type - CompilerCalls map[ast.NodeID]CompilerCall - StringConcatenations map[ast.NodeID]struct{} - VariantConstructions map[ast.NodeID]VariantConstruction - ForIterations map[ast.NodeID]ForIteration - OperationFunctions []*symbols.Symbol -} - -// VariantConstruction is typechecker proof consumed by HIR without resolving -// source paths or revalidating constructor fields. -type VariantConstruction struct { - EnumType typeinfo.Type - Case int - Payload typeinfo.Type - Value ast.Expr -} - -type ForIterationKind uint8 - -const ( - ForIterationRange ForIterationKind = iota - ForIterationSequence -) - -// ForIteration is typechecker-owned evidence consumed by HIR lowering. -// Generated symbols carry hidden loop state; source bindings remain body-scoped. -type ForIteration struct { - Kind ForIterationKind - GuaranteedEntry bool - - ElementType typeinfo.Type - CarrierType typeinfo.Type - Carrier *symbols.Symbol - Cursor *symbols.Symbol - End *symbols.Symbol - Ordinal *symbols.Symbol - Index *symbols.Symbol - Value *symbols.Symbol -} - -// CompilerCall is typechecker-owned dispatch evidence consumed by HIR. -type CompilerCall struct { - Operation symbols.CompilerOp - Kind intrinsics.FunctionKind -} - -// InterfaceImplementation is typechecker proof that one declared method can -// materialize an interface slot. HIR consumes this proof without resolving the -// concrete method set again. -type InterfaceImplementation struct { - MethodName string - Symbol *symbols.Symbol - CallableType *typeinfo.FuncType - OwnerKey string -} - -func (m *Module) DefiningModuleKey() symbols.DefiningModuleKey { - if m == nil { - return symbols.DefiningModuleKey{} - } - return symbols.DefiningModuleKey{ - Origin: string(m.Origin), - Namespace: m.Namespace, - Dependency: m.Dependency, - ImportPath: m.ImportPath, - } -} - // TypeDeclarationIdentity anchors nominal type identity at its declaring module. func (m *Module) TypeDeclarationIdentity(name string) string { - if m == nil || m.Key == "" || name == "" { + if m == nil || !m.ID.Valid() || name == "" { return name } - return m.Key + "::" + name + return m.ID.String() + "::" + name } -func NewSemanticInfo() *SemanticInfo { - return &SemanticInfo{ - BlockScopes: make(map[ast.NodeID]*symbols.Scope), - ResolvedSymbols: make(map[ast.NodeID]*symbols.Symbol), - ExpandedDefaultBindings: make(map[ast.NodeID]struct{}), - ExprTypes: make(map[ast.NodeID]typeinfo.Type), - CaseTests: make(map[ast.NodeID]flowresult.CaseTest), - Matches: make(map[ast.NodeID]flowresult.Match), - ConstValues: make(map[symbols.SymbolID]constvalue.Value), - MethodSets: make(map[string][]*symbols.Symbol), - MethodSymbol: make(map[ast.NodeID]*symbols.Symbol), - InterfaceImplementations: make(map[ast.NodeID][]InterfaceImplementation), - ImplicitConversions: make(map[ast.NodeID]typeinfo.Conversion), - ImplicitCallArguments: make(map[ast.NodeID]typeinfo.Type), - CompilerCalls: make(map[ast.NodeID]CompilerCall), - StringConcatenations: make(map[ast.NodeID]struct{}), - VariantConstructions: make(map[ast.NodeID]VariantConstruction), - ForIterations: make(map[ast.NodeID]ForIteration), - OperationFunctions: make([]*symbols.Symbol, 0), +// ExpandedDefaultBinding resolves declaration-module symbols paired with generated +// default-expression markers. Local remains false for caller escape analysis. +func (m *Module) ExpandedDefaultBinding(ident *ast.Ident) (place.Binding, bool) { + if m == nil || m.Bindings == nil || m.Typechecking == nil || ident == nil { + return place.Binding{}, false } -} - -// MatchCases exposes resolved case indexes without leaking match artifacts -// into CFG's source-topology package. -// ForLoopGuaranteedEntry exposes typechecker proof that one loop executes its -// body before its first condition check. -func (s *SemanticInfo) ForLoopGuaranteedEntry(id ast.NodeID) bool { - if s == nil { - return false + if _, ok := m.Typechecking.ExpandedDefaultBindings[ident.ID()]; !ok { + return place.Binding{}, false } - iteration, found := s.ForIterations[id] - return found && iteration.GuaranteedEntry + return place.Binding{Symbol: m.Bindings.NodeSymbols[ident.ID()]}, true } -func (s *SemanticInfo) MatchCases(id ast.NodeID) ([]int, bool) { - if s == nil { - return nil, false +// RebuildTypedASTIndex publishes canonical node lookup after typechecking. +func (m *Module) RebuildTypedASTIndex() { + if m == nil { + return } - match, found := s.Matches[id] - if !found { - return nil, false + m.TypedASTNodes = ast.Index(m.AST) + if m.Typechecking == nil { + return } - cases := make([]int, len(match.Arms)) - for index, arm := range match.Arms { - if arm.Case < 0 || arm.Case >= len(match.Cases) { - return nil, false + 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 + }) } - cases[index] = arm.Case } - return cases, true } func (m *Module) ResetSemanticData() { if m == nil { return } - m.Semantics = NewSemanticInfo() + m.Bindings = bindingresult.New() + m.Constants = constantresult.New() + m.Typechecking = nil +} + +// BaseExprType returns canonical base typechecker evidence when available. +func (m *Module) BaseExprType(id ast.NodeID) typeinfo.Type { + if m == nil || m.Typechecking == nil { + return nil + } + return m.Typechecking.ExprTypes[id] } // EffectiveExprType returns per-use flow refinement when available and falls @@ -249,10 +160,7 @@ func (m *Module) EffectiveExprType(id ast.NodeID) typeinfo.Type { return typ } } - if m.Semantics == nil { - return nil - } - return m.Semantics.ExprTypes[id] + return m.BaseExprType(id) } // resetToPhase retains artifacts through phase and invalidates downstream data. @@ -263,12 +171,14 @@ func (m *Module) resetToPhase(retained phase.Phase) { m.Phase = retained if retained <= phase.Parsed { m.ModuleScope = nil - m.Semantics = nil + m.Bindings = nil + m.Constants = nil } if retained < phase.Collected { m.namedTypeDeclarations = nil } if retained < phase.Typechecked { + m.Typechecking = nil m.SemanticExportFingerprint = "" m.TypedASTNodes = nil } @@ -278,6 +188,9 @@ func (m *Module) resetToPhase(retained phase.Phase) { if retained < phase.FlowTyped { m.Flow = nil } + if retained < phase.Effects { + m.Effects = nil + } if retained < phase.Ownership { m.Ownership = nil } @@ -316,76 +229,166 @@ func PathWithinRoot(rootPath, path string) bool { return strings.HasPrefix(path, rootPath+"/") } -// NewModuleForFile builds one file-backed module with canonical origin, -// namespace, key, and import path derived from compiler config. +// IdentityForFile assembles canonical module identity for a file. Every producer +// of a moduleid.ID goes through here so origin, namespace and derived import path +// cannot drift apart. Assembling identity separately is what let the prelude +// register under an import path no import could reproduce. +func (ctx *CompilerContext) IdentityForFile(origin ModuleOrigin, namespace, filePath string) (moduleid.ID, error) { + importPath, err := ctx.ImportPathForFile(origin, namespace, filePath) + if err != nil { + return moduleid.ID{}, err + } + return moduleid.ID{ + Origin: string(origin), + Namespace: namespace, + ImportPath: importPath, + }, nil +} + +// NewModuleForFile builds one file-backed module with canonical identity derived from compiler config. func (ctx *CompilerContext) NewModuleForFile(filePath, content string) *Module { if ctx == nil || filePath == "" { return nil } origin, namespace := ctx.ModuleOriginForFile(filePath) - module := &Module{ - Key: ModuleKeyFor(origin, filePath), + id, err := ctx.IdentityForFile(origin, namespace, filePath) + if err != nil { + return nil + } + return &Module{ + ID: id, FilePath: filePath, - Namespace: namespace, - Origin: origin, Content: content, ContentProvided: true, } - if importPath, err := ctx.ImportPathForFile(origin, namespace, filePath); err == nil { - module.ImportPath = importPath - } - return module } -// Register a module in shared compiler state. -func (ctx *CompilerContext) AddModule(module *Module) { - if ctx == nil || module == nil || module.Key == "" { - return +// Register a module in shared compiler state. The identity conflict is detected +// and reported here so registration policy stays in one place; the recorded +// diagnostic is returned so a caller holding the offending source site can label +// it. Returns nil when the module registers cleanly. +func (ctx *CompilerContext) AddModule(module *Module) *diagnostics.Diagnostic { + if ctx == nil || module == nil || !module.ID.Valid() { + return nil } module.FilePath = CanonicalPath(module.FilePath) ctx.mu.Lock() - defer ctx.mu.Unlock() - ctx.modules[module.Key] = module - if module.FilePath != "" { - ctx.fileIndex[module.FilePath] = module.Key + // Identity and file must agree in both directions, and both checks run + // before any index is touched so a rejected registration cannot leave the + // registry half-updated. Import paths and library-root configuration can + // both reach these, so they are user-facing diagnostics, not compiler bugs. + conflict := "" + if previousID, found := ctx.fileIndex[module.FilePath]; module.FilePath != "" && found && previousID != module.ID { + conflict = fmt.Sprintf("module file %s is already registered as %s and cannot also be %s", + module.FilePath, previousID.ImportPath, module.ID.ImportPath) + } else if previous := ctx.modules[module.ID]; previous != nil && module.FilePath != "" && + previous.FilePath != "" && previous.FilePath != module.FilePath { + conflict = fmt.Sprintf("module identity %s is already registered for file %s and cannot also name %s", + module.ID.ImportPath, previous.FilePath, module.FilePath) + } + if conflict != "" { + ctx.mu.Unlock() + if ctx.Diagnostics == nil { + return nil + } + // Reported without a location: the registry knows the identities in + // conflict, not which source site caused the registration. A caller + // holding that site labels the returned diagnostic. + return ctx.Diagnostics.AddError(diagnostics.ErrAmbiguousImport, conflict, nil, "") } + previous := ctx.modules[module.ID] + ctx.modules[module.ID] = module + if module.FilePath != "" { + ctx.fileIndex[module.FilePath] = module.ID + } else if previous != nil && previous.FilePath != "" && ctx.fileIndex[previous.FilePath] == module.ID { + // A pathless replacement must not leave the old file pointing at the + // identity it no longer names. + delete(ctx.fileIndex, previous.FilePath) + } + // ctx.typeDeclarations is derived state: the authoritative copy of a generic + // declaration lives on the module that collection registered it from. A + // context therefore has to rebuild the index for every module it takes in, + // and registration is the only place that happens. + // + // The LSP is what makes this load-bearing. Each request builds a fresh + // context and re-registers modules retained from the previous run, so the + // index starts empty while the retained modules still carry declarations + // collection will not produce again. Without this, instantiateType finds no + // owner and tells the user to recompile a module that is perfectly fine. + // Replacing a module for an identity needs it too, since the index holds + // pointers and would otherwise keep naming the superseded object. + // + // Below Collected the map is nil, so the phase check states the rule rather + // than changing the outcome: only a collected module owns declarations. Its + // mirror is in ResetModule, which drops these entries when a module resets + // below Collected. if module.Phase >= phase.Collected { for identity := range module.namedTypeDeclarations { ctx.typeDeclarations[identity] = module } } + ctx.mu.Unlock() + return nil +} + +// PublishedConstant returns the authoritative value of a constant symbol, +// resolving symbols owned by another module through their defining identity, and +// nil when no value is published. Constant evaluation never publishes a nil value, +// so nil is the absent case and no separate found flag is needed. +// Query-cache entries are excluded on purpose: only published module values are +// stable enough for cross-module reads and export fingerprints. +func (ctx *CompilerContext) PublishedConstant(module *Module, sym *symbols.Symbol) constvalue.Value { + if sym == nil { + return nil + } + owner := module + // Only the cross-module hop needs a context; a local value stays readable + // from the module alone so callers without a registry still fingerprint. + if ownerID := sym.DefiningModule; ownerID.Valid() && (module == nil || ownerID != module.ID) { + if ctx == nil { + return nil + } + found := false + if owner, found = ctx.ModuleByID(ownerID); !found { + return nil + } + } + if owner == nil || owner.Constants == nil { + return nil + } + return owner.Constants.ModuleValues[sym.ID] } -// Lookup by graph identity. -func (ctx *CompilerContext) ModuleByKey(key string) (*Module, bool) { - if ctx == nil || key == "" { +// ModuleByID resolves canonical module identity. +func (ctx *CompilerContext) ModuleByID(id moduleid.ID) (*Module, bool) { + if ctx == nil || !id.Valid() { return nil, false } ctx.mu.RLock() defer ctx.mu.RUnlock() - module, ok := ctx.modules[key] + module, ok := ctx.modules[id] return module, ok } // SetSemanticExportBaseline records prior semantic API state for incremental comparison. -func (ctx *CompilerContext) SetSemanticExportBaseline(key, fingerprint string) { - if ctx == nil || key == "" || fingerprint == "" { +func (ctx *CompilerContext) SetSemanticExportBaseline(id moduleid.ID, fingerprint string) { + if ctx == nil || !id.Valid() || fingerprint == "" { return } ctx.mu.Lock() defer ctx.mu.Unlock() - ctx.semanticExportBaselines[key] = fingerprint + ctx.semanticExportBaselines[id] = fingerprint } // SemanticExportBaseline returns prior semantic API state when supplied by a client. -func (ctx *CompilerContext) SemanticExportBaseline(key string) (string, bool) { - if ctx == nil || key == "" { +func (ctx *CompilerContext) SemanticExportBaseline(id moduleid.ID) (string, bool) { + if ctx == nil || !id.Valid() { return "", false } ctx.mu.RLock() defer ctx.mu.RUnlock() - fingerprint, ok := ctx.semanticExportBaselines[key] + fingerprint, ok := ctx.semanticExportBaselines[id] return fingerprint, ok } @@ -396,11 +399,11 @@ func (ctx *CompilerContext) ModuleByFile(filePath string) (*Module, bool) { } ctx.mu.RLock() defer ctx.mu.RUnlock() - key, ok := ctx.fileIndex[CanonicalPath(filePath)] + id, ok := ctx.fileIndex[CanonicalPath(filePath)] if !ok { return nil, false } - module, ok := ctx.modules[key] + module, ok := ctx.modules[id] return module, ok } diff --git a/internal/project/modules_test.go b/internal/project/modules_test.go index 02f84644..9b93accb 100644 --- a/internal/project/modules_test.go +++ b/internal/project/modules_test.go @@ -9,10 +9,13 @@ import ( "compiler/internal/ir/cfg" "compiler/internal/ir/hir" "compiler/internal/ir/mir" + "compiler/internal/moduleid" "compiler/internal/phase" + "compiler/internal/semantics/effect" "compiler/internal/semantics/flowresult" "compiler/internal/semantics/ownershipresult" "compiler/internal/semantics/symbols" + "compiler/internal/semantics/typecheckresult" "compiler/internal/semantics/typeinfo" ) @@ -20,68 +23,206 @@ func TestCompilerContextAddModuleCanonicalizesFilePath(t *testing.T) { ctx := New(".", ".peep", nil) filePath := filepath.Join("nested", "..", "main.peep") want := CanonicalPath(filePath) - module := &Module{ - Key: "local:test", - FilePath: filePath, - } + id := moduleid.ID{Origin: string(ModuleOriginLocal), ImportPath: "test"} + module := &Module{ID: id, FilePath: filePath} ctx.AddModule(module) if module.FilePath != want { t.Fatalf("module path = %q, want canonical %q", module.FilePath, want) } + byID, foundByID := ctx.ModuleByID(id) + byFile, foundByFile := ctx.ModuleByFile(filePath) + if !foundByID || !foundByFile || byID != module || byFile != module { + t.Fatalf("module lookups by ID/file = (%p, %t), (%p, %t), want %p", byID, foundByID, byFile, foundByFile, module) + } +} + +func TestCompilerContextRejectsZeroModuleID(t *testing.T) { + ctx := New(".", ".peep", nil) + module := &Module{FilePath: "zero.peep"} + + ctx.AddModule(module) + + if len(ctx.Modules()) != 0 { + t.Fatalf("modules after zero-ID add = %#v", ctx.Modules()) + } + if _, found := ctx.ModuleByID(moduleid.ID{}); found { + t.Fatal("zero ID resolved a module") + } + if _, found := ctx.ModuleByFile(module.FilePath); found { + t.Fatal("rejected zero-ID module remained in file index") + } +} + +func TestCompilerContextReportsConflictingFileIdentity(t *testing.T) { + diag := diagnostics.NewDiagnosticBag() + ctx := New(".", ".peep", diag) + const shared = "shared.peep" + firstID := moduleid.ID{Origin: "stdlib", Namespace: "core", ImportPath: "global"} + secondID := moduleid.ID{Origin: "stdlib", Namespace: "core", ImportPath: "prelude/global"} + first := &Module{ID: firstID, FilePath: shared} + + if reported := ctx.AddModule(first); reported != nil { + t.Fatalf("clean registration returned a conflict: %#v", reported) + } + conflict := ctx.AddModule(&Module{ID: secondID, FilePath: shared}) + if conflict == nil { + t.Fatal("conflicting registration returned no diagnostic for the caller to label") + } + + // Two identities for one file is reachable from imports and library-root + // configuration, so it must diagnose rather than abort the compiler. + found := false + for _, item := range diag.Diagnostics() { + if item != nil && item.Code == diagnostics.ErrAmbiguousImport { + found = true + break + } + } + if !found { + t.Fatalf("conflicting file identity produced no ambiguous-import diagnostic: %s", diag.EmitAllToString()) + } + if got, ok := ctx.ModuleByFile(first.FilePath); !ok || got != first { + t.Fatal("first registration was not retained after identity conflict") + } + if _, ok := ctx.ModuleByID(secondID); ok { + t.Fatal("conflicting identity was registered") + } +} + +func TestCompilerContextRejectsIdentityRelocationWithoutCorruptingIndexes(t *testing.T) { + diag := diagnostics.NewDiagnosticBag() + ctx := New(".", ".peep", diag) + idA := moduleid.ID{Origin: "local", ImportPath: "a"} + idB := moduleid.ID{Origin: "local", ImportPath: "b"} + first := &Module{ID: idA, FilePath: "a.peep"} + ctx.AddModule(first) + ctx.AddModule(&Module{ID: idB, FilePath: "b.peep"}) + + // Moving A onto B's file must be rejected, and rejection must not disturb + // the indexes A already owns. + if conflict := ctx.AddModule(&Module{ID: idA, FilePath: "b.peep"}); conflict == nil { + t.Fatal("rejected relocation returned no diagnostic for the caller to label") + } + + if got, ok := ctx.ModuleByID(idA); !ok || got != first { + t.Fatal("rejected relocation lost the original module registration") + } + if got, ok := ctx.ModuleByFile(first.FilePath); !ok || got != first { + t.Fatalf("rejected relocation removed the original file index entry: %#v", ctx.Modules()) + } + found := false + for _, item := range diag.Diagnostics() { + if item != nil && item.Code == diagnostics.ErrAmbiguousImport { + found = true + } + } + if !found { + t.Fatalf("relocation conflict produced no diagnostic: %s", diag.EmitAllToString()) + } +} + +func TestCompilerContextRejectsSecondFileForSameIdentity(t *testing.T) { + diag := diagnostics.NewDiagnosticBag() + ctx := New(".", ".peep", diag) + id := moduleid.ID{Origin: "local", ImportPath: "foo"} + first := &Module{ID: id, FilePath: "foo.peep"} + ctx.AddModule(first) + + // Case-differing extensions reduce to one logical identity; the second file + // must not silently take over the identity. + ctx.AddModule(&Module{ID: id, FilePath: "foo.PEEP"}) + + if got, ok := ctx.ModuleByID(id); !ok || got != first { + t.Fatal("second file for one identity replaced the first registration") + } + if _, ok := ctx.ModuleByFile("foo.PEEP"); ok { + t.Fatal("rejected file was indexed") + } +} + +func TestCompilerContextModuleIDsKeepComponentsCollisionSafe(t *testing.T) { + ctx := New(".", ".peep", nil) + firstID := moduleid.ID{Origin: "local", Namespace: "ab", Dependency: "c", ImportPath: "value"} + secondID := moduleid.ID{Origin: "local", Namespace: "a", Dependency: "bc", ImportPath: "value"} + first := &Module{ID: firstID} + second := &Module{ID: secondID} + + ctx.AddModule(first) + ctx.AddModule(second) + + if firstID.String() == secondID.String() { + t.Fatalf("component-distinct IDs collide: %q", firstID.String()) + } + if got, found := ctx.ModuleByID(firstID); !found || got != first { + t.Fatalf("first module lookup = (%p, %t), want %p", got, found, first) + } + if got, found := ctx.ModuleByID(secondID); !found || got != second { + t.Fatalf("second module lookup = (%p, %t), want %p", got, found, second) + } } func moduleWithArtifacts() *Module { - return &Module{ + module := &Module{ Phase: phase.Backend, SemanticExportFingerprint: "semantic API", ModuleScope: symbols.NewScope(nil), - Semantics: NewSemanticInfo(), TypedASTNodes: map[ast.NodeID]ast.Node{1: &ast.BadStmt{}}, HIR: &hir.Module{}, CFG: &cfg.Module{Functions: []*cfg.Graph{{}}}, Flow: &flowresult.Result{ExprTypes: map[ast.NodeID]typeinfo.Type{1: typeinfo.DefaultIntegerType()}}, + Effects: effect.Result{1: {cfg.SiteID{}: {effect.Use{}}}}, Ownership: ownershipresult.Result{1: &ownershipresult.CleanupPlan{}}, MIR: &mir.Module{}, LLVMIR: "stale IR", } + module.ResetSemanticData() + module.Typechecking = typecheckresult.New() + module.Typechecking.ExprTypes[1] = typeinfo.DefaultIntegerType() + return module } func TestModuleResetToPhaseClearsOnlyDownstreamArtifacts(t *testing.T) { tests := []struct { - phase phase.Phase - scope bool - semantics bool - exportAPI bool - astNodes bool - hir bool - cfg bool - flow bool - ownership bool - mir bool - llvm bool + phase phase.Phase + scope bool + bindings bool + constants bool + typechecking bool + exportAPI bool + astNodes bool + hir bool + cfg bool + flow bool + effects bool + ownership bool + mir bool + llvm bool }{ {phase: phase.Parsed}, - {phase: phase.Typechecked, scope: true, semantics: true, exportAPI: true, astNodes: true}, - {phase: phase.CFG, scope: true, semantics: true, exportAPI: true, astNodes: true, cfg: true}, - {phase: phase.FlowTyped, scope: true, semantics: true, exportAPI: true, astNodes: true, cfg: true, flow: true}, - {phase: phase.DefiniteInit, scope: true, semantics: true, exportAPI: true, astNodes: true, cfg: true, flow: true}, - {phase: phase.Ownership, scope: true, semantics: true, exportAPI: true, astNodes: true, cfg: true, flow: true, ownership: true}, - {phase: phase.Usage, scope: true, semantics: true, exportAPI: true, astNodes: true, cfg: true, flow: true, ownership: true}, - {phase: phase.HIR, scope: true, semantics: true, exportAPI: true, astNodes: true, hir: true, cfg: true, flow: true, ownership: true}, - {phase: phase.MIR, scope: true, semantics: true, exportAPI: true, astNodes: true, hir: true, cfg: true, flow: true, ownership: true, mir: true}, - {phase: phase.Backend, scope: true, semantics: true, exportAPI: true, astNodes: true, hir: true, cfg: true, flow: true, ownership: true, mir: true, llvm: true}, + {phase: phase.Typechecked, scope: true, bindings: true, constants: true, typechecking: true, exportAPI: true, astNodes: true}, + {phase: phase.CFG, scope: true, bindings: true, constants: true, typechecking: true, exportAPI: true, astNodes: true, cfg: true}, + {phase: phase.FlowTyped, scope: true, bindings: true, constants: true, typechecking: true, exportAPI: true, astNodes: true, cfg: true, flow: true}, + {phase: phase.DefiniteInit, scope: true, bindings: true, constants: true, typechecking: true, exportAPI: true, astNodes: true, cfg: true, flow: true, effects: true}, + {phase: phase.Ownership, scope: true, bindings: true, constants: true, typechecking: true, exportAPI: true, astNodes: true, cfg: true, flow: true, effects: true, ownership: true}, + {phase: phase.Usage, scope: true, bindings: true, constants: true, typechecking: true, exportAPI: true, astNodes: true, cfg: true, flow: true, effects: true, ownership: true}, + {phase: phase.HIR, scope: true, bindings: true, constants: true, typechecking: true, exportAPI: true, astNodes: true, hir: true, cfg: true, flow: true, effects: true, ownership: true}, + {phase: phase.MIR, scope: true, bindings: true, constants: true, typechecking: true, exportAPI: true, astNodes: true, hir: true, cfg: true, flow: true, effects: true, ownership: true, mir: true}, + {phase: phase.Backend, scope: true, bindings: true, constants: true, typechecking: true, exportAPI: true, astNodes: true, hir: true, cfg: true, flow: true, effects: true, ownership: true, mir: true, llvm: true}, } for _, test := range tests { module := moduleWithArtifacts() module.resetToPhase(test.phase) if module.Phase != test.phase || (module.ModuleScope != nil) != test.scope || - (module.Semantics != nil) != test.semantics || (module.HIR != nil) != test.hir || + (module.Bindings != nil) != test.bindings || (module.Constants != nil) != test.constants || + (module.Typechecking != nil) != test.typechecking || + (module.HIR != nil) != test.hir || (module.TypedASTNodes != nil) != test.astNodes || (module.SemanticExportFingerprint != "") != test.exportAPI || (module.CFG != nil) != test.cfg || (module.Flow != nil) != test.flow || + (module.Effects != nil) != test.effects || (module.Ownership != nil) != test.ownership || (module.MIR != nil) != test.mir || (module.LLVMIR != "") != test.llvm { @@ -90,6 +231,52 @@ func TestModuleResetToPhaseClearsOnlyDownstreamArtifacts(t *testing.T) { } } +func TestModuleResetSemanticDataInitializesCurrentResults(t *testing.T) { + module := &Module{Typechecking: typecheckresult.New()} + module.ResetSemanticData() + if module.Bindings == nil || module.Bindings.BlockScopes == nil || module.Bindings.NodeSymbols == nil || + module.Bindings.MethodsByReceiver == nil || module.Bindings.MethodsByDecl == nil || + module.Bindings.OperationFunctions == nil || module.Constants == nil || module.Constants.ModuleValues == nil || + module.Constants.QueryCache == nil || module.Typechecking != nil { + t.Fatalf("semantic reset = %#v", module) + } +} + +func TestModuleExprTypeEvidenceFollowsPhaseLifecycle(t *testing.T) { + module := moduleWithArtifacts() + base := module.BaseExprType(1) + if base == nil { + t.Fatal("typechecked module has no base expression type") + } + if got := module.EffectiveExprType(1); got != module.Flow.ExprTypes[1] { + t.Fatalf("effective type = %#v, want flow refinement", got) + } + + module.Flow = nil + if got := module.EffectiveExprType(1); got != base { + t.Fatalf("effective type without flow = %#v, want base type %#v", got, base) + } + module.resetToPhase(phase.Typechecked) + if module.BaseExprType(1) != base { + t.Fatal("typechecked reset discarded base expression type") + } + module.resetToPhase(phase.Parsed) + if module.BaseExprType(1) != nil || module.EffectiveExprType(1) != nil { + t.Fatal("parsed reset retained expression type evidence") + } +} + +func TestModuleExprTypeEvidenceHandlesMissingTypecheckResult(t *testing.T) { + var module *Module + if module.BaseExprType(1) != nil || module.EffectiveExprType(1) != nil { + t.Fatal("nil module returned expression type evidence") + } + module = &Module{} + if module.BaseExprType(1) != nil || module.EffectiveExprType(1) != nil { + t.Fatal("module without typecheck result returned expression type evidence") + } +} + func TestModuleResetToPhaseRetainsCFGIdentity(t *testing.T) { module := moduleWithArtifacts() graph := module.CFG.Functions[0] @@ -104,12 +291,14 @@ func TestModuleResetToPhaseRetainsCFGIdentity(t *testing.T) { func TestCompilerContextResetModuleDiscardsOnlyDownstreamDiagnostics(t *testing.T) { bag := diagnostics.NewDiagnosticBag() - bag.BeginPhase(phase.Parsed, "a").Add(diagnostics.NewWarning("a parse")) - bag.BeginPhase(phase.Typechecked, "a").Add(diagnostics.NewError("a type")) - bag.BeginPhase(phase.Typechecked, "b").Add(diagnostics.NewError("b type")) + aID := moduleid.ID{Origin: string(ModuleOriginLocal), ImportPath: "a"} + bID := moduleid.ID{Origin: string(ModuleOriginLocal), ImportPath: "b"} + bag.BeginPhase(phase.Parsed, aID.String()).Add(diagnostics.NewWarning("a parse")) + bag.BeginPhase(phase.Typechecked, aID.String()).Add(diagnostics.NewError("a type")) + bag.BeginPhase(phase.Typechecked, bID.String()).Add(diagnostics.NewError("b type")) ctx := New(".", ".peep", bag) module := moduleWithArtifacts() - module.Key = "a" + module.ID = aID ctx.ResetModule(module, phase.Parsed) @@ -124,17 +313,19 @@ func TestCompilerContextResetModuleDiscardsOnlyDownstreamDiagnostics(t *testing. func TestCompilerContextResetPurgesOwnedNamedTypeInstances(t *testing.T) { ctx := New(".", ".peep", nil) - module := &Module{Key: "owner"} + ownerID := moduleid.ID{Origin: string(ModuleOriginLocal), ImportPath: "owner"} + otherID := moduleid.ID{Origin: string(ModuleOriginLocal), ImportPath: "other"} + module := &Module{ID: ownerID} ctx.typeInstances["owner::Box"] = namedTypeInstance{ - ownerModuleKey: module.Key, - typ: &typeinfo.DefinedType{Name: "Box", Identity: "owner::Box"}, + ownerModuleID: ownerID, + typ: &typeinfo.DefinedType{Name: "Box", Identity: "owner::Box"}, } ctx.typeInstances["other::Box"] = namedTypeInstance{ - ownerModuleKey: "other", - typ: &typeinfo.DefinedType{Name: "Box", Identity: "other::Box"}, + ownerModuleID: otherID, + typ: &typeinfo.DefinedType{Name: "Box", Identity: "other::Box"}, } - ctx.ResetModule(&Module{Key: module.Key}, phase.Parsed) + ctx.ResetModule(&Module{ID: module.ID}, phase.Parsed) if _, found := ctx.typeInstances["owner::Box"]; found { t.Fatal("reset retained instance owned by reset module") @@ -145,7 +336,10 @@ func TestCompilerContextResetPurgesOwnedNamedTypeInstances(t *testing.T) { } func TestCompilerContextReindexesCollectedTypeDeclarations(t *testing.T) { - module := &Module{Key: "owner", Phase: phase.Collected} + module := &Module{ + ID: moduleid.ID{Origin: string(ModuleOriginLocal), ImportPath: "owner"}, + Phase: phase.Collected, + } base := &typeinfo.DefinedType{Name: "Box", Identity: "owner::Box", Kind: typeinfo.DefinedKindStruct} declaration := &ast.StructDecl{Name: &ast.Ident{Name: "Box"}} original := New(".", ".peep", nil) @@ -167,3 +361,19 @@ func TestCompilerContextReindexesCollectedTypeDeclarations(t *testing.T) { t.Fatal("reset below collection retained context declaration index") } } + +func TestCompilerContextPathlessReplacementClearsFileIndex(t *testing.T) { + ctx := New(".", ".peep", nil) + id := moduleid.ID{Origin: string(ModuleOriginLocal), ImportPath: "x"} + + ctx.AddModule(&Module{ID: id, FilePath: "x.peep"}) + ctx.AddModule(&Module{ID: id}) + + if _, found := ctx.ModuleByFile("x.peep"); found { + t.Fatal("stale file index survived pathless replacement") + } + module, found := ctx.ModuleByID(id) + if !found || module == nil || module.FilePath != "" { + t.Fatalf("ModuleByID = %#v, want pathless replacement module", module) + } +} diff --git a/internal/project/type_lookup.go b/internal/project/type_lookup.go index e3b5064c..b1cb0fe3 100644 --- a/internal/project/type_lookup.go +++ b/internal/project/type_lookup.go @@ -30,7 +30,7 @@ func LookupImportedSymbol(ctx *CompilerContext, currentModule *Module, importedM if impSym, ok := currentModule.ModuleScope.LookupLocal(importedModule); ok && impSym != nil { impSym.Used = true } - imported, ok := ctx.ModuleByKey(imp.Key) + imported, ok := ctx.ModuleByID(imp.ID) if !ok || imported == nil || imported.ModuleScope == nil { return out, false } @@ -66,7 +66,7 @@ func CanonicalEnumDeclaration(ctx *CompilerContext, typ typeinfo.Type) (*Module, if owner == nil { instance, found := ctx.typeInstances[defined.Identity] if found && instance.typ == defined && instance.complete { - owner = ctx.modules[instance.ownerModuleKey] + owner = ctx.modules[instance.ownerModuleID] } } return owner diff --git a/internal/semantics/binder/binder.go b/internal/semantics/binder/binder.go index 8cb40651..d8e68efd 100644 --- a/internal/semantics/binder/binder.go +++ b/internal/semantics/binder/binder.go @@ -39,7 +39,7 @@ func (b *binder) bindModule() { } return true }) - slices.SortFunc(b.module.Semantics.OperationFunctions, func(left, right *symbols.Symbol) int { + slices.SortFunc(b.module.Bindings.OperationFunctions, func(left, right *symbols.Symbol) int { return cmp.Compare(left.Name, right.Name) }) b.validateTypeDeclCycles() @@ -52,7 +52,7 @@ func (b *binder) bindFunctionDecl(fn *ast.FnDecl) { } fnType := typeinfo.FuncTypeFromDeclWithOptions(fn, project.TypeSyntaxOptions(b.ctx, b.module, nil, false)) if fn.Receiver != nil { - if sym := b.module.Semantics.MethodSymbol[fn.ID()]; sym != nil { + if sym := b.module.Bindings.MethodsByDecl[fn.ID()]; sym != nil { sym.BindType(fnType) } return @@ -60,7 +60,7 @@ func (b *binder) bindFunctionDecl(fn *ast.FnDecl) { if sym := b.moduleScopeSymbol(fn.Name.Name); sym != nil { sym.BindType(fnType) if len(fnType.Params) > 0 { - b.module.Semantics.OperationFunctions = append(b.module.Semantics.OperationFunctions, sym) + b.module.Bindings.OperationFunctions = append(b.module.Bindings.OperationFunctions, sym) } } } diff --git a/internal/semantics/binder/binder_test.go b/internal/semantics/binder/binder_test.go index 898364de..d1223c7e 100644 --- a/internal/semantics/binder/binder_test.go +++ b/internal/semantics/binder/binder_test.go @@ -11,6 +11,7 @@ import ( "compiler/internal/diagnostics" "compiler/internal/frontend/lexer" "compiler/internal/frontend/parser" + "compiler/internal/moduleid" "compiler/internal/project" "compiler/internal/semantics/collector" "compiler/internal/semantics/symbols" @@ -28,7 +29,7 @@ fn Alpha(value: Value, extra: i32) {}` diag := diagnostics.NewDiagnosticBag() ctx := project.New(".", peeper.SourceExt, diag) module := &project.Module{ - Key: project.ModuleKeyFor(project.ModuleOriginLocal, filePath), + ID: moduleid.ID{Origin: string(project.ModuleOriginLocal), ImportPath: strings.TrimSuffix(filePath, peeper.SourceExt)}, FilePath: filePath, Content: src, AST: parser.New(filePath, lexer.New(filePath, src, diag).Tokenize(), diag).ParseModule(), @@ -39,7 +40,7 @@ fn Alpha(value: Value, extra: i32) {}` if diag.HasErrors() { t.Fatalf("unexpected diagnostics:\n%s", diag.EmitAllToString()) } - functions := module.Semantics.OperationFunctions + functions := module.Bindings.OperationFunctions if len(functions) != 2 || functions[0].Name != "Alpha" || functions[1].Name != "Zebra" { t.Fatalf("operation functions = %#v, want [Alpha Zebra]", functions) } @@ -90,7 +91,7 @@ struct B { a: A }`, diag := diagnostics.NewDiagnosticBag() ctx := project.New(".", peeper.SourceExt, diag) module := &project.Module{ - Key: project.ModuleKeyFor(project.ModuleOriginLocal, filePath), + ID: moduleid.ID{Origin: string(project.ModuleOriginLocal), ImportPath: strings.TrimSuffix(filePath, peeper.SourceExt)}, FilePath: filePath, Content: test.source, AST: parser.New(filePath, lexer.New(filePath, test.source, diag).Tokenize(), diag).ParseModule(), @@ -124,7 +125,7 @@ fn Use(box: Box, again: Box, other: Box, nested: Box>, n diag := diagnostics.NewDiagnosticBag() ctx := project.New(".", peeper.SourceExt, diag) module := &project.Module{ - Key: project.ModuleKeyFor(project.ModuleOriginLocal, filePath), + ID: moduleid.ID{Origin: string(project.ModuleOriginLocal), ImportPath: strings.TrimSuffix(filePath, peeper.SourceExt)}, FilePath: filePath, Content: src, AST: parser.New(filePath, lexer.New(filePath, src, diag).Tokenize(), diag).ParseModule(), @@ -207,7 +208,7 @@ fn Use(alias: Choice, canonical: Choice) {}` diag := diagnostics.NewDiagnosticBag() ctx := project.New(".", peeper.SourceExt, diag) module := &project.Module{ - Key: project.ModuleKeyFor(project.ModuleOriginLocal, filePath), + ID: moduleid.ID{Origin: string(project.ModuleOriginLocal), ImportPath: strings.TrimSuffix(filePath, peeper.SourceExt)}, FilePath: filePath, Content: src, AST: parser.New(filePath, lexer.New(filePath, src, diag).Tokenize(), diag).ParseModule(), @@ -254,7 +255,7 @@ fn Use(value: &Swap) {}`, diag := diagnostics.NewDiagnosticBag() ctx := project.New(".", peeper.SourceExt, diag) module := &project.Module{ - Key: project.ModuleKeyFor(project.ModuleOriginLocal, filePath), + ID: moduleid.ID{Origin: string(project.ModuleOriginLocal), ImportPath: strings.TrimSuffix(filePath, peeper.SourceExt)}, FilePath: filePath, Content: test.source, AST: parser.New(filePath, lexer.New(filePath, test.source, diag).Tokenize(), diag).ParseModule(), @@ -300,7 +301,7 @@ func TestBindRequiresExactNamedTypeArguments(t *testing.T) { diag := diagnostics.NewDiagnosticBag() ctx := project.New(".", peeper.SourceExt, diag) module := &project.Module{ - Key: project.ModuleKeyFor(project.ModuleOriginLocal, filePath), + ID: moduleid.ID{Origin: string(project.ModuleOriginLocal), ImportPath: strings.TrimSuffix(filePath, peeper.SourceExt)}, FilePath: filePath, Content: test.source, AST: parser.New(filePath, lexer.New(filePath, test.source, diag).Tokenize(), diag).ParseModule(), diff --git a/internal/semantics/binder/type_decl_cycles.go b/internal/semantics/binder/type_decl_cycles.go index 3b5b8f72..4d58246c 100644 --- a/internal/semantics/binder/type_decl_cycles.go +++ b/internal/semantics/binder/type_decl_cycles.go @@ -7,6 +7,7 @@ import ( "compiler/internal/diagnostics" "compiler/internal/frontend/ast" "compiler/internal/graph" + "compiler/internal/moduleid" "compiler/internal/project" "compiler/internal/semantics/symbols" ) @@ -20,7 +21,7 @@ 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.Key, name) + owner := typeDeclNodeID(b.module.ID, name) // Value edges require full layout; indirect references do not force target expansion. b.addTypeDeclEdges(owner, typ, false) } @@ -34,7 +35,7 @@ func (b *binder) validateTypeDeclCycles() { if sym == nil || sym.Kind != symbols.SymbolType { continue } - nodeIDs = append(nodeIDs, typeDeclNodeID(b.module.Key, sym.Name)) + nodeIDs = append(nodeIDs, typeDeclNodeID(b.module.ID, sym.Name)) } if len(nodeIDs) == 0 { return @@ -69,11 +70,11 @@ func (b *binder) validateTypeDeclCycles() { } } -func typeDeclNodeID(moduleKey, name string) graph.NodeID { - if moduleKey == "" || name == "" { +func typeDeclNodeID(moduleID moduleid.ID, name string) graph.NodeID { + if !moduleID.Valid() || name == "" { return "" } - return graph.NodeID("type:" + moduleKey + ":" + name) + return graph.NodeID("type:" + moduleID.String() + ":" + name) } func (b *binder) addTypeDeclEdges(owner graph.NodeID, typ ast.TypeExpr, indirect bool) { @@ -145,7 +146,7 @@ func (b *binder) lookupTypeDeclNodeID(name string) graph.NodeID { if !ok || sym == nil || sym.Kind != symbols.SymbolType { return "" } - return typeDeclNodeID(b.module.Key, sym.Name) + return typeDeclNodeID(b.module.ID, sym.Name) } func (b *binder) lookupQualifiedTypeDeclNodeID(node *ast.ScopeResolution) graph.NodeID { @@ -160,7 +161,7 @@ func (b *binder) lookupQualifiedTypeDeclNodeID(node *ast.ScopeResolution) graph. if !ok || resolved.Module == nil || resolved.Symbol == nil || resolved.Symbol.Kind != symbols.SymbolType { return "" } - return typeDeclNodeID(resolved.Module.Key, resolved.Symbol.Name) + return typeDeclNodeID(resolved.Module.ID, resolved.Symbol.Name) } func typeDeclNameFromNodeID(id graph.NodeID) string { diff --git a/internal/semantics/bindingresult/result.go b/internal/semantics/bindingresult/result.go new file mode 100644 index 00000000..865fc760 --- /dev/null +++ b/internal/semantics/bindingresult/result.go @@ -0,0 +1,25 @@ +// Package bindingresult defines one staged symbol/scope graph completed by collection, binding, resolution, and typechecking. +package bindingresult + +import ( + "compiler/internal/frontend/ast" + "compiler/internal/semantics/symbols" +) + +type Result struct { + BlockScopes map[ast.NodeID]*symbols.Scope + NodeSymbols map[ast.NodeID]*symbols.Symbol + MethodsByReceiver map[string][]*symbols.Symbol + MethodsByDecl map[ast.NodeID]*symbols.Symbol + OperationFunctions []*symbols.Symbol +} + +func New() *Result { + return &Result{ + BlockScopes: make(map[ast.NodeID]*symbols.Scope), + NodeSymbols: make(map[ast.NodeID]*symbols.Symbol), + MethodsByReceiver: make(map[string][]*symbols.Symbol), + MethodsByDecl: make(map[ast.NodeID]*symbols.Symbol), + OperationFunctions: make([]*symbols.Symbol, 0), + } +} diff --git a/internal/semantics/collector/collector.go b/internal/semantics/collector/collector.go index 039bd59a..d10b1c64 100644 --- a/internal/semantics/collector/collector.go +++ b/internal/semantics/collector/collector.go @@ -74,7 +74,7 @@ func (c *collector) collectFnDecl(fn *ast.FnDecl) { } targetKey := typeinfo.TypeText(targetType) var previous *symbols.Symbol - for _, item := range c.module.Semantics.MethodSets[targetKey] { + for _, item := range c.module.Bindings.MethodsByReceiver[targetKey] { if item != nil && item.Name == fn.Name.Name { previous = item break @@ -86,14 +86,14 @@ func (c *collector) collectFnDecl(fn *ast.FnDecl) { return } sym := symbols.New(fn.Name.Name, symbols.SymbolMethod, fn, ast.LocOf(fn.Name)) - sym.DefiningModule = c.module.DefiningModuleKey() + sym.DefiningModule = c.module.ID sym.Scope = symbols.NewScope(c.module.ModuleScope) - c.module.Semantics.MethodSets[targetKey] = append(c.module.Semantics.MethodSets[targetKey], sym) - c.module.Semantics.MethodSymbol[fn.ID()] = sym + c.module.Bindings.MethodsByReceiver[targetKey] = append(c.module.Bindings.MethodsByReceiver[targetKey], sym) + c.module.Bindings.MethodsByDecl[fn.ID()] = sym return } sym := symbols.New(fn.Name.Name, symbols.SymbolFunc, fn, ast.LocOf(fn.Name)) - sym.DefiningModule = c.module.DefiningModuleKey() + sym.DefiningModule = c.module.ID sym.Scope = symbols.NewScope(c.module.ModuleScope) if err := c.module.ModuleScope.Declare(sym); err != nil { problems.ReportRedeclaration(c.ctx.Diagnostics, c.module.ModuleScope, err.Error(), fn.Name.Name, fn.Name.Location) @@ -152,12 +152,12 @@ func (c *collector) collectConcreteTypeDecl(decl ast.TypeDecl) { } variantSymbol := symbols.New(variant.Name.Name, symbols.SymbolVariant, variant.Name, variant.Name.Location) variantSymbol.Type = defined - variantSymbol.DefiningModule = c.module.DefiningModuleKey() + variantSymbol.DefiningModule = c.module.ID if err := sym.Scope.Declare(variantSymbol); err != nil { problems.ReportRedeclaration(c.ctx.Diagnostics, sym.Scope, err.Error(), variant.Name.Name, variant.Name.Location) continue } - c.module.Semantics.ResolvedSymbols[variant.Name.ID()] = variantSymbol + c.module.Bindings.NodeSymbols[variant.Name.ID()] = variantSymbol } } } @@ -169,6 +169,7 @@ func (c *collector) collectModuleBinding(name *ast.Ident, kind symbols.Kind, nod return } sym := symbols.New(name.Name, kind, node, ast.LocOf(name)) + sym.DefiningModule = c.module.ID sym.Type = &typeinfo.UnknownType{} // binder fills real type if err := c.module.ModuleScope.Declare(sym); err != nil { problems.ReportRedeclaration(c.ctx.Diagnostics, c.module.ModuleScope, err.Error(), name.Name, name.Location) diff --git a/internal/semantics/collector/collector_test.go b/internal/semantics/collector/collector_test.go index 118050ba..01c49e1b 100644 --- a/internal/semantics/collector/collector_test.go +++ b/internal/semantics/collector/collector_test.go @@ -8,6 +8,7 @@ import ( "compiler/internal/frontend/ast" "compiler/internal/frontend/lexer" "compiler/internal/frontend/parser" + "compiler/internal/moduleid" "compiler/internal/project" "compiler/internal/semantics/symbols" "compiler/internal/semantics/typeinfo" @@ -17,27 +18,28 @@ import ( var _ func(*collector, ast.TypeDecl) = (*collector).collectConcreteTypeDecl var _ func(*collector, *ast.Ident, symbols.Kind, ast.Node) = (*collector).collectModuleBinding -func TestCallableSymbolsKeepDefiningModuleKey(t *testing.T) { +func TestCallableSymbolsKeepDefiningModuleIdentity(t *testing.T) { const filePath = "collector_callable_module_test" + peeper.SourceExt const src = `struct Counter { value: i32 } fn Value() -> i32 { return 1; } fn (self: Counter) Read() -> i32 { return self.value; }` diag := diagnostics.NewDiagnosticBag() module := &project.Module{ - Key: project.ModuleKeyFor(project.ModuleOriginDependency, filePath), - ImportPath: "math/counter", - FilePath: filePath, - Namespace: "vendor", - Origin: project.ModuleOriginDependency, - Dependency: "mathlib", - Content: src, - AST: parser.New(filePath, lexer.New(filePath, src, diag).Tokenize(), diag).ParseModule(), - Imports: make(map[string]project.ResolvedImport), + ID: moduleid.ID{ + Origin: string(project.ModuleOriginDependency), + Namespace: "vendor", + Dependency: "mathlib", + ImportPath: "math/counter", + }, + FilePath: filePath, + Content: src, + AST: parser.New(filePath, lexer.New(filePath, src, diag).Tokenize(), diag).ParseModule(), + Imports: make(map[string]project.ResolvedImport), } ctx := project.New(".", peeper.SourceExt, diag) Collect(ctx, module) - want := symbols.DefiningModuleKey{ + want := moduleid.ID{ Origin: string(project.ModuleOriginDependency), Namespace: "vendor", Dependency: "mathlib", @@ -47,7 +49,7 @@ fn (self: Counter) Read() -> i32 { return self.value; }` if !ok || function == nil || function.DefiningModule != want { t.Fatalf("function defining module = %#v, want %#v", function, want) } - methods := module.Semantics.MethodSets["Counter"] + methods := module.Bindings.MethodsByReceiver["Counter"] if len(methods) != 1 || methods[0] == nil || methods[0].DefiningModule != want { t.Fatalf("method defining module = %#v, want %#v", methods, want) } @@ -58,7 +60,7 @@ func TestCollectedDefinedTypeKeepsDeclaringModuleIdentity(t *testing.T) { const src = `enum Status { Ready }` diag := diagnostics.NewDiagnosticBag() module := &project.Module{ - Key: project.ModuleKeyFor(project.ModuleOriginLocal, filePath), + ID: moduleid.ID{Origin: string(project.ModuleOriginLocal), ImportPath: strings.TrimSuffix(filePath, peeper.SourceExt)}, FilePath: filePath, Content: src, AST: parser.New(filePath, lexer.New(filePath, src, diag).Tokenize(), diag).ParseModule(), @@ -75,7 +77,7 @@ func TestCollectedDefinedTypeKeepsDeclaringModuleIdentity(t *testing.T) { if !ok || defined == nil { t.Fatalf("collected enum type = %T, want DefinedType", sym.Type) } - want := module.Key + "::Status" + want := module.ID.String() + "::Status" if defined.Identity != want { t.Fatalf("collected enum identity = %q, want %q", defined.Identity, want) } @@ -90,7 +92,7 @@ func TestCollectedEnumOwnsOrderedVariantSymbols(t *testing.T) { type Alias = Result;` diag := diagnostics.NewDiagnosticBag() module := &project.Module{ - Key: project.ModuleKeyFor(project.ModuleOriginLocal, filePath), + ID: moduleid.ID{Origin: string(project.ModuleOriginLocal), ImportPath: strings.TrimSuffix(filePath, peeper.SourceExt)}, FilePath: filePath, Content: src, AST: parser.New(filePath, lexer.New(filePath, src, diag).Tokenize(), diag).ParseModule(), @@ -121,7 +123,7 @@ type Alias = Result;` } enumType := enumDecl.Type.(*ast.EnumType) for index, variant := range enumType.Variants { - if module.Semantics.ResolvedSymbols[variant.Name.ID()] != children[index] { + if module.Bindings.NodeSymbols[variant.Name.ID()] != children[index] { t.Fatalf("variant %s identifier does not resolve to child symbol", variant.Name.Name) } } @@ -136,7 +138,7 @@ func TestCollectedEnumRejectsDuplicateVariants(t *testing.T) { const src = `enum Status { Ready, Ready }` diag := diagnostics.NewDiagnosticBag() module := &project.Module{ - Key: project.ModuleKeyFor(project.ModuleOriginLocal, filePath), + ID: moduleid.ID{Origin: string(project.ModuleOriginLocal), ImportPath: strings.TrimSuffix(filePath, peeper.SourceExt)}, FilePath: filePath, Content: src, AST: parser.New(filePath, lexer.New(filePath, src, diag).Tokenize(), diag).ParseModule(), @@ -166,18 +168,15 @@ fn main() -> i32 { } module := &project.Module{ - Key: project.ModuleKeyFor(project.ModuleOriginLocal, filePath), - ImportPath: "collector_import_test", - FilePath: filePath, - Content: src, - AST: modAST, + ID: moduleid.ID{Origin: string(project.ModuleOriginLocal), ImportPath: "collector_import_test"}, + FilePath: filePath, + Content: src, + AST: modAST, Imports: map[string]project.ResolvedImport{ "external": { - Key: "local:external" + peeper.SourceExt, - ImportPath: "external", - FilePath: "external" + peeper.SourceExt, - Origin: project.ModuleOriginLocal, - Decl: modAST.Imports[0], + ID: moduleid.ID{Origin: string(project.ModuleOriginLocal), ImportPath: "external"}, + FilePath: "external" + peeper.SourceExt, + Decl: modAST.Imports[0], }, }, } @@ -210,12 +209,11 @@ fn Platform() -> i32 { ctx := project.NewWithConfig(project.Config{RootDir: ".", Extension: peeper.SourceExt, TargetOS: "linux"}, diag) modAST := parser.New(filePath, lexer.New(filePath, src, diag).Tokenize(), diag).ParseModule() module := &project.Module{ - Key: project.ModuleKeyFor(project.ModuleOriginLocal, filePath), - ImportPath: "collector_target_test", - FilePath: filePath, - Content: src, - AST: modAST, - Imports: make(map[string]project.ResolvedImport), + ID: moduleid.ID{Origin: string(project.ModuleOriginLocal), ImportPath: "collector_target_test"}, + FilePath: filePath, + Content: src, + AST: modAST, + Imports: make(map[string]project.ResolvedImport), } Collect(ctx, module) @@ -249,12 +247,11 @@ func TestTargetOSImplMethodsStillCollide(t *testing.T) { ctx := project.NewWithConfig(project.Config{RootDir: ".", Extension: peeper.SourceExt, TargetOS: "linux"}, diag) modAST := parser.New(filePath, lexer.New(filePath, src, diag).Tokenize(), diag).ParseModule() module := &project.Module{ - Key: project.ModuleKeyFor(project.ModuleOriginLocal, filePath), - ImportPath: "collector_method_target_test", - FilePath: filePath, - Content: src, - AST: modAST, - Imports: make(map[string]project.ResolvedImport), + ID: moduleid.ID{Origin: string(project.ModuleOriginLocal), ImportPath: "collector_method_target_test"}, + FilePath: filePath, + Content: src, + AST: modAST, + Imports: make(map[string]project.ResolvedImport), } Collect(ctx, module) diff --git a/internal/semantics/constantresult/result.go b/internal/semantics/constantresult/result.go new file mode 100644 index 00000000..195aad70 --- /dev/null +++ b/internal/semantics/constantresult/result.go @@ -0,0 +1,20 @@ +// Package constantresult defines constant-evaluation artifacts for one semantic generation. +package constantresult + +import ( + "compiler/internal/constvalue" + "compiler/internal/semantics/symbols" +) + +// Result separates authoritative module constants from mutable lazy query entries. +type Result struct { + ModuleValues map[symbols.SymbolID]constvalue.Value + QueryCache map[symbols.SymbolID]constvalue.Value +} + +func New() *Result { + return &Result{ + ModuleValues: make(map[symbols.SymbolID]constvalue.Value), + QueryCache: make(map[symbols.SymbolID]constvalue.Value), + } +} diff --git a/internal/semantics/consteval/consteval.go b/internal/semantics/consteval/consteval.go index 32dbb506..66580ddc 100644 --- a/internal/semantics/consteval/consteval.go +++ b/internal/semantics/consteval/consteval.go @@ -5,15 +5,18 @@ import ( "compiler/internal/diagnostics" "compiler/internal/frontend/ast" "compiler/internal/project" + "compiler/internal/semantics/constantresult" "compiler/internal/semantics/symbols" "compiler/internal/semantics/typeinfo" "compiler/pkg/numeric" ) type evaluator struct { - ctx *project.CompilerContext - module *project.Module - inProgress map[symbols.SymbolID]struct{} + ctx *project.CompilerContext + module *project.Module + constants *constantresult.Result + inProgress map[symbols.SymbolID]struct{} + publishModuleValues bool } // Evaluate performs the eager semantic const prepass after name resolution. @@ -23,36 +26,24 @@ func Evaluate(ctx *project.CompilerContext, module *project.Module) { if ctx == nil || module == nil || module.ModuleScope == nil { return } - if module.Semantics == nil { - module.Semantics = project.NewSemanticInfo() - } - if module.Semantics.ConstValues == nil { - module.Semantics.ConstValues = make(map[symbols.SymbolID]constvalue.Value) - } - e := &evaluator{ - ctx: ctx, - module: module, - inProgress: make(map[symbols.SymbolID]struct{}), - } - for _, sym := range module.ModuleScope.Symbols() { - if sym != nil && sym.Kind == symbols.SymbolConst { - e.evalConstSymbol(sym, module.ModuleScope) - } - } + e := newEvaluator(ctx, module, false) + e.evalModuleConstants() } -// FinalizeValues recomputes module constants after typechecking assigns final -// symbol types. Local const cache entries remain available to later queries. +// FinalizeValues recomputes and publishes authoritative module constants after +// typechecking assigns final symbol types. Local query-cache entries remain mutable. func FinalizeValues(ctx *project.CompilerContext, module *project.Module) { - if ctx == nil || module == nil || module.ModuleScope == nil || module.Semantics == nil { + if ctx == nil || module == nil || module.ModuleScope == nil { return } + e := newEvaluator(ctx, module, true) + clear(e.constants.ModuleValues) for _, sym := range module.ModuleScope.Symbols() { if sym != nil && sym.Kind == symbols.SymbolConst { - delete(module.Semantics.ConstValues, sym.ID) + delete(e.constants.QueryCache, sym.ID) } } - Evaluate(ctx, module) + e.evalModuleConstants() } // EvaluateExpr computes one semantic constant using expected type information @@ -64,25 +55,43 @@ func EvaluateExpr(ctx *project.CompilerContext, module *project.Module, scope *s if scope == nil && module.ModuleScope == nil { return nil, false } - if module.Semantics == nil { - module.Semantics = project.NewSemanticInfo() + e := newEvaluator(ctx, module, false) + return e.evalExpr(scope, expr, expected) +} + +func newEvaluator(ctx *project.CompilerContext, module *project.Module, publishModuleValues bool) *evaluator { + if module.Constants == nil { + module.Constants = constantresult.New() } - if module.Semantics.ConstValues == nil { - module.Semantics.ConstValues = make(map[symbols.SymbolID]constvalue.Value) + return &evaluator{ + ctx: ctx, + module: module, + constants: module.Constants, + inProgress: make(map[symbols.SymbolID]struct{}), + publishModuleValues: publishModuleValues, } - e := &evaluator{ - ctx: ctx, - module: module, - inProgress: make(map[symbols.SymbolID]struct{}), +} + +func (e *evaluator) evalModuleConstants() { + for _, sym := range e.module.ModuleScope.Symbols() { + if sym != nil && sym.Kind == symbols.SymbolConst { + e.evalConstSymbol(sym, e.module.ModuleScope) + } } - return e.evalExpr(scope, expr, expected) } func (e *evaluator) evalConstSymbol(sym *symbols.Symbol, scope *symbols.Scope) (constvalue.Value, bool) { - if e == nil || e.module == nil || e.module.Semantics == nil || sym == nil { + if e == nil || e.module == nil || sym == nil { return nil, false } - if value, ok := e.module.Semantics.ConstValues[sym.ID]; ok { + if ownerID := sym.DefiningModule; ownerID.Valid() && ownerID != e.module.ID { + value := e.ctx.PublishedConstant(e.module, sym) + return value, value != nil + } + if value, ok := e.constants.ModuleValues[sym.ID]; ok { + return value, true + } + if value, ok := e.constants.QueryCache[sym.ID]; ok { return value, true } if _, ok := e.inProgress[sym.ID]; ok { @@ -117,56 +126,68 @@ func (e *evaluator) evalConstSymbol(sym *symbols.Symbol, scope *symbols.Scope) ( if !ok { return nil, false } - e.module.Semantics.ConstValues[sym.ID] = value + if e.publishModuleValues { + if topLevel, found := e.module.ModuleScope.LookupLocal(sym.Name); found && topLevel != nil && topLevel.ID == sym.ID { + e.constants.ModuleValues[sym.ID] = value + delete(e.constants.QueryCache, sym.ID) + return value, true + } + } + e.constants.QueryCache[sym.ID] = value return value, true } func (e *evaluator) evalExpr(scope *symbols.Scope, expr ast.Expr, expected typeinfo.Type) (constvalue.Value, bool) { - if construction, ok := e.module.Semantics.VariantConstructions[expr.ID()]; ok { - if !typeinfo.IsImplicitCopyType(construction.EnumType) { - return nil, false - } - descriptor, variant := typeinfo.VariantDescriptorOf(construction.EnumType) - if !variant || construction.Case < 0 || construction.Case >= len(descriptor.Cases) { - return nil, false - } - if construction.Value != nil { - if literal, ok := construction.Value.(*ast.StructLit); ok { - payload, structured := typeinfo.Underlying(construction.Payload).(*typeinfo.StructType) - if !structured || payload == nil { - return nil, false - } - valuesByName := make(map[string]ast.Expr, len(literal.Fields)) - for _, field := range literal.Fields { - if field.Name != nil { - valuesByName[field.Name.Name] = field.Value - } - } - fields := make([]constvalue.Value, len(payload.Fields)) - for index, field := range payload.Fields { - valueExpr := valuesByName[field.Name] - value, ok := e.evalExpr(scope, valueExpr, field.Type) - if !ok { + if e.module.Typechecking != nil { + if construction, ok := e.module.Typechecking.VariantConstructions[expr.ID()]; ok { + if typeinfo.OwnershipCapabilityOf(construction.EnumType).Copy != typeinfo.CopyImplicit { + return nil, false + } + descriptor, variant := typeinfo.VariantDescriptorOf(construction.EnumType) + if !variant || construction.Case < 0 || construction.Case >= len(descriptor.Cases) { + return nil, false + } + if construction.Value != nil { + if literal, ok := construction.Value.(*ast.StructLit); ok { + payload, structured := typeinfo.Underlying(construction.Payload).(*typeinfo.StructType) + if !structured || payload == nil { return nil, false } - fields[index] = value + valuesByName := make(map[string]ast.Expr, len(literal.Fields)) + for _, field := range literal.Fields { + if field.Name != nil { + valuesByName[field.Name.Name] = field.Value + } + } + fields := make([]constvalue.Value, len(payload.Fields)) + for index, field := range payload.Fields { + valueExpr := valuesByName[field.Name] + value, ok := e.evalExpr(scope, valueExpr, field.Type) + if !ok { + return nil, false + } + fields[index] = value + } + return constvalue.NewVariant(descriptor.Identity, typeinfo.TypeText(construction.EnumType), construction.Case, fields) } - return constvalue.NewVariant(descriptor.Identity, typeinfo.TypeText(construction.EnumType), construction.Case, fields) - } - value, ok := e.evalExpr(scope, construction.Value, construction.Payload) - if !ok { - return nil, false + value, ok := e.evalExpr(scope, construction.Value, construction.Payload) + if !ok { + return nil, false + } + return constvalue.NewVariant(descriptor.Identity, typeinfo.TypeText(construction.EnumType), construction.Case, []constvalue.Value{value}) } - return constvalue.NewVariant(descriptor.Identity, typeinfo.TypeText(construction.EnumType), construction.Case, []constvalue.Value{value}) + return constvalue.NewVariant(descriptor.Identity, typeinfo.TypeText(construction.EnumType), construction.Case, nil) } - return constvalue.NewVariant(descriptor.Identity, typeinfo.TypeText(construction.EnumType), construction.Case, nil) } if node, ok := expr.(*ast.IsExpr); ok { - test, found := e.module.Semantics.CaseTests[node.ID()] + if e.module.Typechecking == nil { + return nil, false + } + test, found := e.module.Typechecking.CaseTests[node.ID()] if !found || test.Family != typeinfo.VariantFamilyNamed { return nil, false } - value, ok := e.evalExpr(scope, node.Value, e.module.Semantics.ExprTypes[node.Value.ID()]) + value, ok := e.evalExpr(scope, node.Value, e.module.BaseExprType(node.Value.ID())) variant, constant := value.(*constvalue.VariantConst) if !ok || !constant || variant == nil { return nil, false diff --git a/internal/semantics/consteval/consteval_test.go b/internal/semantics/consteval/consteval_test.go index a614b042..df64cd3b 100644 --- a/internal/semantics/consteval/consteval_test.go +++ b/internal/semantics/consteval/consteval_test.go @@ -5,12 +5,15 @@ import ( "compiler/internal/constvalue" "compiler/internal/diagnostics" + "compiler/internal/frontend/ast" "compiler/internal/frontend/lexer" "compiler/internal/frontend/parser" + "compiler/internal/moduleid" "compiler/internal/project" "compiler/internal/semantics/binder" "compiler/internal/semantics/collector" "compiler/internal/semantics/resolver" + "compiler/internal/semantics/symbols" "compiler/internal/semantics/typeinfo" "compiler/pkg/peeper" ) @@ -22,12 +25,11 @@ func constevalModule(t *testing.T, src string) (*project.Module, *diagnostics.Di diag.AddSourceContent(filePath, src) ctx := project.New(".", peeper.SourceExt, diag) module := &project.Module{ - Key: project.ModuleKeyFor(project.ModuleOriginLocal, filePath), - ImportPath: "consteval_test", - FilePath: filePath, - Content: src, - AST: parser.New(filePath, lexer.New(filePath, src, diag).Tokenize(), diag).ParseModule(), - Imports: make(map[string]project.ResolvedImport), + ID: moduleid.ID{Origin: string(project.ModuleOriginLocal), ImportPath: "consteval_test"}, + FilePath: filePath, + Content: src, + AST: parser.New(filePath, lexer.New(filePath, src, diag).Tokenize(), diag).ParseModule(), + Imports: make(map[string]project.ResolvedImport), } ctx.AddModule(module) collector.Collect(ctx, module) @@ -37,6 +39,20 @@ func constevalModule(t *testing.T, src string) (*project.Module, *diagnostics.Di return module, diag } +func TestEvaluateInitializesOnlyConstantResult(t *testing.T) { + diag := diagnostics.NewDiagnosticBag() + module := &project.Module{ModuleScope: symbols.NewScope(nil)} + + Evaluate(project.New(".", peeper.SourceExt, diag), module) + + if module.Constants == nil || module.Constants.ModuleValues == nil || module.Constants.QueryCache == nil { + t.Fatal("Evaluate did not initialize constant result") + } + if module.Bindings != nil { + t.Fatalf("Evaluate initialized Bindings: %#v", module.Bindings) + } +} + func TestEvaluateTopLevelConstExpressions(t *testing.T) { module, diag := constevalModule(t, `const A = 1 + 2 * 3; const B = A + 4; @@ -162,11 +178,110 @@ func TestFinalizeValuesRecomputesConstantsWithFinalSymbolTypes(t *testing.T) { if !ok || sym == nil { t.Fatal("missing symbol Value") } + if _, found := module.Constants.QueryCache[sym.ID]; !found { + t.Fatal("eager constant missing provisional query-cache value") + } + if _, found := module.Constants.ModuleValues[sym.ID]; found { + t.Fatal("eager prepass published authoritative module value before typecheck") + } sym.BindType(&typeinfo.IntegerType{Signed: true, Bits: 64}) ctx := project.New(".", peeper.SourceExt, diag) ctx.AddModule(module) FinalizeValues(ctx, module) assertIntConst(t, module, "Value", "1", "i64") + if _, found := module.Constants.QueryCache[sym.ID]; found { + t.Fatal("finalized module constant remains duplicated in query cache") + } + if _, found := module.Constants.ModuleValues[sym.ID]; !found { + t.Fatal("finalized module constant was not published") + } +} + +func TestEvaluateExprCachesLocalConstantsWithoutChangingPublishedValues(t *testing.T) { + module, diag := constevalModule(t, `const Top = 1; +fn main() { + const Local = 2; + let value = Local; +} +`) + ctx := project.New(".", peeper.SourceExt, diag) + ctx.AddModule(module) + FinalizeValues(ctx, module) + fn := module.AST.Stmts[1].(*ast.FnDecl) + local := fn.Body.Stmts[0].(*ast.ConstDecl) + reference := fn.Body.Stmts[1].(*ast.LetDecl).Value.(*ast.Ident) + scope := module.Bindings.BlockScopes[fn.Body.ID()] + if _, ok := EvaluateExpr(ctx, module, scope, reference, nil); !ok { + t.Fatal("failed to evaluate local constant reference") + } + localSymbol, found := scope.LookupLocal(local.Name.Name) + if !found || localSymbol == nil { + t.Fatal("missing local constant symbol") + } + if _, found := module.Constants.QueryCache[localSymbol.ID]; !found { + t.Fatal("local constant missing query-cache entry") + } + if _, found := module.Constants.ModuleValues[localSymbol.ID]; found { + t.Fatal("local constant leaked into authoritative module values") + } + top, _ := module.ModuleScope.LookupLocal("Top") + if _, found := module.Constants.QueryCache[top.ID]; found { + t.Fatal("published module constant was duplicated by local query") + } +} + +func TestEvaluateReadsForeignPublishedConstantWithoutConsumerCache(t *testing.T) { + diag := diagnostics.NewDiagnosticBag() + ctx := project.New(".", peeper.SourceExt, diag) + parse := func(filePath, importPath, src string) *project.Module { + module := &project.Module{ + ID: moduleid.ID{Origin: string(project.ModuleOriginLocal), ImportPath: importPath}, + FilePath: filePath, + Content: src, + AST: parser.New(filePath, lexer.New(filePath, src, diag).Tokenize(), diag).ParseModule(), + Imports: make(map[string]project.ResolvedImport), + } + ctx.AddModule(module) + return module + } + resolve := func(module *project.Module) { + collector.Collect(ctx, module) + binder.Bind(ctx, module) + resolver.Resolve(ctx, module) + } + + owner := parse("owner"+peeper.SourceExt, "owner", "const Shared: i32 = 7;") + resolve(owner) + Evaluate(ctx, owner) + FinalizeValues(ctx, owner) + shared, found := owner.ModuleScope.LookupLocal("Shared") + if !found || shared == nil || shared.DefiningModule != owner.ID { + t.Fatalf("foreign constant owner = %#v, want %v", shared, owner.ID) + } + if err := ctx.GlobalScope.Declare(shared); err != nil { + t.Fatalf("publish shared constant: %v", err) + } + + consumer := parse("consumer"+peeper.SourceExt, "consumer", "const Local = Shared;") + resolve(consumer) + Evaluate(ctx, consumer) + local, found := consumer.ModuleScope.LookupLocal("Local") + if !found || local == nil { + t.Fatal("missing consumer constant") + } + value, ok := consumer.Constants.QueryCache[local.ID].(*constvalue.IntConst) + if !ok || value == nil || value.Text() != "7" { + t.Fatalf("consumer value = %#v, want 7", consumer.Constants.QueryCache[local.ID]) + } + if _, found := consumer.Constants.QueryCache[shared.ID]; found { + t.Fatal("foreign constant duplicated in consumer query cache") + } + if _, found := owner.Constants.ModuleValues[shared.ID]; !found { + t.Fatal("owner lost published constant") + } + if diag.HasErrors() { + t.Fatalf("unexpected diagnostics:\n%s", diag.EmitAllToString()) + } } func TestEvaluateUsesConstOperandTypeForNestedArithmetic(t *testing.T) { @@ -189,9 +304,10 @@ func TestEvaluateStringConst(t *testing.T) { if !ok || sym == nil { t.Fatalf("missing symbol Name") } - got, ok := module.Semantics.ConstValues[sym.ID].(*constvalue.StringConst) + value := evaluatedConst(module, sym.ID) + got, ok := value.(*constvalue.StringConst) if !ok || got == nil || got.Text() != "puts" || got.TypeText() != "cstr" { - t.Fatalf("Name = %#v, want str puts cstr", module.Semantics.ConstValues[sym.ID]) + t.Fatalf("Name = %#v, want str puts cstr", value) } } @@ -201,10 +317,18 @@ func assertIntConst(t *testing.T, module *project.Module, name, want, wantType s if !ok || sym == nil { t.Fatalf("missing symbol %s", name) } - got, ok := module.Semantics.ConstValues[sym.ID].(*constvalue.IntConst) + value := evaluatedConst(module, sym.ID) + got, ok := value.(*constvalue.IntConst) if !ok || got == nil || got.Text() != want || (wantType != "" && got.TypeText() != wantType) { - t.Fatalf("%s = %#v, want int %s %s", name, module.Semantics.ConstValues[sym.ID], want, wantType) + t.Fatalf("%s = %#v, want int %s %s", name, value, want, wantType) + } +} + +func evaluatedConst(module *project.Module, id symbols.SymbolID) constvalue.Value { + if value := module.Constants.ModuleValues[id]; value != nil { + return value } + return module.Constants.QueryCache[id] } func assertBoolConst(t *testing.T, module *project.Module, name string, want bool) { @@ -213,8 +337,9 @@ func assertBoolConst(t *testing.T, module *project.Module, name string, want boo if !ok || sym == nil { t.Fatalf("missing symbol %s", name) } - got, ok := module.Semantics.ConstValues[sym.ID].(*constvalue.BoolConst) + value := evaluatedConst(module, sym.ID) + got, ok := value.(*constvalue.BoolConst) if !ok || got == nil || got.Bool() != want { - t.Fatalf("%s = %#v, want bool %v", name, module.Semantics.ConstValues[sym.ID], want) + t.Fatalf("%s = %#v, want bool %v", name, value, want) } } diff --git a/internal/semantics/definiteinit/initialization.go b/internal/semantics/definiteinit/initialization.go index bd4e1c86..b37bce1d 100644 --- a/internal/semantics/definiteinit/initialization.go +++ b/internal/semantics/definiteinit/initialization.go @@ -2,11 +2,12 @@ package definiteinit import ( "compiler/internal/diagnostics" - "compiler/internal/frontend/ast" + graphcore "compiler/internal/graph" "compiler/internal/ir" "compiler/internal/ir/cfg" - "compiler/internal/semantics/flowresult" + "compiler/internal/semantics/effect" "compiler/internal/semantics/symbols" + "compiler/internal/source" ) type state map[symbols.SymbolID]struct{} @@ -16,22 +17,11 @@ type functionResult struct { Out map[cfg.SiteID]state } -type site struct { - cfgSite *cfg.Site - stmt ast.Stmt - condition ast.Expr - scope *symbols.Scope -} - // Check diagnoses reads not initialized on every reachable CFG predecessor. -func Check( - graphs *cfg.Module, - nodes map[ast.NodeID]ast.Node, - blockScopes map[ast.NodeID]*symbols.Scope, - resolvedSymbols map[ast.NodeID]*symbols.Symbol, - matches map[ast.NodeID]flowresult.Match, - diag *diagnostics.DiagnosticBag, -) { +// +// It consumes published effects and never inspects syntax, so a new construct +// that defines, writes, or reads a binding needs no case here. +func Check(graphs *cfg.Module, effects effect.Result, diag *diagnostics.DiagnosticBag) { if graphs == nil { return } @@ -39,76 +29,39 @@ func Check( if graph == nil { continue } - fn, _ := nodes[ast.NodeID(graph.NodeID)].(*ast.FnDecl) - if fn == nil { - continue - } - analyzeFunction(fn, graph, nodes, blockScopes, resolvedSymbols, matches, diag) + analyzeFunction(graph, effects[graph.NodeID], diag) } } -func analyzeFunction( - fn *ast.FnDecl, - graph *cfg.Graph, - nodes map[ast.NodeID]ast.Node, - blockScopes map[ast.NodeID]*symbols.Scope, - resolvedSymbols map[ast.NodeID]*symbols.Symbol, - matches map[ast.NodeID]flowresult.Match, - diag *diagnostics.DiagnosticBag, -) *functionResult { - sites, order, tracked := indexSites(fn, graph, nodes, blockScopes) +func analyzeFunction(graph *cfg.Graph, ops effect.SiteOps, diag *diagnostics.DiagnosticBag) *functionResult { result := &functionResult{In: make(map[cfg.SiteID]state), Out: make(map[cfg.SiteID]state)} if graph == nil || graph.Entry == nil || len(graph.Entry.Sites) == 0 { return result } + sites, order := indexSites(graph) + tracked := trackedSymbols(ops, order) + + // Parameters and match payload bindings arrive as initialized defines at the + // site that receives them, so entry needs no seeded state of its own. entry := graph.Entry.Sites[0].ID - entryState := make(state) - if fn != nil && fn.Body != nil { - functionScope := blockScopes[fn.Body.ID()] - for _, param := range fn.ParamsWithReceiver() { - if param.Name == nil { - continue - } - symbol, found := functionScope.Lookup(param.Name.Name) - if !found || symbol == nil { - continue - } - entryState[symbol.ID] = struct{}{} - tracked[symbol.ID] = symbol.Name + result.In[entry] = make(state) + work := graphcore.NewWorklist(entry) + for { + id, pending := work.Next() + if !pending { + break } - } - result.In[entry] = entryState - queue := []cfg.SiteID{entry} - queued := map[cfg.SiteID]bool{entry: true} - for len(queue) > 0 { - id := queue[0] - queue = queue[1:] - queued[id] = false - node := sites[id] - if node == nil { + site := sites[id] + if site == nil { continue } - out := transfer(node, result.In[id]) + out := transfer(ops[id], result.In[id]) result.Out[id] = out - for _, edge := range node.cfgSite.Successors { + for _, edge := range graph.SiteEdges.OutEdges(site.ID) { if sites[edge.To] == nil { continue } edgeState := copyState(out) - if edge.Kind == cfg.EdgeVariantCase { - match, found := matches[ast.NodeID(node.cfgSite.NodeID)] - if found { - if arm, armFound := match.Arm(edge.Case); armFound { - for _, field := range arm.Fields { - if field.Binding == nil { - continue - } - edgeState[field.Binding.ID] = struct{}{} - tracked[field.Binding.ID] = field.Binding.Name - } - } - } - } current, exists := result.In[edge.To] merged := edgeState if exists { @@ -118,162 +71,150 @@ func analyzeFunction( continue } result.In[edge.To] = merged - if !queued[edge.To] { - queue = append(queue, edge.To) - queued[edge.To] = true - } + work.Add(edge.To) } } + // Reporting walks declaration order rather than worklist order so diagnostics + // are deterministic. A site absent from In was never reached. for _, id := range order { if initialized, reachable := result.In[id]; reachable { - checkReads(sites[id], initialized, tracked, resolvedSymbols, diag) + checkReads(ops[id], initialized, tracked, diag) } } return result } -func indexSites( - fn *ast.FnDecl, - graph *cfg.Graph, - nodes map[ast.NodeID]ast.Node, - blockScopes map[ast.NodeID]*symbols.Scope, -) (map[cfg.SiteID]*site, []cfg.SiteID, map[symbols.SymbolID]string) { - sites := make(map[cfg.SiteID]*site) +func indexSites(graph *cfg.Graph) (map[cfg.SiteID]*cfg.Site, []cfg.SiteID) { + sites := make(map[cfg.SiteID]*cfg.Site) order := make([]cfg.SiteID, 0) - tracked := make(map[symbols.SymbolID]string) - if fn == nil || graph == nil { - return sites, order, tracked - } for _, block := range graph.Blocks { if block == nil || !block.Reachable { continue } - for _, cfgSite := range block.Sites { - if cfgSite == nil { + for _, site := range block.Sites { + if site == nil { continue } - indexed := &site{ - cfgSite: cfgSite, - scope: blockScopes[ast.NodeID(cfgSite.ScopeID)], - } - if stmt, ok := nodes[ast.NodeID(cfgSite.NodeID)].(ast.Stmt); ok { - indexed.stmt = stmt - } - if branch, ok := block.Terminator.(*cfg.Branch); ok && cfgSite.Kind == cfg.SiteTerminator { - indexed.condition, _ = nodes[ast.NodeID(branch.ConditionID)].(ast.Expr) - } - switch binding := indexed.stmt.(type) { - case *ast.LetDecl: - if indexed.scope != nil { - if symbol, found := indexed.scope.LookupNode(binding); found && symbol != nil { - tracked[symbol.ID] = symbol.Name - } - } - case *ast.ConstDecl: - if indexed.scope != nil { - if symbol, found := indexed.scope.LookupNode(binding); found && symbol != nil { - tracked[symbol.ID] = symbol.Name - } - } - } - sites[cfgSite.ID] = indexed - order = append(order, cfgSite.ID) + sites[site.ID] = site + order = append(order, site.ID) } } - return sites, order, tracked + return sites, order } -func transfer(node *site, in state) state { - out := copyState(in) - if node == nil || node.scope == nil { - return out - } - switch stmt := node.stmt.(type) { - case *ast.LetDecl: - if stmt.Value != nil { - if symbol, found := node.scope.LookupNode(stmt); found && symbol != nil { - out[symbol.ID] = struct{}{} - } - } - case *ast.ConstDecl: - if stmt.Value != nil { - if symbol, found := node.scope.LookupNode(stmt); found && symbol != nil { - out[symbol.ID] = struct{}{} - } - } - case *ast.AssignStmt: - if ident, direct := stmt.Target.(*ast.Ident); direct && ident != nil { - if symbol, found := node.scope.Lookup(ident.Name); found && symbol != nil { - out[symbol.ID] = struct{}{} - } +// trackedSymbols is the diagnosable universe: a binding this function defines. +// A symbol with no define belongs to an enclosing scope and is never reported. +func trackedSymbols(ops effect.SiteOps, order []cfg.SiteID) map[symbols.SymbolID]string { + tracked := make(map[symbols.SymbolID]string) + visitor := &initializationVisitor{tracked: tracked} + for _, id := range order { + for _, op := range ops[id] { + effect.Visit(op, visitor) } } + return tracked +} + +// transfer applies one site's effects in evaluation order. The lattice only +// gains initialized symbols, and the join intersects, so the fixed point +// terminates. +func transfer(ops []effect.Op, in state) state { + out := copyState(in) + visitor := &initializationVisitor{current: out, applyState: true} + for _, op := range ops { + effect.Visit(op, visitor) + } return out } -func checkReads( - node *site, - initialized state, - tracked map[symbols.SymbolID]string, - resolvedSymbols map[ast.NodeID]*symbols.Symbol, - diag *diagnostics.DiagnosticBag, -) { - if node == nil || diag == nil { +// checkReads reports a read of a tracked binding that is not initialized at +// that point. It replays the site's effects so a define earlier in the same +// site covers a read later in it. +func checkReads(ops []effect.Op, initialized state, tracked map[symbols.SymbolID]string, diag *diagnostics.DiagnosticBag) { + if diag == nil { return } - checkExpr := func(expr ast.Expr) { - ast.Inspect(expr, func(node ast.Node) bool { - ident, ok := node.(*ast.Ident) - if !ok { - return true - } - symbol := resolvedSymbols[ident.ID()] - if symbol == nil { - return true - } - name, local := tracked[symbol.ID] - if !local { - return true - } - if _, present := initialized[symbol.ID]; present { - return true - } - if name == "" { - name = ident.Name - } - name = ir.StripSymbolInstance(name) - msg := "symbol `" + name + "` used before it's initialized" - diag.Add(diagnostics.NewError(msg). - WithCode(diagnostics.ErrUninitializedVariable). - WithPrimaryLabel(ast.LocOf(ident), msg). - WithHelp("assign a value before reading this symbol")) - return true - }) + visitor := &initializationVisitor{ + current: initialized, tracked: tracked, diag: diag, + applyState: true, reportReads: true, } - switch stmt := node.stmt.(type) { - case *ast.LetDecl: - checkExpr(stmt.Value) - case *ast.ConstDecl: - checkExpr(stmt.Value) - case *ast.AssignStmt: - checkExpr(stmt.Value) - if _, direct := stmt.Target.(*ast.Ident); !direct { - checkExpr(stmt.Target) - } - case *ast.ExprStmt: - checkExpr(stmt.Expr) - case *ast.ReturnStmt: - checkExpr(stmt.Value) + visitor.current = copyState(initialized) + for _, op := range ops { + effect.Visit(op, visitor) + } +} + +// initializationVisitor is the exhaustive semantic-operation contract for +// definite initialization. Adding a new effect does not compile until this +// analysis explicitly classifies it. +type initializationVisitor struct { + current state + tracked map[symbols.SymbolID]string + diag *diagnostics.DiagnosticBag + applyState bool + reportReads bool +} + +func (v *initializationVisitor) VisitDefine(op effect.Define) { + if op.Symbol != nil && v.tracked != nil { + v.tracked[op.Symbol.ID] = op.Symbol.Name + } + if v.applyState && op.Initialized && op.Symbol != nil { + v.current[op.Symbol.ID] = struct{}{} + } +} + +func (v *initializationVisitor) VisitWrite(op effect.Write) { + if v.applyState && op.Place.Root != nil { + v.current[op.Place.Root.ID] = struct{}{} + } +} + +func (v *initializationVisitor) VisitUse(op effect.Use) { + if v.reportReads { + reportUninitializedRead(op.Place, op.Location, v.current, v.tracked, v.diag) + } +} + +func (v *initializationVisitor) VisitBorrow(op effect.Borrow) { + if v.reportReads { + reportUninitializedRead(op.Place, op.Location, v.current, v.tracked, v.diag) + } +} + +func (*initializationVisitor) VisitIterate(effect.Iterate) {} +func (*initializationVisitor) VisitDiscard(effect.Discard) {} +func (*initializationVisitor) VisitCallBegin(effect.CallBegin) {} +func (*initializationVisitor) VisitCallEnd(effect.CallEnd) {} + +func reportUninitializedRead(at effect.Place, location *source.Location, current state, tracked map[symbols.SymbolID]string, diag *diagnostics.DiagnosticBag) { + if at.Root == nil { + return + } + name, local := tracked[at.Root.ID] + if !local { + return + } + if _, present := current[at.Root.ID]; present { + return + } + if name == "" { + name = at.Root.Name } - checkExpr(node.condition) + name = ir.StripSymbolInstance(name) + msg := "symbol `" + name + "` used before it's initialized" + diag.Add(diagnostics.NewError(msg). + WithCode(diagnostics.ErrUninitializedVariable). + WithPrimaryLabel(location, msg). + WithHelp("assign a value before reading this symbol")) } func copyState(current state) state { - copy := make(state, len(current)) + copied := make(state, len(current)) for symbol := range current { - copy[symbol] = struct{}{} + copied[symbol] = struct{}{} } - return copy + return copied } func intersectState(left, right state) state { diff --git a/internal/semantics/definiteinit/initialization_test.go b/internal/semantics/definiteinit/initialization_test.go index edd210d0..e98c65da 100644 --- a/internal/semantics/definiteinit/initialization_test.go +++ b/internal/semantics/definiteinit/initialization_test.go @@ -10,9 +10,11 @@ import ( "compiler/internal/frontend/parser" "compiler/internal/ir" "compiler/internal/ir/cfg" + "compiler/internal/moduleid" "compiler/internal/project" "compiler/internal/semantics/binder" "compiler/internal/semantics/collector" + "compiler/internal/semantics/effect" "compiler/internal/semantics/resolver" "compiler/internal/semantics/typechecker" "compiler/pkg/peeper" @@ -25,22 +27,21 @@ func analyzeInitializationSource(t *testing.T, source string) (*functionResult, diag.AddSourceContent(filePath, source) ctx := project.New(".", peeper.SourceExt, diag) module := &project.Module{ - Key: project.ModuleKeyFor(project.ModuleOriginLocal, filePath), - ImportPath: "definite_init_test", - FilePath: filePath, - Content: source, - AST: parser.New(filePath, lexer.New(filePath, source, diag).Tokenize(), diag).ParseModule(), - Imports: make(map[string]project.ResolvedImport), + ID: moduleid.ID{Origin: string(project.ModuleOriginLocal), ImportPath: "definite_init_test"}, + FilePath: filePath, + Content: source, + AST: parser.New(filePath, lexer.New(filePath, source, diag).Tokenize(), diag).ParseModule(), + Imports: make(map[string]project.ResolvedImport), } ctx.AddModule(module) collector.Collect(ctx, module) binder.Bind(ctx, module) resolver.Resolve(ctx, module) typechecker.Check(ctx, module) - module.TypedASTNodes = ast.Index(module.AST) + module.RebuildTypedASTIndex() module.CFG = cfg.BuildModule(module.AST, cfg.BuildQueries{ - MatchCases: module.Semantics.MatchCases, - LoopGuaranteedEntry: module.Semantics.ForLoopGuaranteedEntry, + MatchCases: module.Typechecking.MatchCases, + LoopGuaranteedEntry: module.Typechecking.ForLoopGuaranteedEntry, }) symbol, found := module.ModuleScope.Lookup("choose") if !found || symbol == nil { @@ -54,15 +55,18 @@ func analyzeInitializationSource(t *testing.T, source string) (*functionResult, if graph == nil { t.Fatal("choose function CFG missing") } - result := analyzeFunction( - fn, - graph, - module.TypedASTNodes, - module.Semantics.BlockScopes, - module.Semantics.ResolvedSymbols, - module.Semantics.Matches, - diag, - ) + effects := effect.Build(module.CFG, module.TypedASTNodes, effect.BuildQueries{ + Symbols: module.Bindings.NodeSymbols, + Scopes: module.Bindings.BlockScopes, + CallArguments: module.Typechecking.CallArgumentsOrSource, + ArmBindings: module.Typechecking.ArmBindings, + StringConcatenation: module.Typechecking.StringConcatenation, + ValueUse: module.Typechecking.ValueUse, + ExprType: module.EffectiveExprType, + ReferenceArgument: module.Typechecking.ReferenceArgument, + SequenceCarrier: module.Typechecking.SequenceCarrier, + }) + result := analyzeFunction(graph, effects[graph.NodeID], diag) return result, diag, module } @@ -185,15 +189,21 @@ fn choose(result: Result) -> i32 { } fn := module.AST.Stmts[1].(*ast.FnDecl) match := fn.Body.Stmts[0].(*ast.MatchStmt) - binding := module.Semantics.ResolvedSymbols[match.Arms[0].Fields[0].Binding.ID()] + binding := module.Bindings.NodeSymbols[match.Arms[0].Fields[0].Binding.ID()] returnID := ir.NodeID(match.Arms[0].Body.Stmts[0].ID()) for _, block := range module.CFG.Function(ir.NodeID(fn.ID())).Blocks { for _, cfgSite := range block.Sites { if cfgSite.NodeID != returnID { continue } - if _, initialized := result.In[cfgSite.ID][binding.ID]; !initialized { - t.Fatalf("pattern binding absent at arm return: state=%#v", result.In[cfgSite.ID]) + // The binding is published as an initialized define at the arm + // block's first site, which is this return's own site, so it lands + // in Out rather than In. It used to be applied on the case edge and + // so appeared in In. The read of `payload` in the arm body is + // covered either way, because a site's effects are replayed in + // evaluation order and the define precedes the read. + if _, initialized := result.Out[cfgSite.ID][binding.ID]; !initialized { + t.Fatalf("pattern binding absent at arm return: state=%#v", result.Out[cfgSite.ID]) } return } @@ -209,3 +219,57 @@ func hasDiagnosticCode(diag *diagnostics.DiagnosticBag, code string) bool { } return false } + +// A match subject is a read like any other. This was previously undiagnosed: +// the analysis attached a site condition for a branch terminator only, so +// nothing checked the subject of a match. Publishing the subject as an ordinary +// read closed the gap, and this analysis learned nothing about matches to get it. +func TestInitializationChecksMatchSubject(t *testing.T) { + _, diag, _ := analyzeInitializationSource(t, `enum Outcome { + Ok: { value: i32 }, + Pending, +} + +fn choose(flag: bool) -> i32 { + let mut outcome: Outcome; + if flag { + outcome = Outcome::Pending; + } + match outcome { + Outcome::Ok with { value = payload } => { + return payload; + } + Outcome::Pending => { + return 0; + } + } +}`) + if !hasDiagnosticCode(diag, diagnostics.ErrUninitializedVariable) { + t.Fatalf("expected uninitialized match subject diagnostic:\n%s", diag.EmitAllToString()) + } + if got := diag.EmitAllToString(); !strings.Contains(got, "symbol `outcome` used before it's initialized") { + t.Fatalf("diagnostic does not name the match subject:\n%s", got) + } +} + +// A loop's iterated sequence is a read like any other. It was previously +// unchecked here: the analysis saw only a statement's own expressions and a +// branch condition, and a for statement carries neither. Ownership always read +// it, so publishing it once closed the gap for initialization too. +func TestInitializationChecksLoopIterable(t *testing.T) { + _, diag, _ := analyzeInitializationSource(t, `fn choose(flag: bool) -> i32 { + let mut limit: i32; + if flag { + limit = 3; + } + for i in 0..limit { + } + return 0; +}`) + if !hasDiagnosticCode(diag, diagnostics.ErrUninitializedVariable) { + t.Fatalf("expected uninitialized loop bound diagnostic:\n%s", diag.EmitAllToString()) + } + if got := diag.EmitAllToString(); !strings.Contains(got, "symbol `limit` used before it's initialized") { + t.Fatalf("diagnostic does not name the loop bound:\n%s", got) + } +} diff --git a/internal/semantics/effect/build.go b/internal/semantics/effect/build.go new file mode 100644 index 00000000..e4ea65be --- /dev/null +++ b/internal/semantics/effect/build.go @@ -0,0 +1,546 @@ +package effect + +import ( + "fmt" + + "compiler/internal/frontend/ast" + "compiler/internal/ir/cfg" + "compiler/internal/semantics/place" + "compiler/internal/semantics/symbols" + "compiler/internal/semantics/typeinfo" +) + +// BuildQueries supplies the published facts this producer needs. Like +// cfg.BuildQueries, it declares narrow accessors so this package does not +// import the artifacts that own them. +type BuildQueries struct { + // Symbols resolves a referenced identifier to its binding. The resolver + // indexes references only, so a declaration name and an assignment target + // are absent here and resolve through Scopes instead. + Symbols map[ast.NodeID]*symbols.Symbol + // Scopes resolves a CFG site's lexical scope, which is how a definition + // reaches its symbol. Definitions and references genuinely use two different + // mechanisms today; this producer reproduces that rather than changing it. + Scopes map[ast.NodeID]*symbols.Scope + // CallArguments returns a call's effective arguments, including any the + // typechecker expanded from a default. + CallArguments func(*ast.CallExpr) []ast.Expr + // ArmBindings returns the payload symbols one match arm binds. + ArmBindings func(match ast.NodeID, caseIndex int) []*symbols.Symbol + // StringConcatenation reports a binary expression the typechecker resolved + // as string concatenation, which consumes its left operand. + StringConcatenation func(ast.NodeID) bool + // ValueUse returns the typechecker's decision for one expression, where it + // made one. Call arguments have one; most positions do not, and the walk + // decides those from the position itself. + ValueUse func(ast.NodeID) (typeinfo.UseKind, bool) + // ExprType is the flow-refined type of an expression, which decides whether + // a slice borrows mutably. + ExprType func(ast.NodeID) typeinfo.Type + // ReferenceArgument reports an argument whose parameter is a reference, and + // whether that reference is mutable. The borrow exists because of the + // parameter type rather than because the source wrote an ampersand. + ReferenceArgument func(ast.NodeID) (mutable bool, found bool) + // SequenceCarrier reports the hidden carrier a typed sequence loop keeps for + // the loop lifetime. Range loops return found=false. This is typechecker + // evidence; the effect producer must not rediscover iteration kind. + SequenceCarrier func(ast.NodeID) (carrier *symbols.Symbol, found bool) +} + +// Build publishes the semantic effects of every reachable CFG site. +// +// This is the only place that inspects syntax to decide what a construct does +// to a binding. It runs after flow typing so published evidence is final. +func Build(graphs *cfg.Module, nodes map[ast.NodeID]ast.Node, queries BuildQueries) Result { + if graphs == nil || queries.Symbols == nil || queries.Scopes == nil { + return nil + } + result := make(Result, len(graphs.Functions)) + for _, graph := range graphs.Functions { + if graph == nil { + continue + } + fn, _ := nodes[ast.NodeID(graph.NodeID)].(*ast.FnDecl) + if fn == nil { + continue + } + b := &builder{nodes: nodes, queries: queries, graph: graph, ops: make(SiteOps)} + b.buildFunction(fn) + result[graph.NodeID] = b.ops + } + return result +} + +type builder struct { + nodes map[ast.NodeID]ast.Node + queries BuildQueries + graph *cfg.Graph + ops SiteOps +} + +func (b *builder) emit(site cfg.SiteID, op Op) { + b.ops[site] = append(b.ops[site], op) +} + +func (b *builder) buildFunction(fn *ast.FnDecl) { + if b.graph.Entry == nil || len(b.graph.Entry.Sites) == 0 { + return + } + entry := b.graph.Entry.Sites[0].ID + if fn.Body != nil { + functionScope := b.queries.Scopes[fn.Body.ID()] + for _, param := range fn.ParamsWithReceiver() { + if param.Name == nil { + continue + } + sym, found := functionScope.Lookup(param.Name.Name) + if !found || sym == nil { + continue + } + b.emit(entry, Define{Symbol: sym, Node: param.Name.ID(), Initialized: true, OnEntry: true}) + } + } + // Bindings that a site inherits on entry are published before that site's + // own effects, because a site's operations are read in evaluation order and + // an arm body may read the payload it binds. + b.eachSite(func(block *cfg.Block, site *cfg.Site) { + if site.Kind != cfg.SiteTerminator { + return + } + if terminator, ok := block.Terminator.(*cfg.SwitchVariant); ok { + b.buildMatchArms(site, terminator) + } + }) + b.eachSite(b.buildSite) +} + +func (b *builder) eachSite(visit func(*cfg.Block, *cfg.Site)) { + for _, block := range b.graph.Blocks { + if block == nil || !block.Reachable { + continue + } + for _, site := range block.Sites { + if site == nil { + continue + } + visit(block, site) + } + } +} + +func (b *builder) buildSite(block *cfg.Block, site *cfg.Site) { + stmt, _ := b.nodes[ast.NodeID(site.NodeID)].(ast.Stmt) + if stmt != nil { + b.publishStmt(site.ID, b.queries.Scopes[ast.NodeID(site.ScopeID)], stmt) + } + if site.Kind != cfg.SiteTerminator { + return + } + switch terminator := block.Terminator.(type) { + case *cfg.Branch: + if condition, ok := b.nodes[ast.NodeID(terminator.ConditionID)].(ast.Expr); ok { + b.value(site.ID, b.queries.Scopes[ast.NodeID(site.ScopeID)], condition, typeinfo.UseRead) + } + case *cfg.SwitchVariant: + // Arm payload bindings are published in the leading pass above; the + // subject itself is read at the match statement's own site. + case *cfg.Jump, *cfg.Return: + // A jump carries no condition, and a return's value is read at the + // return statement's own site. + default: + panic(fmt.Sprintf("effect: unhandled CFG terminator %T", block.Terminator)) + } +} + +// buildMatchArms publishes each arm's payload bindings at the first site of +// that arm's body. CFG construction gives every arm a fresh block reached only +// by its own case edge, so a site-keyed define is equivalent to attaching it to +// the edge. +func (b *builder) buildMatchArms(site *cfg.Site, terminator *cfg.SwitchVariant) { + if b.queries.ArmBindings == nil { + return + } + for _, edge := range b.graph.SiteEdges.OutEdges(site.ID) { + if edge.Kind != cfg.EdgeVariantCase { + continue + } + for _, sym := range b.queries.ArmBindings(ast.NodeID(terminator.NodeID), edge.Case) { + if sym == nil { + continue + } + b.emit(edge.To, Define{Symbol: sym, Node: ast.NodeID(terminator.NodeID), Initialized: true, OnEntry: true}) + } + } +} + +// publishStmt publishes one statement's effects in evaluation order. +// +// CFG construction panics on a statement it does not place, and this is now the +// single producer of statement meaning, so it takes the same policy: a new kind +// must be handled here or declared inert in internal/contracts. +func (b *builder) publishStmt(site cfg.SiteID, scope *symbols.Scope, stmt ast.Stmt) { + switch node := stmt.(type) { + case *ast.LetDecl: + b.buildBinding(site, scope, node, node.Value) + case *ast.ConstDecl: + b.buildBinding(site, scope, node, node.Value) + case *ast.AssignStmt: + b.value(site, scope, node.Value, typeinfo.UseMove) + b.writeTarget(site, scope, node.Target, node.ID(), node.Value) + case *ast.ExprStmt: + b.value(site, scope, node.Expr, typeinfo.UseRead) + b.emit(site, Discard{ + Place: b.placeOrTemporary(scope, node.Expr), + Node: node.Expr.ID(), + Location: ast.LocOf(node.Expr), + }) + case *ast.ReturnStmt: + b.value(site, scope, node.Value, typeinfo.UseMove) + case *ast.MatchStmt: + // A match reaches this producer at its terminator site, and at a plain + // statement site when semantic evidence was too incomplete for CFG to + // decompose it. Publishing the subject here covers both. + b.value(site, scope, node.Subject, typeinfo.UseRead) + case *ast.ForStmt: + // The condition is published from the terminator, which names it + // directly. The iterated sequence is evaluated by the loop itself and + // belongs here. A typed sequence loop additionally holds a shared access + // to its iterable until the loop exit; publish that lifetime fact here + // instead of making ownership recognize ForStmt. + b.value(site, scope, node.Iterable, typeinfo.UseRead) + if node.Iterable != nil && b.queries.SequenceCarrier != nil { + if carrier, found := b.queries.SequenceCarrier(node.ID()); found && carrier != nil { + b.emit(site, Iterate{ + Loop: node.ID(), Place: b.placeOrTemporary(scope, node.Iterable), + Node: node.Iterable.ID(), Carrier: carrier, Location: ast.LocOf(node.Iterable), + }) + } + } + case *ast.IfStmt: + // A branch condition is published from the terminator, which names it + // directly, so a site carries exactly the reads that happen at it. + case *ast.BlockStmt: + // Blocks are decomposed by CFG construction; a scope-exit site names one + // but evaluates nothing. + case *ast.BreakStmt, *ast.ContinueStmt: + // A transfer is a CFG edge and evaluates no expression. + case *ast.BadStmt, *ast.BadDecl: + // Recovery nodes carry no evidence. + case *ast.ImportDecl, *ast.FnDecl, *ast.TypeAliasDecl, + *ast.StructDecl, *ast.InterfaceDecl, *ast.EnumDecl: + // Declarations reach a CFG site after the resolver reports them as + // unsupported statements. They evaluate nothing here. + default: + panic(fmt.Sprintf("effect: unhandled AST statement %T", stmt)) + } +} + +// buildBinding publishes a declaration's initializer reads before the define +// they initialize, so `let x = x` reads an outer binding rather than itself. +func (b *builder) buildBinding(site cfg.SiteID, scope *symbols.Scope, decl ast.Stmt, value ast.Expr) { + b.value(site, scope, value, typeinfo.UseMove) + if scope == nil { + return + } + sym, found := scope.LookupNode(decl) + if !found || sym == nil { + return + } + valueID := ast.NodeID(0) + if value != nil { + valueID = value.ID() + } + b.emit(site, Define{Symbol: sym, Node: decl.ID(), Value: valueID, Initialized: value != nil}) +} + +// value publishes what one expression does to the bindings it names. +// +// It mirrors the propagation ownership used to perform itself: a position +// decides the kind, and the kind travels down to the identifier that carries +// the value. A projection reads its base, a literal moves what it stores, and a +// call argument takes the typechecker's published decision. +// +// This is the expression dispatch site for published effects. A new expression +// kind must be handled here or declared inert in internal/contracts. +func (b *builder) value(site cfg.SiteID, scope *symbols.Scope, expr ast.Expr, kind typeinfo.UseKind) { + switch node := expr.(type) { + case nil: + return + case *ast.Ident: + if sym := b.queries.Symbols[node.ID()]; sym != nil { + b.emit(site, Use{Place: Place{Root: sym}, Node: node.ID(), Location: ast.LocOf(node), Kind: kind}) + } + case *ast.AddressExpr: + b.borrow(site, scope, node, node.Expr, nil, node.Mode == ast.AddressMutable, node.Mode == ast.AddressRaw) + case *ast.SelectorExpr: + // A field of a place is itself a place, so the use lands on the + // projection rather than on the whole aggregate. Structural projection + // shape comes from place.Project, the canonical place grammar. + projection, ok := place.Project(node) + if !ok { + return + } + b.projection(site, scope, node, projection.Base, projection.Step, kind) + case *ast.IndexExpr: + // Slicing does not read an element out; it borrows a run of the + // sequence, mutably when the slice itself is a mutable reference. + // A full range writes no bounds at all, so the index may be absent + // rather than an empty range. + _, ranged := node.Index.(*ast.RangeExpr) + if ranged || node.Index == nil { + b.borrow(site, scope, node, node.Expr, node.Index, b.mutableReference(node.ID()), false) + return + } + projection, ok := place.Project(node) + if !ok { + return + } + b.projection(site, scope, node, projection.Base, projection.Step, kind) + case *ast.RangeExpr: + b.value(site, scope, node.Start, typeinfo.UseRead) + b.value(site, scope, node.End, typeinfo.UseRead) + case *ast.StructLit: + for _, field := range node.Fields { + b.value(site, scope, field.Value, typeinfo.UseMove) + } + case *ast.VariantLit: + b.value(site, scope, node.Payload, typeinfo.UseMove) + case *ast.ArrayLit: + for _, element := range node.Values { + b.value(site, scope, element, typeinfo.UseMove) + } + case *ast.CallExpr: + b.emit(site, CallBegin{Node: node.ID(), Location: ast.LocOf(node)}) + if selector, method := node.Callee.(*ast.SelectorExpr); method && selector != nil { + // A method callee names a method, not storage. The receiver is the + // value the call uses, and it is used the way the receiver + // parameter demands, borrow included, exactly like any argument. + b.argument(site, scope, selector.Expr) + } else { + b.value(site, scope, node.Callee, typeinfo.UseRead) + } + arguments := node.Args + if b.queries.CallArguments != nil { + arguments = b.queries.CallArguments(node) + } + for _, argument := range arguments { + b.argument(site, scope, argument) + } + b.emit(site, CallEnd{Node: node.ID()}) + case *ast.FreeExpr: + b.value(site, scope, node.Expr, typeinfo.UseMove) + case *ast.PrintExpr: + b.value(site, scope, node.Expr, typeinfo.UseRead) + case *ast.UnaryExpr: + b.value(site, scope, node.Expr, typeinfo.UseRead) + case *ast.BinaryExpr: + if b.queries.StringConcatenation != nil && b.queries.StringConcatenation(node.ID()) { + // Concatenation consumes the left operand into the result. + b.value(site, scope, node.Left, typeinfo.UseMove) + b.value(site, scope, node.Right, typeinfo.UseRead) + return + } + b.value(site, scope, node.Left, typeinfo.UseRead) + b.value(site, scope, node.Right, typeinfo.UseRead) + case *ast.IsExpr: + b.value(site, scope, node.Value, typeinfo.UseRead) + case *ast.AsExpr: + b.value(site, scope, node.Expr, typeinfo.UseMove) + case *ast.ScopeResolution, *ast.NumberLit, *ast.StringLit, *ast.ByteLit, + *ast.CharLit, *ast.BoolLit, *ast.NoneLit, *ast.BadExpr: + // These name no binding whose value is used. + default: + panic(fmt.Sprintf("effect: unhandled AST expression %T", expr)) + } +} + +// argumentKind takes the typechecker's decision where it made one. An absent +// decision means the call did not resolve, which is invalid source continuing +// through the phase, and a read is the answer that claims least. +func (b *builder) argumentKind(argument ast.Expr) typeinfo.UseKind { + if argument == nil || b.queries.ValueUse == nil { + return typeinfo.UseRead + } + if kind, found := b.queries.ValueUse(argument.ID()); found { + return kind + } + return typeinfo.UseRead +} + +// placeOf resolves a syntactic place through the canonical place structure. +// The place package owns selector/index decomposition; this producer only maps +// the root identifier to its already-resolved symbol. +func (b *builder) placeOf(scope *symbols.Scope, expr ast.Expr) (Place, bool) { + root, projections, ok := place.Decompose(expr) + if !ok { + return Place{}, false + } + ident, ok := root.(*ast.Ident) + if !ok || ident == nil { + return Place{}, false + } + sym := b.queries.Symbols[ident.ID()] + if sym == nil && scope != nil { + sym, _ = scope.Lookup(ident.Name) + } + if sym == nil { + return Place{}, false + } + return Place{Root: sym, Projections: append([]place.OriginProjection(nil), projections...)}, true +} + +// placeOperands evaluates what is needed to reach storage, without charging a +// second use of the root binding. Decomposing a place alone loses the index +// expressions; they must run from the innermost base outward before the access. +func (b *builder) placeOperands(site cfg.SiteID, scope *symbols.Scope, expr ast.Expr) { + if projection, projected := place.Project(expr); projected { + if place.IsPlaceExpr(projection.Base) { + b.placeOperands(site, scope, projection.Base) + } else { + // Preserve uses of intermediate temporary projections as well as the + // evaluation that produces their base. + b.value(site, scope, projection.Base, typeinfo.UseRead) + } + b.value(site, scope, projection.Index, typeinfo.UseRead) + return + } + if _, binding := expr.(*ast.Ident); !binding { + b.value(site, scope, expr, typeinfo.UseRead) + } +} + +// projection publishes a use of one projected place. When the base names +// storage the use roots at that binding; otherwise the base is a temporary, +// which still has its own effects and is walked before the projection is +// published. +func (b *builder) projection(site cfg.SiteID, scope *symbols.Scope, whole, base ast.Expr, step place.OriginProjection, kind typeinfo.UseKind) { + b.placeOperands(site, scope, whole) + if rooted, ok := b.placeOf(scope, whole); ok { + b.emit(site, Use{Place: rooted, Node: whole.ID(), Location: ast.LocOf(whole), Kind: kind}) + return + } + b.emit(site, Use{ + Place: Place{Temporary: base.ID(), Projections: []place.OriginProjection{step}}, + Node: whole.ID(), + Location: ast.LocOf(whole), + Kind: kind, + }) +} + +// placeOrTemporary names what an expression denotes: the binding it reaches, or +// the expression itself when it produces a value that lives nowhere. +func (b *builder) placeOrTemporary(scope *symbols.Scope, expr ast.Expr) Place { + if expr == nil { + return Place{} + } + if rooted, ok := b.placeOf(scope, expr); ok { + return rooted + } + return Place{Temporary: expr.ID()} +} + +// writeTarget publishes the store an assignment performs. +// +// A projection target still evaluates the values inside it — an index is read +// to reach the element it selects — so those are published before the write +// itself. +func (b *builder) writeTarget(site cfg.SiteID, scope *symbols.Scope, target ast.Expr, owner ast.NodeID, value ast.Expr) { + valueID := ast.NodeID(0) + if value != nil { + valueID = value.ID() + } + if ident, ok := target.(*ast.Ident); ok { + if ident == nil || scope == nil { + return + } + sym := b.queries.Symbols[ident.ID()] + if sym == nil { + sym, _ = scope.Lookup(ident.Name) + } + if sym != nil { + b.emit(site, Write{Place: Place{Root: sym}, Node: ident.ID(), Owner: owner, Value: valueID, Location: ast.LocOf(ident)}) + } + return + } + + projection, projected := place.Project(target) + if !projected { + // Anything else is not a place; the typechecker rejects it as a target, + // and its own effects are all it contributes here. + b.value(site, scope, target, typeinfo.UseRead) + return + } + b.writeProjection(site, scope, target, projection.Base, projection.Step, owner, valueID) +} + +func (b *builder) writeProjection(site cfg.SiteID, scope *symbols.Scope, whole, base ast.Expr, step place.OriginProjection, owner, value ast.NodeID) { + b.placeOperands(site, scope, whole) + if rooted, ok := b.placeOf(scope, whole); ok { + b.emit(site, Write{Place: rooted, Node: whole.ID(), Owner: owner, Value: value, Location: ast.LocOf(whole)}) + return + } + b.emit(site, Write{ + Place: Place{Temporary: base.ID(), Projections: []place.OriginProjection{step}}, + Node: whole.ID(), + Owner: owner, + Value: value, + Location: ast.LocOf(whole), + }) +} + +// mutableReference reports whether an expression's own type is a mutable +// reference, which is what makes a slice of it a mutable borrow. +func (b *builder) mutableReference(id ast.NodeID) bool { + if b.queries.ExprType == nil { + return false + } + _, mutable, reference := typeinfo.ReferenceTarget(typeinfo.Underlying(b.queries.ExprType(id))) + return reference && mutable +} + +// argument publishes what one call argument does. +// +// A reference parameter borrows, whether or not the source wrote an ampersand, +// and that borrow is the argument's whole effect: publishing a read beside it +// would charge the same place twice. Everything else is an ordinary value use. +func (b *builder) argument(site cfg.SiteID, scope *symbols.Scope, argument ast.Expr) { + if argument == nil { + return + } + mutable, borrows := false, false + if b.queries.ReferenceArgument != nil { + mutable, borrows = b.queries.ReferenceArgument(argument.ID()) + } + if !borrows { + b.value(site, scope, argument, b.argumentKind(argument)) + return + } + operand := argument + if address, explicit := argument.(*ast.AddressExpr); explicit { + operand = address.Expr + } + b.placeOperands(site, scope, operand) + b.emit(site, Borrow{ + Place: b.placeOrTemporary(scope, operand), + Node: argument.ID(), + Operand: operand.ID(), + Location: ast.LocOf(argument), + Mutable: mutable, + Argument: true, + }) +} + +// borrow publishes a reference taken to a place. Values inside the operand that +// are evaluated to reach it, such as an index, are published first. +func (b *builder) borrow(site cfg.SiteID, scope *symbols.Scope, whole, operand, bounds ast.Expr, mutable, raw bool) { + b.placeOperands(site, scope, operand) + b.value(site, scope, bounds, typeinfo.UseRead) + b.emit(site, Borrow{ + Place: b.placeOrTemporary(scope, operand), + Node: whole.ID(), + Operand: operand.ID(), + Location: ast.LocOf(whole), + Mutable: mutable, + Raw: raw, + }) +} diff --git a/internal/semantics/effect/build_test.go b/internal/semantics/effect/build_test.go new file mode 100644 index 00000000..f066e45a --- /dev/null +++ b/internal/semantics/effect/build_test.go @@ -0,0 +1,457 @@ +package effect_test + +import ( + "testing" + + "compiler/internal/diagnostics" + "compiler/internal/frontend/ast" + "compiler/internal/frontend/lexer" + "compiler/internal/frontend/parser" + "compiler/internal/ir" + "compiler/internal/ir/cfg" + "compiler/internal/moduleid" + "compiler/internal/project" + "compiler/internal/semantics/binder" + "compiler/internal/semantics/collector" + "compiler/internal/semantics/effect" + "compiler/internal/semantics/place" + "compiler/internal/semantics/resolver" + "compiler/internal/semantics/typechecker" + "compiler/pkg/peeper" +) + +func buildEffects(t *testing.T, source string) (effect.Result, *project.Module) { + t.Helper() + const filePath = "effect_test" + peeper.SourceExt + diag := diagnostics.NewDiagnosticBag() + diag.AddSourceContent(filePath, source) + ctx := project.New(".", peeper.SourceExt, diag) + module := &project.Module{ + ID: moduleid.ID{Origin: string(project.ModuleOriginLocal), ImportPath: "effect_test"}, + FilePath: filePath, + Content: source, + AST: parser.New(filePath, lexer.New(filePath, source, diag).Tokenize(), diag).ParseModule(), + Imports: make(map[string]project.ResolvedImport), + } + ctx.AddModule(module) + collector.Collect(ctx, module) + binder.Bind(ctx, module) + resolver.Resolve(ctx, module) + typechecker.Check(ctx, module) + module.RebuildTypedASTIndex() + module.CFG = cfg.BuildModule(module.AST, cfg.BuildQueries{ + MatchCases: module.Typechecking.MatchCases, + LoopGuaranteedEntry: module.Typechecking.ForLoopGuaranteedEntry, + }) + if diag.HasErrors() { + t.Fatalf("unexpected diagnostics:\n%s", diag.EmitAllToString()) + } + result := effect.Build(module.CFG, module.TypedASTNodes, effect.BuildQueries{ + Symbols: module.Bindings.NodeSymbols, + Scopes: module.Bindings.BlockScopes, + CallArguments: module.Typechecking.CallArgumentsOrSource, + ArmBindings: module.Typechecking.ArmBindings, + StringConcatenation: module.Typechecking.StringConcatenation, + ValueUse: module.Typechecking.ValueUse, + ExprType: module.EffectiveExprType, + ReferenceArgument: module.Typechecking.ReferenceArgument, + SequenceCarrier: module.Typechecking.SequenceCarrier, + }) + if result == nil { + t.Fatal("Build published no result") + } + if err := module.CFG.Validate(); err != nil { + t.Fatalf("constructed CFG rejected: %v", err) + } + if err := result.Validate(module.CFG, module.TypedASTNodes); err != nil { + t.Fatalf("published effects rejected: %v", err) + } + return result, module +} + +// publishedOps flattens one function's effects in site order so a test can +// state the published sequence without naming SiteIDs. +func publishedOps(t *testing.T, result effect.Result, module *project.Module, name string) []string { + t.Helper() + symbol, found := module.ModuleScope.Lookup(name) + if !found || symbol == nil { + t.Fatalf("function %q missing", name) + } + fn, ok := symbol.ASTNode.(*ast.FnDecl) + if !ok || fn == nil { + t.Fatalf("function %q has no declaration", name) + } + graph := module.CFG.Function(ir.NodeID(fn.ID())) + if graph == nil { + t.Fatalf("function %q has no CFG", name) + } + published := make([]string, 0) + for _, block := range graph.Blocks { + if block == nil || !block.Reachable { + continue + } + for _, site := range block.Sites { + if site == nil { + continue + } + for _, op := range result.At(graph.NodeID, site.ID) { + published = append(published, describe(op)) + } + } + } + return published +} + +func describe(op effect.Op) string { + switch op := op.(type) { + case effect.Define: + if op.Initialized { + return "define " + op.Symbol.Name + } + return "declare " + op.Symbol.Name + case effect.Write: + return "write " + op.Place.Root.Name + case effect.Use: + if op.Place.Root == nil { + return "use temporary" + } + return "use " + op.Place.Root.Name + case effect.Borrow: + if op.Place.Root == nil { + return "borrow temporary" + } + return "borrow " + op.Place.Root.Name + case effect.CallBegin: + return "call" + case effect.CallEnd: + return "end" + case effect.Discard: + return "discard" + case effect.Iterate: + if op.Place.Root == nil { + return "iterate temporary" + } + return "iterate " + op.Place.Root.Name + } + return "unknown" +} + +func TestBuildPublishesProjectionOperands(t *testing.T) { + for _, test := range []struct { + name string + source string + want []string + }{ + { + name: "nested read", + source: `fn probe(matrix: [2][2]i32, i: i32, j: i32) -> i32 { return matrix[i][j]; }`, + want: []string{"define matrix", "define i", "define j", "use i", "use j", "use matrix"}, + }, + { + name: "field after index", + source: `struct Row { field: i32 } +fn probe(rows: [2]Row, i: i32) -> i32 { return rows[i].field; }`, + want: []string{"define rows", "define i", "use i", "use rows"}, + }, + { + name: "write after RHS", + source: `fn probe(mut matrix: [2][2]i32, i: i32, j: i32, value: i32) { matrix[i][j] = value; }`, + want: []string{"define matrix", "define i", "define j", "define value", "use value", "use i", "use j", "write matrix"}, + }, + { + name: "explicit borrow", + source: `fn probe(matrix: [2][2]i32, i: i32, j: i32) { let view = &matrix[i][j]; }`, + want: []string{"define matrix", "define i", "define j", "use i", "use j", "borrow matrix", "define view"}, + }, + { + name: "implicit borrow", + source: `struct Cell { value: i32 } +fn (cell: &Cell) take() -> i32 { return cell.value; } +fn probe(matrix: [2][2]Cell, i: i32, j: i32) -> i32 { return matrix[i][j].take(); }`, + want: []string{"define matrix", "define i", "define j", "call", "use i", "use j", "borrow matrix", "end"}, + }, + { + name: "slice bounds after base", + source: `fn probe(matrix: [2][2]i32, i: i32, start: i32, end: i32) { let view = matrix[i][start..end]; }`, + want: []string{"define matrix", "define i", "define start", "define end", "use i", "use start", "use end", "borrow matrix", "define view"}, + }, + { + name: "full slice", + source: `fn probe(matrix: [2][2]i32, i: i32) { let view = matrix[i][..]; }`, + want: []string{"define matrix", "define i", "use i", "borrow matrix", "define view"}, + }, + { + name: "calls in indexes", + source: `fn first() -> i32 { return 0; } +fn second() -> i32 { return 1; } +fn probe(matrix: [2][2]i32) -> i32 { return matrix[first()][second()]; }`, + want: []string{"define matrix", "call", "use first", "end", "call", "use second", "end", "use matrix"}, + }, + { + name: "temporary receiver", + source: `struct Cell { value: i32 } +fn (cell: &Cell) take() -> i32 { return cell.value; } +fn make() -> Cell { return .Cell{value = 1}; } +fn probe() -> i32 { return make().take(); }`, + want: []string{"call", "call", "use make", "end", "borrow temporary", "end"}, + }, + { + name: "temporary base", + source: `fn make() -> [2]i32 { return [2]i32{1, 2}; } +fn probe(i: i32) -> i32 { return make()[i]; }`, + want: []string{"define i", "call", "use make", "end", "use i", "use temporary"}, + }, + } { + t.Run(test.name, func(t *testing.T) { + result, module := buildEffects(t, test.source) + got := publishedOps(t, result, module, "probe") + if !sameOps(got, test.want) { + t.Fatalf("published %v, want %v", got, test.want) + } + }) + } +} + +func TestBuildPublishesSequenceIterationLifetime(t *testing.T) { + result, module := buildEffects(t, `fn walk(values: [2]i32) { + for value in values {} +}`) + got := publishedOps(t, result, module, "walk") + want := []string{"define values", "use values", "iterate values"} + if !sameOps(got, want) { + t.Fatalf("published %v, want %v", got, want) + } +} + +func sameOps(got, want []string) bool { + if len(got) != len(want) { + return false + } + for i := range got { + if got[i] != want[i] { + return false + } + } + return true +} + +// Parameters are initialized on entry, and a binding's initializer is read +// before the binding it defines. That order is what makes `let x = x` resolve +// against an outer binding rather than itself. +func TestBuildPublishesReadsBeforeTheDefineTheyInitialize(t *testing.T) { + result, module := buildEffects(t, `fn add(a: i32, b: i32) -> i32 { + let total = a + b; + return total; +}`) + got := publishedOps(t, result, module, "add") + want := []string{ + "define a", "define b", + "use a", "use b", "define total", + "use total", + } + if !sameOps(got, want) { + t.Fatalf("published %v, want %v", got, want) + } +} + +// An assignment reads its value before it writes its target. +func TestBuildPublishesAssignmentAsReadThenWrite(t *testing.T) { + result, module := buildEffects(t, `fn bump(start: i32) -> i32 { + let mut count = start; + count = count + 1; + return count; +}`) + got := publishedOps(t, result, module, "bump") + want := []string{ + "define start", + "use start", "define count", + "use count", "write count", + "use count", + } + if !sameOps(got, want) { + t.Fatalf("published %v, want %v", got, want) + } +} + +// A declaration with no initializer declares storage without initializing it. +// That distinction is the whole basis of definite initialization. +func TestBuildDistinguishesUninitializedDeclaration(t *testing.T) { + result, module := buildEffects(t, `fn choose(flag: bool) -> i32 { + let mut value: i32; + if flag { + value = 7; + } else { + value = 3; + } + return value; +}`) + got := publishedOps(t, result, module, "choose") + want := []string{ + "define flag", + "declare value", + "use flag", + "write value", + "write value", + "use value", + } + if !sameOps(got, want) { + t.Fatalf("published %v, want %v", got, want) + } +} + +// A branch condition belongs to the terminator site, not to the statement, so +// each site carries exactly the reads that happen at it. +func TestBuildPublishesBranchConditionAtTerminatorSite(t *testing.T) { + result, module := buildEffects(t, `fn gate(flag: bool) -> i32 { + if flag { + return 1; + } + return 0; +}`) + got := publishedOps(t, result, module, "gate") + want := []string{"define flag", "use flag"} + if !sameOps(got, want) { + t.Fatalf("published %v, want %v", got, want) + } +} + +// An arm's payload binding is published before that arm body's own effects, +// because the body may read the payload it binds. The binding is published at +// the arm block's first site rather than on the case edge, which is equivalent +// because CFG construction gives every arm a fresh block reached only by its +// own case edge. +func TestBuildPublishesArmBindingBeforeArmBodyEffects(t *testing.T) { + result, module := buildEffects(t, `enum Result { + Ok: { value: i32 }, + Pending, +} + +fn choose(outcome: Result) -> i32 { + match outcome { + Result::Ok with { value = payload } => { + return payload; + } + Result::Pending => { + return 0; + } + } +}`) + got := publishedOps(t, result, module, "choose") + // The subject is published at the match's own site. Ownership's liveness + // needs it, and publishing it also closed the gap where definite + // initialization never checked a match subject for initialization. + want := []string{ + "define outcome", + "use outcome", + "define payload", "use payload", + } + if !sameOps(got, want) { + t.Fatalf("published %v, want %v", got, want) + } +} + +// A use of a field or an element names that place, not the whole aggregate. +// Ownership needs the distinction: moving out of `pair.left` is a different +// decision from moving `pair`, and its diagnostics say so. +func TestBuildPublishesProjectedPlaces(t *testing.T) { + result, module := buildEffects(t, `struct Pair { left: i32, right: i32 } + +fn read(values: [3]i32, pair: Pair, index: i32) -> i32 { + return pair.left + values[index]; +}`) + symbol, found := module.ModuleScope.Lookup("read") + if !found { + t.Fatal("function read missing") + } + fn := symbol.ASTNode.(*ast.FnDecl) + graph := module.CFG.Function(ir.NodeID(fn.ID())) + if graph == nil { + t.Fatal("function read has no CFG") + } + + projected := make([]string, 0) + for _, block := range graph.Blocks { + for _, site := range block.Sites { + for _, op := range result.At(graph.NodeID, site.ID) { + use, ok := op.(effect.Use) + if !ok || len(use.Place.Projections) == 0 { + continue + } + for _, step := range use.Place.Projections { + switch step.Kind { + case place.OriginField: + projected = append(projected, use.Place.Root.Name+"."+step.Field) + case place.OriginIndex: + projected = append(projected, use.Place.Root.Name+"[]") + } + } + } + } + } + want := []string{"pair.left", "values[]"} + if !sameOps(projected, want) { + t.Fatalf("projected places = %v, want %v", projected, want) + } +} + +// A method callee names a method, not storage. Publishing `point.copy` as a +// field projection of point would claim a field that does not exist, and would +// hand ownership a projected place where the real effect is a use of the +// receiver. +// +// The receiver is also where an implicit borrow lives: nothing in this source +// says `&`, but the receiver parameter is `&Point`, so passing `point` borrows +// it. The typechecker records that in ImplicitCallArguments. +func TestBuildPublishesMethodReceiverAsWholeUse(t *testing.T) { + result, module := buildEffects(t, `struct Point { x: i32, y: i32 } + +fn (self: &Point) copy() -> Point { + return .{x = self.x, y = self.y}; +} + +fn choose(point: Point) -> i32 { + let duplicate = point.copy(); + return duplicate.x; +}`) + symbol, found := module.ModuleScope.Lookup("choose") + if !found { + t.Fatal("function choose missing") + } + fn := symbol.ASTNode.(*ast.FnDecl) + graph := module.CFG.Function(ir.NodeID(fn.ID())) + + var receiver *effect.Borrow + var field *effect.Use + for _, block := range graph.Blocks { + for _, site := range block.Sites { + for _, op := range result.At(graph.NodeID, site.ID) { + switch op := op.(type) { + case effect.Borrow: + if op.Place.Root != nil && op.Place.Root.Name == "point" { + copied := op + receiver = &copied + } + case effect.Use: + if op.Place.Root != nil && op.Place.Root.Name == "duplicate" { + copied := op + field = &copied + } + } + } + } + } + // The receiver parameter is `&Point`, so the call borrows the receiver + // rather than reading it, and it borrows the whole binding. + if receiver == nil || len(receiver.Place.Projections) != 0 || receiver.Mutable { + t.Fatalf("receiver borrow = %+v, want a shared borrow of a whole binding", receiver) + } + if field == nil || len(field.Place.Projections) != 1 { + t.Fatalf("field use = %+v, want one projection", field) + } + // The receiver parameter is a reference, so the typechecker recorded the + // adaptation. That evidence is what a future consumer reads to know the + // call borrows rather than moves. + if len(module.Typechecking.ImplicitCallArguments) == 0 { + t.Fatal("expected the implicit receiver borrow to be published") + } +} diff --git a/internal/semantics/effect/model.go b/internal/semantics/effect/model.go new file mode 100644 index 00000000..57fe399a --- /dev/null +++ b/internal/semantics/effect/model.go @@ -0,0 +1,197 @@ +// Package effect defines the ordered semantic meaning of source constructs. +// +// The producer in this package is the only code that inspects syntax to decide +// what a construct does to a binding. Definite initialization consumes the +// published stream and never switches on an AST kind, so a construct that maps +// onto these operations needs no case in any consumer. +// +// This package shares evidence, not a solver. Each analysis keeps its own +// lattice, join, direction, and diagnostics, per COMPILER_GUIDELINES.md section 6. +package effect + +import ( + "compiler/internal/frontend/ast" + "compiler/internal/ir" + "compiler/internal/ir/cfg" + "compiler/internal/semantics/place" + "compiler/internal/semantics/symbols" + "compiler/internal/semantics/typeinfo" + "compiler/internal/source" +) + +// Op is one semantic effect on one binding. +// +// The set is closed by unexported methods, so no kind can be introduced +// outside this package. visit(Visitor) supplies the other half of the contract: +// a new semantic operation extends Visitor and therefore breaks every exhaustive +// consumer at compile time until it makes an explicit decision. +type Op interface { + effectOp() + visit(Visitor) +} + +// Define brings a binding into existence. Initialized separates `let x = e`, +// which also stores a value, from a declaration that leaves storage empty. +type Define struct { + Symbol *symbols.Symbol + // Node is the declaration, which is where a diagnostic about the binding + // itself belongs. + Node ast.NodeID + // Value names the initializer whose value enters Symbol. It is zero for a + // declaration without an initializer and for OnEntry bindings. Consumers + // that track reference/pointer provenance can therefore update the binding + // from this operation without rediscovering declaration syntax. + Value ast.NodeID + Initialized bool + // OnEntry marks a binding that already exists when the site begins rather + // than being established by it: a function parameter, or a match payload + // binding, which the case edge creates before the arm body runs. + // + // Operations at a site are read in evaluation order, so a consumer that + // replays them needs no distinction. One that treats a site as a set does: + // liveness must not conclude a binding is dead before a site that merely + // receives it. + OnEntry bool +} + +// Write stores to storage that already exists. It names a place for the same +// reason Use does: `a.b = x` writes a field, and an assignment target takes a +// mutating access whether it is a whole binding or a projection out of one. +type Write struct { + Place Place + // Node is the assignment target. + Node ast.NodeID + // Owner is the source construct performing the replacement. Cleanup plans + // key pre-assignment drops by this identity, while Node remains the target + // expression used for diagnostics and place typing. + Owner ast.NodeID + // Value is the expression whose value is stored into Place. + Value ast.NodeID + Location *source.Location +} + +// Place identifies storage: a root binding and the projections taken from it to +// reach the value being used. +// +// Projections are reused from the canonical place walk rather than restated, so +// a consumer that already reasons about origins needs no translation. Empty +// projections mean the whole binding. A consumer that only cares which binding +// was touched reads Root and ignores the rest. +type Place struct { + // Root is the binding the storage belongs to. It is nil for a temporary: + // a value that never lives in a binding, such as a call result. + Root *symbols.Symbol + // Temporary names the expression that produced the value when Root is nil. + // Exactly one of Root and Temporary is set, which the validator enforces. + // + // Ownership needs the distinction because a temporary has nobody to own it: + // a projection out of one has to be bound before use, and a discarded one + // dies where it is produced. + Temporary ast.NodeID + Projections []place.OriginProjection +} + +// Use reads a binding's value. Node is the reading identifier rather than the +// enclosing statement, so a diagnostic anchors on the read itself. +// +// Location travels with the operation so a consumer never has to resolve the +// node back to syntax just to report against it. Define and Write carry no +// location because no current diagnostic anchors on them. +type Use struct { + Place Place + Node ast.NodeID + Location *source.Location + // Kind is what happens to the value here: observed, duplicated, or + // consumed. The producer decides it from the position the value occupies + // and, for a call argument, from the typechecker's published decision. + Kind typeinfo.UseKind +} + +// Borrow takes a reference to a place rather than reading its value. Mutable +// separates `&mut x` from `&x`, which is the difference that decides whether a +// second borrow conflicts. +// +// It names the place it borrows, so it is the whole access: a consumer that saw +// both a borrow and a separate read of the same place would charge that place +// twice. +type Borrow struct { + Place Place + // Node is the source expression that creates the borrow (an AddressExpr or + // an adapted call argument). Operand is the place expression actually + // borrowed. Keeping both identities means consumers never have to peel + // syntax to rediscover that relationship. + Node ast.NodeID + Operand ast.NodeID + Location *source.Location + Mutable bool + // Argument marks a borrow handed to a call. It outlives the expression that + // wrote it, because the callee holds it for as long as the call runs, so a + // consumer tracking loans records one rather than only checking an access. + Argument bool + // Raw marks taking a raw pointer. It reads the place but takes no tracked + // reference to it, so it neither conflicts with a borrow nor creates one. + Raw bool +} + +// Iterate records the long-lived shared access a sequence loop holds on its +// iterable storage. The ordinary Use for the iterable is still published in +// evaluation order; Iterate adds only the lifetime fact that lasts until the +// loop exit. Range loops publish no Iterate operation. +type Iterate struct { + Loop ast.NodeID + Place Place + Node ast.NodeID + Carrier *symbols.Symbol + Location *source.Location +} + +// CallBegin and CallEnd bracket the operations a call evaluates. Everything +// between them happens while the call is in progress. +// +// The bracket is a fact about evaluation, not one analysis's bookkeeping: a +// temporary created while computing an argument lives until the call completes, +// and a reservation taken for a receiver activates when the call starts. Any +// consumer modelling temporaries needs that boundary, and a flat sequence of +// uses cannot express it. Calls nest, so the pair nests too. +type CallBegin struct { + Node ast.NodeID + Location *source.Location +} + +type CallEnd struct { + Node ast.NodeID +} + +// Discard is a value produced and dropped, as an expression statement does. +// The value never reaches a binding, so anything owned in it dies here. +type Discard struct { + // Place is what was discarded, so a consumer can tell a dropped temporary + // from a statement that merely names storage. + Place Place + Node ast.NodeID + Location *source.Location +} + +func (Define) effectOp() {} +func (Write) effectOp() {} +func (Use) effectOp() {} +func (Borrow) effectOp() {} +func (Iterate) effectOp() {} +func (Discard) effectOp() {} +func (CallBegin) effectOp() {} +func (CallEnd) effectOp() {} + +// Result holds published effects for one semantic generation. +// +// A cfg.SiteID is only meaningful relative to one graph, so function identity +// is the outer key. Slice order is evaluation order; consumers must not reorder +// it. +type Result map[ir.NodeID]SiteOps + +// SiteOps holds one function's effects, keyed by the site they happen at. +type SiteOps map[cfg.SiteID][]Op + +// At returns the effects published for one site, in evaluation order. +func (r Result) At(fn ir.NodeID, site cfg.SiteID) []Op { + return r[fn][site] +} diff --git a/internal/semantics/effect/validate.go b/internal/semantics/effect/validate.go new file mode 100644 index 00000000..bf8cb396 --- /dev/null +++ b/internal/semantics/effect/validate.go @@ -0,0 +1,209 @@ +package effect + +import ( + "errors" + "fmt" + "sort" + "strings" + + "compiler/internal/frontend/ast" + "compiler/internal/ir" + "compiler/internal/ir/cfg" +) + +const maxReportedProblems = 10 + +// Validate checks operation identities, expression categories, storage roots, +// source locations, call brackets, and membership in the supplied CFG. +// +// It deliberately does not re-derive meaning. Whether a read should have been +// published for some expression is the producer's decision, and re-deciding it +// here would be a second implementation of the thing being validated. Dispatch +// contracts check node-kind coverage, not what each case publishes. Required +// operations and their order are covered by producer tests and source fixtures. +func (r Result) Validate(graphs *cfg.Module, nodes map[ast.NodeID]ast.Node) error { + if len(r) == 0 { + return nil + } + problems := make([]string, 0) + sitesByFunction := make(map[ir.NodeID]map[cfg.SiteID]struct{}) + if graphs != nil { + for _, graph := range graphs.Functions { + if graph == nil { + continue + } + sitesByFunction[graph.NodeID] = graphSites(graph) + } + } + for fn, siteOps := range r { + known, found := sitesByFunction[fn] + if !found { + problems = append(problems, fmt.Sprintf("function %d has published effects but no control-flow graph", fn)) + continue + } + for site, ops := range siteOps { + if _, exists := known[site]; !exists { + problems = append(problems, fmt.Sprintf("function %d publishes effects at site %v, which the graph does not contain", fn, site)) + continue + } + problems = append(problems, validateOps(fn, site, ops, nodes)...) + } + } + if len(problems) == 0 { + return nil + } + // Map iteration order is unspecified, so an unsorted report would differ + // between runs of the same broken artifact. + sort.Strings(problems) + if len(problems) > maxReportedProblems { + return fmt.Errorf("%s (%d more)", strings.Join(problems[:maxReportedProblems], "; "), len(problems)-maxReportedProblems) + } + return errors.New(strings.Join(problems, "; ")) +} + +func validateOps(fn ir.NodeID, site cfg.SiteID, ops []Op, nodes map[ast.NodeID]ast.Node) []string { + visitor := &validationVisitor{fn: fn, site: site, nodes: nodes} + for index, op := range ops { + visitor.index = index + Visit(op, visitor) + } + for _, unclosed := range visitor.open { + visitor.problems = append(visitor.problems, fmt.Sprintf("function %d site %v leaves call %d open", fn, site, unclosed)) + } + return visitor.problems +} + +type validationVisitor struct { + fn ir.NodeID + site cfg.SiteID + index int + nodes map[ast.NodeID]ast.Node + problems []string + open []ast.NodeID +} + +func (v *validationVisitor) where() string { + return fmt.Sprintf("function %d site %v operation %d", v.fn, v.site, v.index) +} + +func (v *validationVisitor) VisitDefine(op Define) { + where := v.where() + v.problems = append(v.problems, validateNode[ast.Node](where, "define", op.Symbol == nil, op.Node, v.nodes)...) + if op.Value != 0 { + v.problems = append(v.problems, validateNode[ast.Expr](where, "define value", false, op.Value, v.nodes)...) + } +} + +func (v *validationVisitor) VisitWrite(op Write) { + where := v.where() + v.problems = append(v.problems, validatePlace(where, "write", op.Place, v.nodes)...) + v.problems = append(v.problems, validateNode[ast.Expr](where, "write", false, op.Node, v.nodes)...) + v.problems = append(v.problems, validateNode[ast.Node](where, "write owner", false, op.Owner, v.nodes)...) + if op.Value != 0 { + v.problems = append(v.problems, validateNode[ast.Expr](where, "write value", false, op.Value, v.nodes)...) + } +} + +func (v *validationVisitor) VisitUse(op Use) { + where := v.where() + v.problems = append(v.problems, validatePlace(where, "use", op.Place, v.nodes)...) + v.problems = append(v.problems, validateNode[ast.Expr](where, "use", false, op.Node, v.nodes)...) + if op.Location == nil { + v.problems = append(v.problems, where+" is a use with no source location to report against") + } +} + +func (v *validationVisitor) VisitBorrow(op Borrow) { + where := v.where() + v.problems = append(v.problems, validatePlace(where, "borrow", op.Place, v.nodes)...) + v.problems = append(v.problems, validateNode[ast.Expr](where, "borrow", false, op.Node, v.nodes)...) + v.problems = append(v.problems, validateNode[ast.Expr](where, "borrow operand", false, op.Operand, v.nodes)...) + if op.Location == nil { + v.problems = append(v.problems, where+" is a borrow with no source location to report against") + } +} + +func (v *validationVisitor) VisitIterate(op Iterate) { + where := v.where() + v.problems = append(v.problems, validatePlace(where, "iteration", op.Place, v.nodes)...) + v.problems = append(v.problems, validateNode[ast.Expr](where, "iteration", op.Carrier == nil, op.Node, v.nodes)...) + v.problems = append(v.problems, validateNode[ast.Node](where, "iteration owner", false, op.Loop, v.nodes)...) + if op.Location == nil { + v.problems = append(v.problems, where+" is an iteration with no source location to report against") + } +} + +func (v *validationVisitor) VisitDiscard(op Discard) { + where := v.where() + v.problems = append(v.problems, validatePlace(where, "discard", op.Place, v.nodes)...) + v.problems = append(v.problems, validateNode[ast.Expr](where, "discard", false, op.Node, v.nodes)...) + if op.Location == nil { + v.problems = append(v.problems, where+" is a discard with no source location to report against") + } +} + +func (v *validationVisitor) VisitCallBegin(op CallBegin) { + where := v.where() + v.problems = append(v.problems, validateNode[ast.Expr](where, "call start", false, op.Node, v.nodes)...) + v.open = append(v.open, op.Node) +} + +func (v *validationVisitor) VisitCallEnd(op CallEnd) { + where := v.where() + if len(v.open) == 0 { + v.problems = append(v.problems, fmt.Sprintf("%s ends a call that never started", where)) + return + } + if last := v.open[len(v.open)-1]; last != op.Node { + v.problems = append(v.problems, fmt.Sprintf("%s ends call %d while call %d is still open", where, op.Node, last)) + } + v.open = v.open[:len(v.open)-1] +} + +// validatePlace enforces that a place names exactly one root. A place with +// neither names nothing; one with both would let a consumer reach two different +// answers depending on which field it read. +func validatePlace(where, kind string, at Place, nodes map[ast.NodeID]ast.Node) []string { + switch { + case at.Root == nil && at.Temporary == 0: + return []string{fmt.Sprintf("%s is a %s whose place names neither a binding nor a temporary", where, kind)} + case at.Root != nil && at.Temporary != 0: + return []string{fmt.Sprintf("%s is a %s whose place names both binding %s and temporary %d", + where, kind, at.Root.Name, at.Temporary)} + case at.Temporary != 0: + return validateNode[ast.Expr](where, kind+" temporary", false, at.Temporary, nodes) + } + return nil +} + +func validateNode[T ast.Node](where, kind string, missingSymbol bool, node ast.NodeID, nodes map[ast.NodeID]ast.Node) []string { + problems := make([]string, 0, 2) + if missingSymbol { + problems = append(problems, fmt.Sprintf("%s is a %s with no symbol", where, kind)) + } + if nodes == nil { + return problems + } + syntax, exists := nodes[node] + if !exists { + problems = append(problems, fmt.Sprintf("%s is a %s naming node %d, which is not in the typed AST", where, kind, node)) + } else if _, ok := syntax.(T); !ok { + problems = append(problems, fmt.Sprintf("%s is a %s naming node %d with unexpected node type %T", where, kind, node, syntax)) + } + return problems +} + +func graphSites(graph *cfg.Graph) map[cfg.SiteID]struct{} { + sites := make(map[cfg.SiteID]struct{}) + for _, block := range graph.Blocks { + if block == nil { + continue + } + for _, site := range block.Sites { + if site != nil { + sites[site.ID] = struct{}{} + } + } + } + return sites +} diff --git a/internal/semantics/effect/validate_test.go b/internal/semantics/effect/validate_test.go new file mode 100644 index 00000000..a6e03eb2 --- /dev/null +++ b/internal/semantics/effect/validate_test.go @@ -0,0 +1,162 @@ +package effect_test + +import ( + "strings" + "testing" + + "compiler/internal/frontend/ast" + "compiler/internal/ir" + "compiler/internal/ir/cfg" + "compiler/internal/semantics/effect" + "compiler/internal/semantics/symbols" +) + +const validationSource = `fn bump(start: i32) -> i32 { + let mut count = start; + count = count + 1; + return count; +}` + +// A real artifact from the real producer must validate. Without this, every +// negative case below could pass against a fixture that was already broken. +func TestValidateAcceptsPublishedEffects(t *testing.T) { + result, module := buildEffects(t, validationSource) + if err := result.Validate(module.CFG, module.TypedASTNodes); err != nil { + t.Fatalf("Validate() = %v, want nil for a published artifact", err) + } +} + +func TestValidateReportsDefects(t *testing.T) { + tests := []struct { + name string + damage func(effect.Result, ir.NodeID, cfg.SiteID) + want string + }{ + { + name: "place naming no root at all", + damage: func(result effect.Result, fn ir.NodeID, site cfg.SiteID) { + result[fn][site] = []effect.Op{effect.Use{Node: 1}} + }, + want: "names neither a binding nor a temporary", + }, + { + name: "place naming two roots", + damage: func(result effect.Result, fn ir.NodeID, site cfg.SiteID) { + result[fn][site] = []effect.Op{effect.Use{ + Place: effect.Place{Root: &symbols.Symbol{Name: "x"}, Temporary: 1}, + Node: 1, + }} + }, + want: "names both binding x and temporary", + }, + { + name: "use with no source location", + damage: func(result effect.Result, fn ir.NodeID, site cfg.SiteID) { + result[fn][site] = []effect.Op{effect.Use{Place: effect.Place{Root: &symbols.Symbol{Name: "x"}}, Node: 1}} + }, + want: "is a use with no source location to report against", + }, + { + name: "operation naming an unknown node", + damage: func(result effect.Result, fn ir.NodeID, site cfg.SiteID) { + result[fn][site] = []effect.Op{effect.Write{Place: effect.Place{Root: &symbols.Symbol{Name: "x"}}, Node: 999999}} + }, + want: "which is not in the typed AST", + }, + { + name: "effects at a site the graph does not contain", + damage: func(result effect.Result, fn ir.NodeID, site cfg.SiteID) { + result[fn][cfg.SiteID{Block: 4242, Index: 7}] = []effect.Op{} + }, + want: "which the graph does not contain", + }, + { + name: "effects for a function with no graph", + damage: func(result effect.Result, fn ir.NodeID, site cfg.SiteID) { + result[ir.NodeID(987654)] = effect.SiteOps{} + }, + want: "has published effects but no control-flow graph", + }, + } + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + result, module := buildEffects(t, validationSource) + fn, site := anySite(t, result) + test.damage(result, fn, site) + err := result.Validate(module.CFG, module.TypedASTNodes) + if err == nil { + t.Fatalf("Validate() = nil, want a report containing %q", test.want) + } + if !strings.Contains(err.Error(), test.want) { + t.Fatalf("Validate() = %v, want a report containing %q", err, test.want) + } + }) + } +} + +func TestValidateRejectsWrongExpressionIdentity(t *testing.T) { + result, module := buildEffects(t, validationSource) + fn, site := anySite(t, result) + function := module.TypedASTNodes[ast.NodeID(fn)].(*ast.FnDecl) + expr := function.Params[0].Name + target := function.Body.Stmts[1].(*ast.AssignStmt).Target + root := effect.Place{Root: &symbols.Symbol{Name: "count"}} + for _, test := range []struct { + name string + ops []effect.Op + }{ + {"define value", []effect.Op{effect.Define{Symbol: root.Root, Node: function.ID(), Value: expr.ID(), Initialized: true}}}, + {"write target", []effect.Op{effect.Write{Place: root, Node: expr.ID(), Owner: function.Body.ID()}}}, + {"write value", []effect.Op{effect.Write{Place: root, Node: target.ID(), Owner: function.Body.ID(), Value: expr.ID()}}}, + {"use", []effect.Op{effect.Use{Place: root, Node: expr.ID(), Location: ast.LocOf(expr)}}}, + {"borrow", []effect.Op{effect.Borrow{Place: root, Node: expr.ID(), Operand: target.ID(), Location: ast.LocOf(expr)}}}, + {"borrow operand", []effect.Op{effect.Borrow{Place: root, Node: target.ID(), Operand: expr.ID(), Location: ast.LocOf(target)}}}, + {"iteration", []effect.Op{effect.Iterate{Place: root, Node: expr.ID(), Loop: function.Body.ID(), Carrier: root.Root, Location: ast.LocOf(expr)}}}, + {"discard", []effect.Op{effect.Discard{Place: root, Node: expr.ID(), Location: ast.LocOf(expr)}}}, + {"call start", []effect.Op{effect.CallBegin{Node: expr.ID(), Location: ast.LocOf(expr)}, effect.CallEnd{Node: expr.ID()}}}, + {"temporary", []effect.Op{effect.Use{Place: effect.Place{Temporary: expr.ID()}, Node: target.ID(), Location: ast.LocOf(target)}}}, + } { + t.Run(test.name, func(t *testing.T) { + published := effect.Result{fn: effect.SiteOps{site: test.ops}} + if err := published.Validate(module.CFG, module.TypedASTNodes); err != nil { + t.Fatalf("well-shaped operation rejected: %v", err) + } + nodes := make(map[ast.NodeID]ast.Node, len(module.TypedASTNodes)) + for id, node := range module.TypedASTNodes { + nodes[id] = node + } + nodes[expr.ID()] = &ast.BlockStmt{NodeIDHolder: ast.NodeIDHolder{NodeID: expr.ID()}} + if err := published.Validate(module.CFG, nodes); err == nil || !strings.Contains(err.Error(), "unexpected node type") { + t.Fatalf("wrong expression identity: got %v", err) + } + }) + } +} + +func TestValidateRejectsNilIndexedNode(t *testing.T) { + result, module := buildEffects(t, validationSource) + for id := range module.TypedASTNodes { + module.TypedASTNodes[id] = nil + } + if err := result.Validate(module.CFG, module.TypedASTNodes); err == nil { + t.Fatal("nil indexed nodes accepted") + } +} + +func TestValidateAcceptsEmptyResult(t *testing.T) { + if err := effect.Result(nil).Validate(nil, nil); err != nil { + t.Fatalf("Validate() = %v, want nil for an empty artifact", err) + } +} + +// anySite returns one published site so a damage case has somewhere to write. +func anySite(t *testing.T, result effect.Result) (ir.NodeID, cfg.SiteID) { + t.Helper() + for fn, siteOps := range result { + for site := range siteOps { + return fn, site + } + } + t.Fatal("published result has no sites") + return 0, cfg.SiteID{} +} diff --git a/internal/semantics/effect/visitor.go b/internal/semantics/effect/visitor.go new file mode 100644 index 00000000..e36aa9e9 --- /dev/null +++ b/internal/semantics/effect/visitor.go @@ -0,0 +1,40 @@ +package effect + +// Visitor is the exhaustive consumer contract for semantic operations. +// +// Syntax additions that reuse existing operations never touch this interface. +// Adding a genuinely new semantic operation is different: the new Op must call +// a corresponding Visitor method from its visit implementation, which makes +// every semantic consumer fail compilation until it explicitly decides what +// the operation means. This is the deliberate "introduce new semantics to +// everyone" boundary; it is not used for AST dispatch throughout the compiler. +type Visitor interface { + VisitDefine(Define) + VisitWrite(Write) + VisitUse(Use) + VisitBorrow(Borrow) + VisitIterate(Iterate) + VisitDiscard(Discard) + VisitCallBegin(CallBegin) + VisitCallEnd(CallEnd) +} + +// Visit dispatches one operation through the exhaustive semantic visitor. +func Visit(op Op, visitor Visitor) { + if op == nil { + panic("effect: cannot visit a nil operation") + } + if visitor == nil { + panic("effect: cannot visit with a nil visitor") + } + op.visit(visitor) +} + +func (op Define) visit(visitor Visitor) { visitor.VisitDefine(op) } +func (op Write) visit(visitor Visitor) { visitor.VisitWrite(op) } +func (op Use) visit(visitor Visitor) { visitor.VisitUse(op) } +func (op Borrow) visit(visitor Visitor) { visitor.VisitBorrow(op) } +func (op Iterate) visit(visitor Visitor) { visitor.VisitIterate(op) } +func (op Discard) visit(visitor Visitor) { visitor.VisitDiscard(op) } +func (op CallBegin) visit(visitor Visitor) { visitor.VisitCallBegin(op) } +func (op CallEnd) visit(visitor Visitor) { visitor.VisitCallEnd(op) } diff --git a/internal/semantics/effect/visitor_test.go b/internal/semantics/effect/visitor_test.go new file mode 100644 index 00000000..7f232f2c --- /dev/null +++ b/internal/semantics/effect/visitor_test.go @@ -0,0 +1,29 @@ +package effect + +import ( + "reflect" + "testing" +) + +type recordingVisitor struct{ kinds []string } + +func (v *recordingVisitor) VisitDefine(Define) { v.kinds = append(v.kinds, "define") } +func (v *recordingVisitor) VisitWrite(Write) { v.kinds = append(v.kinds, "write") } +func (v *recordingVisitor) VisitUse(Use) { v.kinds = append(v.kinds, "use") } +func (v *recordingVisitor) VisitBorrow(Borrow) { v.kinds = append(v.kinds, "borrow") } +func (v *recordingVisitor) VisitIterate(Iterate) { v.kinds = append(v.kinds, "iterate") } +func (v *recordingVisitor) VisitDiscard(Discard) { v.kinds = append(v.kinds, "discard") } +func (v *recordingVisitor) VisitCallBegin(CallBegin) { v.kinds = append(v.kinds, "call-begin") } +func (v *recordingVisitor) VisitCallEnd(CallEnd) { v.kinds = append(v.kinds, "call-end") } + +func TestVisitorDispatchesEverySemanticOperation(t *testing.T) { + ops := []Op{Define{}, Write{}, Use{}, Borrow{}, Iterate{}, Discard{}, CallBegin{}, CallEnd{}} + visitor := &recordingVisitor{} + for _, op := range ops { + Visit(op, visitor) + } + want := []string{"define", "write", "use", "borrow", "iterate", "discard", "call-begin", "call-end"} + if !reflect.DeepEqual(visitor.kinds, want) { + t.Fatalf("visited %v, want %v", visitor.kinds, want) + } +} diff --git a/internal/semantics/flowresult/result.go b/internal/semantics/flowresult/result.go index 6f872faf..c34bf693 100644 --- a/internal/semantics/flowresult/result.go +++ b/internal/semantics/flowresult/result.go @@ -8,6 +8,7 @@ import ( "compiler/internal/ir/cfg" "compiler/internal/semantics/place" "compiler/internal/semantics/symbols" + "compiler/internal/semantics/typecheckresult" "compiler/internal/semantics/typeinfo" ) @@ -42,12 +43,8 @@ func (p PayloadAccess) AppliesTo(storage []place.Origin) bool { } type CaseTest struct { - SubjectID ast.NodeID - Case int - CaseWhenTrue bool - CaseCount int - PayloadPath []int - Family typeinfo.VariantFamily + typecheckresult.CaseTest + PayloadPath []int } type VariantFieldAccess struct { @@ -58,41 +55,6 @@ type VariantFieldAccess struct { Type typeinfo.Type } -// Match is typechecker-owned case and binding evidence consumed by CFG and -// later semantic phases without resolving source paths again. -type Match struct { - SubjectID ast.NodeID - EnumType typeinfo.Type - Cases []typeinfo.VariantCase - Arms []MatchArm -} - -type MatchArm struct { - ArmID ast.NodeID - BodyID ast.NodeID - Case int - Payload typeinfo.Type - Fields []MatchField -} - -type MatchField struct { - Field int - WholePayload bool - Type typeinfo.Type - Binding *symbols.Symbol - Discard bool -} - -// Arm returns resolved evidence for one case-labelled CFG edge. -func (m Match) Arm(caseIndex int) (MatchArm, bool) { - for _, arm := range m.Arms { - if arm.Case == caseIndex { - return arm, true - } - } - return MatchArm{}, false -} - type Result struct { SiteFacts map[ir.NodeID]map[cfg.SiteID]Facts ExprTypes map[ast.NodeID]typeinfo.Type diff --git a/internal/semantics/ownership/effects.go b/internal/semantics/ownership/effects.go new file mode 100644 index 00000000..660e1b06 --- /dev/null +++ b/internal/semantics/ownership/effects.go @@ -0,0 +1,425 @@ +package ownership + +import ( + "compiler/internal/diagnostics" + "compiler/internal/frontend/ast" + "compiler/internal/ir" + "compiler/internal/semantics/effect" + "compiler/internal/semantics/place" + "compiler/internal/semantics/symbols" + "compiler/internal/semantics/typeinfo" +) + +// callFrame remembers where a call's loans start, so completing the call can +// give back the temporaries its arguments created. +type callFrame struct { + call ast.Node + temporary int + reserved int +} + +// storedReference is the reference value carried by an initializer/RHS before +// that expression is evaluated. Evaluation can move the source binding, so +// consumers must snapshot provenance before replaying the site's effects. +type storedReference struct { + loans []referenceLoan + present bool +} + +// applyEffects runs one site's published operations in evaluation order. +// +// This is what replaced ownership's own expression walk. Every decision it +// needs for ordinary storage/use behavior is published: what happens to a +// value, whether a reference is taken, and where a call begins and ends. AST +// identity is recovered only for source diagnostics and ownership-specific +// reference/raw-pointer provenance; generic action meaning is not re-derived. +func (a *analyzer) applyEffects(node *site, st state, loans *loanContext) { + if a == nil || node == nil || node.cfgSite == nil { + return + } + ops := a.effects[node.cfgSite.ID] + visitor := &ownershipEffectVisitor{ + a: a, node: node, st: st, loans: loans, + storedReferences: a.captureStoredReferences(ops, st), + calls: make([]callFrame, 0, 2), + } + for _, op := range ops { + effect.Visit(op, visitor) + } +} + +type ownershipEffectVisitor struct { + a *analyzer + node *site + st state + loans *loanContext + storedReferences map[ast.NodeID]storedReference + calls []callFrame +} + +func (v *ownershipEffectVisitor) VisitDefine(op effect.Define) { + v.a.applyDefineEffect(v.node, op, v.st, v.storedReferences) +} + +func (v *ownershipEffectVisitor) VisitWrite(op effect.Write) { + v.a.applyWriteEffect(v.node, op, v.st, v.loans, v.storedReferences) +} + +func (v *ownershipEffectVisitor) VisitUse(op effect.Use) { + v.a.applyUse(v.node, op, v.st, v.loans) +} + +func (v *ownershipEffectVisitor) VisitBorrow(op effect.Borrow) { + v.a.applyBorrow(v.node, op, v.st, v.loans, v.calls) +} + +func (v *ownershipEffectVisitor) VisitIterate(op effect.Iterate) { + v.a.applyIterateEffect(op, v.st, v.loans) +} + +func (*ownershipEffectVisitor) VisitDiscard(effect.Discard) { + // Cleanup planning consumes Discard separately after evaluation. +} + +func (v *ownershipEffectVisitor) VisitCallBegin(op effect.CallBegin) { + call := v.a.module.TypedASTNodes[op.Node] + v.calls = append(v.calls, callFrame{ + call: call, temporary: len(v.loans.temporary), reserved: len(v.loans.reserved), + }) +} + +func (v *ownershipEffectVisitor) VisitCallEnd(effect.CallEnd) { + if len(v.calls) == 0 { + return + } + frame := v.calls[len(v.calls)-1] + v.calls = v.calls[:len(v.calls)-1] + // Reservations activate as the call starts, which is observable only once + // its arguments are evaluated; argument temporaries die with the call. + v.a.activateCallReservations(frame.call, frame.reserved, v.loans) + v.loans.temporary = v.loans.temporary[:frame.temporary] + v.loans.reserved = v.loans.reserved[:frame.reserved] +} + +// captureStoredReferences snapshots the reference provenance carried by values +// that a Define or Write will store. This runs before any operation at the site +// so a move performed while evaluating the source cannot erase the value that +// is about to enter the destination. +func (a *analyzer) captureStoredReferences(ops []effect.Op, st state) map[ast.NodeID]storedReference { + visitor := &storedReferenceVisitor{a: a, st: st, values: make(map[ast.NodeID]storedReference)} + for _, op := range ops { + effect.Visit(op, visitor) + } + return visitor.values +} + +type storedReferenceVisitor struct { + a *analyzer + st state + values map[ast.NodeID]storedReference +} + +func (v *storedReferenceVisitor) capture(valueID ast.NodeID) { + if valueID == 0 { + return + } + if _, captured := v.values[valueID]; captured { + return + } + value, _ := v.a.module.TypedASTNodes[valueID].(ast.Expr) + loans, present := v.a.referenceValueForExpr(value, v.st) + v.values[valueID] = storedReference{loans: loans, present: present} +} + +func (v *storedReferenceVisitor) VisitDefine(op effect.Define) { v.capture(op.Value) } +func (v *storedReferenceVisitor) VisitWrite(op effect.Write) { v.capture(op.Value) } +func (*storedReferenceVisitor) VisitUse(effect.Use) {} +func (*storedReferenceVisitor) VisitBorrow(effect.Borrow) {} +func (*storedReferenceVisitor) VisitIterate(effect.Iterate) {} +func (*storedReferenceVisitor) VisitDiscard(effect.Discard) {} +func (*storedReferenceVisitor) VisitCallBegin(effect.CallBegin) {} +func (*storedReferenceVisitor) VisitCallEnd(effect.CallEnd) {} + +// applyDefineEffect makes a newly defined binding own the value published by +// the semantic producer. The declaration syntax is irrelevant here: any future +// construct that publishes Define inherits the same ownership transition. +func (a *analyzer) applyDefineEffect(node *site, op effect.Define, st state, references map[ast.NodeID]storedReference) { + if op.Symbol == nil { + return + } + if op.Value != 0 { + value, _ := a.module.TypedASTNodes[op.Value].(ast.Expr) + reference := references[op.Value] + a.updatePointerSymbol(op.Symbol, node.scope, value, st) + a.updateReferenceSymbol(op.Symbol, reference.loans, reference.present, st) + } else if !op.OnEntry { + // An ordinary declaration without an initializer establishes empty + // storage. Entry bindings already carry state seeded by the edge/function + // entry and must not have that provenance erased here. + a.updatePointerSymbol(op.Symbol, node.scope, nil, st) + a.updateReferenceSymbol(op.Symbol, nil, false, st) + } + if ownershipTrackedSymbol(op.Symbol) { + delete(st.moved, op.Symbol) + st.live[op.Symbol] = struct{}{} + } +} + +// applyWriteEffect records replacement of existing storage. Place decides +// whether this is whole-binding reinitialization or mutation through a +// projection; Owner gives cleanup a stable identity independent of syntax kind. +func (a *analyzer) applyWriteEffect( + node *site, + op effect.Write, + st state, + loans *loanContext, + references map[ast.NodeID]storedReference, +) { + if op.Owner != 0 { + delete(a.cleanup.BeforeAssign, ir.NodeID(op.Owner)) + } + target, _ := a.module.TypedASTNodes[op.Node].(ast.Expr) + if target == nil { + return + } + + // Assigning through a projection reaches into existing storage and cannot + // revive a root that was already moved away. + if op.Place.Root == nil || len(op.Place.Projections) > 0 { + if op.Place.Root != nil && a.reportUseAfterMove(op.Place.Root, st, effect.Use{ + Place: op.Place, Node: op.Node, Location: op.Location, + }) { + return + } + a.checkStorageAccess(target, loans, storageMutate) + if op.Owner != 0 && typeinfo.OwnershipCapabilityOf(a.exprType(target)).Drop { + a.cleanup.BeforeAssign[ir.NodeID(op.Owner)] = struct{}{} + } + if op.Value != 0 { + a.replaceReferenceField(target, references[op.Value], st) + } + return + } + + sym := op.Place.Root + if _, referenceTarget := referenceMutability(sym); !referenceTarget { + a.checkStorageAccess(target, loans, storageMutate) + } + if typ, ok := symbols.GetSymbolType(sym); ok && typeinfo.OwnershipCapabilityOf(typ).Drop { + if _, live := st.live[sym]; live && op.Owner != 0 { + a.cleanup.BeforeAssign[ir.NodeID(op.Owner)] = struct{}{} + } + } + if ownershipTrackedSymbol(sym) { + delete(st.moved, sym) + st.live[sym] = struct{}{} + } + if op.Value == 0 { + return + } + value, _ := a.module.TypedASTNodes[op.Value].(ast.Expr) + reference := references[op.Value] + a.updatePointerSymbol(sym, node.scope, value, st) + a.updateReferenceSymbol(sym, reference.loans, reference.present, st) +} + +// applyIterateEffect installs the long-lived shared access a sequence loop +// holds on its iterable. Iteration kind and carrier identity were decided by +// typechecking and published by effects; ownership does not inspect ForStmt or +// the iteration plan. +func (a *analyzer) applyIterateEffect(op effect.Iterate, st state, loans *loanContext) { + if op.Carrier == nil || op.Node == 0 { + return + } + iterable, _ := a.module.TypedASTNodes[op.Node].(ast.Expr) + if iterable == nil { + return + } + a.checkStorageAccess(iterable, loans, storageSharedBorrow) + origins := a.originsForExpr(iterable) + if op.Place.Root != nil { + if value, found := st.references[op.Place.Root]; found { + origins = referenceOrigins(value) + } + } else if value, hasValue := a.referenceValueForExpr(iterable, st); hasValue { + origins = referenceOrigins(value) + } + if len(origins) == 0 { + return + } + st.references[op.Carrier] = []referenceLoan{{ + id: loanID{node: iterable}, origins: origins, site: iterable, loop: op.Loop, + }} +} + +func (a *analyzer) applyUse(node *site, op effect.Use, st state, loans *loanContext) { + syntax, _ := a.module.TypedASTNodes[op.Node].(ast.Expr) + if op.Place.Root == nil { + // A value with no owner. Only a projection out of one has an effect + // here, and it is that the projection must be bound before use. The + // effect place already names the temporary base; do not peel syntax. + base, _ := a.module.TypedASTNodes[op.Place.Temporary].(ast.Expr) + a.planProjectionBaseDrop(syntax, base) + return + } + if a.reportUseAfterMove(op.Place.Root, st, op) { + return + } + if len(op.Place.Projections) == 0 { + a.applyWholeUse(node, op, st, loans, syntax) + return + } + a.applyProjectedUse(op, st, loans, syntax) +} + +// applyWholeUse is the effect of using a binding entire: its move state changes, +// and the storage it names is accessed. +func (a *analyzer) applyWholeUse(node *site, op effect.Use, st state, loans *loanContext, syntax ast.Expr) { + sym := op.Place.Root + a.applyUseKind(sym, op, st, syntax) + if _, reference := referenceMutability(sym); reference { + loans.useReference(sym) + return + } + if syntax != nil { + a.checkStorageAccess(syntax, loans, storageAccessForUse(a.exprType(syntax), op.Kind)) + } + if referenceHoldingSymbol(sym) { + loans.useReference(sym) + } +} + +// applyProjectedUse is the effect of using part of a binding. Consuming a part +// is what a partial move is, and the language does not allow it out of a +// move-only place. +func (a *analyzer) applyProjectedUse(op effect.Use, st state, loans *loanContext, syntax ast.Expr) { + if syntax == nil { + return + } + // Reaching through a binding spends it the same way naming it does, so a + // reference reached through here is one use closer to its last. + if sym := op.Place.Root; sym != nil { + if _, reference := referenceMutability(sym); reference || referenceHoldingSymbol(sym) { + loans.useReference(sym) + } + } + a.checkStorageAccess(syntax, loans, storageAccessForUse(a.exprType(syntax), op.Kind)) + if op.Kind == typeinfo.UseRead || !ownershipTrackedType(a.exprType(syntax)) { + return + } + if a.partialVariantPayloadMove(op.Node) { + a.ctx.Diagnostics.AddError(diagnostics.ErrInvalidCopy, + "move-only variant payload cannot be moved from partial place; borrow it instead", op.Location, "") + return + } + if projection, projected := place.Project(syntax); projected && projection.Step.Kind == place.OriginIndex { + a.ctx.Diagnostics.AddError(diagnostics.ErrInvalidCopy, + "move-only indexed element cannot be used by value; borrow it with `&` or `&mut`", op.Location, "") + return + } + a.ctx.Diagnostics.AddError(diagnostics.ErrInvalidCopy, + "move-only subexpression must be bound before it can be consumed", op.Location, "") +} + +// applyBorrow records a reference taken to a place. A mutable borrow taken while +// a call is being evaluated is a reservation rather than a borrow: it does not +// take effect until the call it is an argument to actually starts. +func (a *analyzer) applyBorrow(node *site, op effect.Borrow, st state, loans *loanContext, calls []callFrame) { + if op.Place.Root != nil && a.reportUseAfterMove(op.Place.Root, st, effect.Use{ + Place: op.Place, Node: op.Node, Location: op.Location, Kind: typeinfo.UseRead, + }) { + return + } + access := storageSharedBorrow + if op.Mutable { + access = storageMutableBorrow + if op.Argument { + // A mutable borrow handed to a call does not take effect until the + // call starts, so it is reserved here and activated there. + access = storageMutableReservation + } + } + if op.Raw { + // A raw pointer is not a tracked reference: it neither conflicts with a + // live borrow nor becomes one. + return + } + if sym := op.Place.Root; sym != nil { + if _, reference := referenceMutability(sym); reference || referenceHoldingSymbol(sym) { + loans.useReference(sym) + } + } + borrowed, _ := a.module.TypedASTNodes[op.Operand].(ast.Expr) + if borrowed == nil { + return + } + a.checkStorageAccess(borrowed, loans, access) + if op.Argument { + a.installArgumentLoan(borrowed, op, loans, calls) + } +} + +// installArgumentLoan records the loan a call holds on an argument for as long +// as it runs. A mutable one is reserved until the call starts; a shared one is +// a temporary that dies when the call completes. +func (a *analyzer) installArgumentLoan(borrowed ast.Expr, op effect.Borrow, loans *loanContext, calls []callFrame) { + origins := a.originsForExpr(borrowed) + if len(origins) == 0 { + return + } + var call ast.Node + if len(calls) > 0 && calls[len(calls)-1].call != nil { + call = calls[len(calls)-1].call + } + loan := referenceLoan{ + id: loanID{node: borrowed}, + origins: origins, + mutable: op.Mutable, + site: borrowed, + } + if op.Mutable { + loans.reserved = append(loans.reserved, loanFact{ + loan: loan, + holder: a.referenceHolder(borrowed), + keepingAlive: call, + }) + return + } + loans.addTemporary([]referenceLoan{loan}, call) +} + +func (a *analyzer) reportUseAfterMove(sym *symbols.Symbol, st state, op effect.Use) bool { + site, moved := st.moved[sym] + if !moved { + return false + } + diag := a.ctx.Diagnostics.AddError(diagnostics.ErrUseAfterMove, "value used after move", op.Location, "") + if site != nil { + diag.WithSecondaryLabel(ast.LocOf(site), "moved here") + } + return true +} + +// applyUseKind is what happens to a binding's value at one use: a move leaves it +// dead, and a copy is rejected for anything the language will not duplicate. A +// read leaves it as it was. +func (a *analyzer) applyUseKind(sym *symbols.Symbol, op effect.Use, st state, syntax ast.Expr) { + if !ownershipTrackedSymbol(sym) { + return + } + switch op.Kind { + case typeinfo.UseCopy: + if symType, typed := symbols.GetSymbolType(sym); typed { + if _, mutable, ok := typeinfo.ReferenceTarget(typeinfo.Underlying(symType)); ok && mutable { + a.ctx.Diagnostics.AddError(diagnostics.ErrInvalidCopy, + "mutable reference cannot be copied; pass it directly to transfer or reborrow", op.Location, "") + return + } + } + a.ctx.Diagnostics.AddError(diagnostics.ErrInvalidCopy, + "copy of move-only value requires a consuming context", op.Location, "") + case typeinfo.UseMove: + st.moved[sym] = syntax + delete(st.live, sym) + } +} diff --git a/internal/semantics/ownership/expr.go b/internal/semantics/ownership/expr.go index e63ca068..5cca46b7 100644 --- a/internal/semantics/ownership/expr.go +++ b/internal/semantics/ownership/expr.go @@ -1,250 +1,29 @@ package ownership import ( - "fmt" - "compiler/internal/diagnostics" "compiler/internal/frontend/ast" "compiler/internal/ir" - "compiler/internal/semantics/intrinsics" "compiler/internal/semantics/place" "compiler/internal/semantics/symbols" "compiler/internal/semantics/typeinfo" ) -type useKind uint8 - -const ( - useRead useKind = iota - useCopy - useConsume -) - -func (a *analyzer) checkExpr( - scope *symbols.Scope, - expr ast.Expr, - st state, - use useKind, - loans *loanContext, - projectionBase bool, -) { - if a == nil || expr == nil { - return - } - switch e := expr.(type) { - case *ast.Ident: - a.checkIdent(scope, e, st, use) - sym := a.module.Semantics.ResolvedSymbols[e.ID()] - if _, reference := referenceMutability(sym); reference { - loans.useReference(sym) - return - } - if !projectionBase { - a.checkStorageAccess(e, loans, storageAccessForUse(a.exprType(e), use)) - } - if referenceHoldingSymbol(sym) { - loans.useReference(sym) - } - case *ast.AddressExpr: - access := storageSharedBorrow - if e.Mode == ast.AddressMutable { - access = storageMutableBorrow - } - a.checkAddressExpr(scope, e, st, loans, access) - case *ast.SelectorExpr: - a.checkSelector(scope, e, st, use, loans) - if !projectionBase { - a.checkStorageAccess(e, loans, storageAccessForUse(a.exprType(e), use)) - } - case *ast.IndexExpr: - if typeinfo.IsInvalidOrUnknown(a.exprType(e)) { - return - } - _, slicing := e.Index.(*ast.RangeExpr) - a.checkExpr(scope, e.Expr, st, useRead, loans, true) - a.checkExpr(scope, e.Index, st, useRead, loans, false) - if !projectionBase { - access := storageAccessForUse(a.exprType(e), use) - if slicing { - access = storageSharedBorrow - if _, mutable, reference := typeinfo.ReferenceTarget(typeinfo.Underlying(a.exprType(e))); reference && mutable { - access = storageMutableBorrow - } - } - a.checkStorageAccess(e, loans, access) - } - if slicing { - return - } - if a.planProjectionBaseDrop(e, e.Expr) { - return - } - if use != useRead && ownershipTrackedType(a.exprType(e)) { - if a.partialVariantPayloadMove(e) { - a.ctx.Diagnostics.AddError(diagnostics.ErrInvalidCopy, - "move-only variant payload cannot be moved from partial place; borrow it instead", ast.LocOf(e), "") - return - } - a.ctx.Diagnostics.AddError(diagnostics.ErrInvalidCopy, - "move-only indexed element cannot be used by value; borrow it with `&` or `&mut`", ast.LocOf(e), "") - } - case *ast.RangeExpr: - a.checkExpr(scope, e.Start, st, useRead, loans, false) - a.checkExpr(scope, e.End, st, useRead, loans, false) - case *ast.StructLit: - a.checkLiteralFields(scope, e.Fields, st, loans) - case *ast.VariantLit: - a.checkExpr(scope, e.Payload, st, useConsume, loans, false) - case *ast.ArrayLit: - for _, value := range e.Values { - a.checkExpr(scope, value, st, useConsume, loans, false) - } - case *ast.CallExpr: - a.checkCall(scope, e, st, loans) - case *ast.FreeExpr: - a.checkExpr(scope, e.Expr, st, useConsume, loans, false) - case *ast.PrintExpr: - a.checkExpr(scope, e.Expr, st, useRead, loans, false) - case *ast.UnaryExpr: - a.checkExpr(scope, e.Expr, st, useRead, loans, false) - case *ast.BinaryExpr: - if _, concat := a.module.Semantics.StringConcatenations[e.ID()]; concat { - a.checkExpr(scope, e.Left, st, useConsume, loans, false) - a.checkExpr(scope, e.Right, st, useRead, loans, false) - return - } - a.checkExpr(scope, e.Left, st, useRead, loans, false) - a.checkExpr(scope, e.Right, st, useRead, loans, false) - case *ast.IsExpr: - a.checkExpr(scope, e.Value, st, useRead, loans, false) - case *ast.AsExpr: - a.checkExpr(scope, e.Expr, st, useConsume, loans, false) - case *ast.ScopeResolution, *ast.NumberLit, *ast.StringLit, *ast.ByteLit, *ast.CharLit, *ast.BoolLit, *ast.NoneLit, *ast.BadExpr: - return - default: - panic(fmt.Sprintf("ownership: unhandled expression %T", expr)) - } -} - -func (a *analyzer) checkLiteralFields(scope *symbols.Scope, fields []ast.StructLitField, st state, loans *loanContext) { - for _, field := range fields { - a.checkExpr(scope, field.Value, st, useConsume, loans, false) - } -} - -func (a *analyzer) expandedDefaultBinding(ident *ast.Ident) (place.Binding, bool) { - if a == nil || a.module == nil || a.module.Semantics == nil || ident == nil { - return place.Binding{}, false - } - if _, ok := a.module.Semantics.ExpandedDefaultBindings[ident.ID()]; !ok { - return place.Binding{}, false - } - return place.Binding{Symbol: a.module.Semantics.ResolvedSymbols[ident.ID()]}, true -} - -func (a *analyzer) checkAddressExpr( - scope *symbols.Scope, - expr *ast.AddressExpr, - st state, - loans *loanContext, - access storageAccess, -) { - if expr == nil { - return - } - a.checkExpr(scope, expr.Expr, st, useRead, loans, true) - if expr.Mode != ast.AddressRaw { - a.checkStorageAccess(expr.Expr, loans, access) - } -} - -func storageAccessForUse(typ typeinfo.Type, use useKind) storageAccess { - if use == useConsume && ownershipTrackedType(typ) { +func storageAccessForUse(typ typeinfo.Type, use typeinfo.UseKind) storageAccess { + if use == typeinfo.UseMove && ownershipTrackedType(typ) { return storageConsume } return storageRead } -func (a *analyzer) checkIdent(scope *symbols.Scope, ident *ast.Ident, st state, use useKind) { - if scope == nil || ident == nil { - return - } - var sym *symbols.Symbol - var ok bool - if a.module != nil && a.module.Semantics != nil { - sym = a.module.Semantics.ResolvedSymbols[ident.ID()] - ok = sym != nil - } - if !ok { - sym, ok = scope.Lookup(ident.Name) - } - if !ok || sym == nil { - return - } - if site, moved := st.moved[sym]; moved { - diag := a.ctx.Diagnostics.AddError(diagnostics.ErrUseAfterMove, - "value used after move", ast.LocOf(ident), "") - if site != nil { - diag.WithSecondaryLabel(ast.LocOf(site), "moved here") - } - return - } - if !ownershipTrackedSymbol(sym) { - return - } - switch use { - case useCopy: - if symType, ok := symbols.GetSymbolType(sym); ok { - if _, mutable, ok := typeinfo.ReferenceTarget(typeinfo.Underlying(symType)); ok && mutable { - a.ctx.Diagnostics.AddError(diagnostics.ErrInvalidCopy, - "mutable reference cannot be copied; pass it directly to transfer or reborrow", ast.LocOf(ident), "") - return - } - } - a.ctx.Diagnostics.AddError(diagnostics.ErrInvalidCopy, - "copy of move-only value requires a consuming context", ast.LocOf(ident), "") - case useConsume: - st.moved[sym] = ident - delete(st.live, sym) - } -} - -func (a *analyzer) checkSelector( - scope *symbols.Scope, - selector *ast.SelectorExpr, - st state, - use useKind, - loans *loanContext, -) { - if selector == nil { - return - } - a.checkExpr(scope, selector.Expr, st, useRead, loans, true) - if a.planProjectionBaseDrop(selector, selector.Expr) { - return - } - if use == useRead { - return - } - if ownershipTrackedType(a.exprType(selector)) { - if a.partialVariantPayloadMove(selector) { - a.ctx.Diagnostics.AddError(diagnostics.ErrInvalidCopy, - "move-only variant payload cannot be moved from partial place; borrow it instead", ast.LocOf(selector), "") - return - } - a.ctx.Diagnostics.AddError(diagnostics.ErrInvalidCopy, - "move-only subexpression must be bound before it can be consumed", ast.LocOf(selector), "") - } -} - func (a *analyzer) planProjectionBaseDrop(projection, base ast.Expr) bool { if a == nil || a.cleanup == nil || projection == nil || base == nil { return false } - if place.IsPlaceExpr(base) || !typeinfo.NeedsDrop(a.exprType(base)) { + if place.IsPlaceExpr(base) || !typeinfo.OwnershipCapabilityOf(a.exprType(base)).Drop { return false } - if typeinfo.NeedsDrop(a.exprType(projection)) { + if typeinfo.OwnershipCapabilityOf(a.exprType(projection)).Drop { a.ctx.Diagnostics.AddError(diagnostics.ErrInvalidCopy, "ownership-bearing projection from temporary must be bound before use", ast.LocOf(projection), "") return true @@ -253,142 +32,6 @@ func (a *analyzer) planProjectionBaseDrop(projection, base ast.Expr) bool { return false } -func (a *analyzer) checkCall(scope *symbols.Scope, call *ast.CallExpr, st state, loans *loanContext) { - if call == nil { - return - } - temporaryMark := len(loans.temporary) - reservationMark := len(loans.reserved) - defer func() { - loans.temporary = loans.temporary[:temporaryMark] - loans.reserved = loans.reserved[:reservationMark] - }() - if selector, ok := call.Callee.(*ast.SelectorExpr); ok && selector != nil { - if a.checkMethodCall(scope, selector, call, st, loans) { - a.activateCallReservations(call, reservationMark, loans) - } - return - } - a.checkExpr(scope, call.Callee, st, useRead, loans, false) - if ident, ok := call.Callee.(*ast.Ident); ok && ident != nil { - sym := a.module.Semantics.ResolvedSymbols[ident.ID()] - if sym != nil && sym.CompilerOp == symbols.CompilerOpAlloc { - for i, arg := range call.Args { - use := useRead - if i == 0 { - use = useConsume - } - a.checkExpr(scope, arg, st, use, loans, false) - } - return - } - if sym != nil && sym.CompilerOp == symbols.CompilerOpFromBytes { - definition, found := intrinsics.LookupFunction(sym.CompilerOp) - if !found { - panic("missing from_bytes intrinsic definition") - } - fn := definition.Signature(nil, a.ctx.Target) - for i, arg := range call.Args { - if i >= len(fn.Params) { - a.checkExpr(scope, arg, st, useRead, loans, false) - continue - } - a.checkCallArgument(scope, arg, fn.Params[i], call, st, loans) - } - return - } - } - fn, ok := a.exprType(call.Callee).(*typeinfo.FuncType) - if !ok || fn == nil || len(call.Args) != len(fn.Params) { - for _, arg := range call.Args { - a.checkExpr(scope, arg, st, useRead, loans, false) - } - return - } - for i, arg := range call.Args { - a.checkCallArgument(scope, arg, fn.Params[i], call, st, loans) - } - a.activateCallReservations(call, reservationMark, loans) -} - -func (a *analyzer) checkMethodCall( - scope *symbols.Scope, - selector *ast.SelectorExpr, - call *ast.CallExpr, - st state, - loans *loanContext, -) bool { - fn, ok := a.exprType(selector).(*typeinfo.FuncType) - if !ok || fn == nil || selector == nil || call == nil { - if selector != nil { - a.checkExpr(scope, selector.Expr, st, useRead, loans, false) - } - for _, arg := range call.Args { - a.checkExpr(scope, arg, st, useRead, loans, false) - } - return false - } - a.checkCallArgument(scope, selector.Expr, fn.Params[0], call, st, loans) - if len(call.Args)+1 != len(fn.Params) { - for _, arg := range call.Args { - a.checkExpr(scope, arg, st, useRead, loans, false) - } - return false - } - for i, arg := range call.Args { - a.checkCallArgument(scope, arg, fn.Params[i+1], call, st, loans) - } - return true -} - -func (a *analyzer) checkCallArgument( - scope *symbols.Scope, - arg ast.Expr, - paramType typeinfo.Type, - call *ast.CallExpr, - st state, - loans *loanContext, -) { - _, mutable, reference := typeinfo.ReferenceValueTarget(paramType) - if !reference { - use := useConsume - if typeinfo.IsImplicitCopyType(paramType) { - use = useRead - } - a.checkExpr(scope, arg, st, use, loans, false) - return - } - access := storageSharedBorrow - if mutable { - access = storageMutableReservation - } - if explicitBorrow, explicit := arg.(*ast.AddressExpr); explicit { - a.checkAddressExpr(scope, explicitBorrow, st, loans, access) - } else { - a.checkExpr(scope, arg, st, useRead, loans, true) - a.checkStorageAccess(arg, loans, access) - } - origins := a.originsForExpr(arg) - if len(origins) == 0 { - return - } - loan := referenceLoan{ - id: loanID{node: arg}, - origins: origins, - mutable: mutable, - site: arg, - } - if mutable { - loans.reserved = append(loans.reserved, loanFact{ - loan: loan, - holder: a.referenceHolder(arg), - keepingAlive: call, - }) - return - } - loans.addTemporary([]referenceLoan{loan}, call) -} - func (a *analyzer) exprType(expr ast.Expr) typeinfo.Type { if a == nil || a.module == nil || expr == nil { return nil @@ -396,11 +39,11 @@ func (a *analyzer) exprType(expr ast.Expr) typeinfo.Type { return a.module.EffectiveExprType(expr.ID()) } -func (a *analyzer) partialVariantPayloadMove(expr ast.Expr) bool { - if a == nil || a.module == nil || a.module.Flow == nil || expr == nil { +func (a *analyzer) partialVariantPayloadMove(id ast.NodeID) bool { + if a == nil || a.module == nil || a.module.Flow == nil || id == 0 { return false } - payload, ok := a.module.Flow.Payloads[expr.ID()] + payload, ok := a.module.Flow.Payloads[id] return ok && len(payload.Cases) > 0 && !payload.Direct } @@ -452,7 +95,7 @@ func (a *analyzer) pointerOrigin(scope *symbols.Scope, expr ast.Expr, st state) if e.Mode != ast.AddressRaw { return pointerOrigin{}, false } - root, ok := place.LocalRoot(scope, a.module.ModuleScope, e.Expr, a.exprType, a.expandedDefaultBinding) + root, ok := place.LocalRoot(scope, a.module.ModuleScope, e.Expr, a.exprType, a.module.ExpandedDefaultBinding) if !ok || root == nil { return pointerOrigin{}, false } @@ -482,8 +125,8 @@ func (a *analyzer) pointerOrigin(scope *symbols.Scope, expr ast.Expr, st state) } var sym *symbols.Symbol var found bool - if a.module != nil && a.module.Semantics != nil { - sym = a.module.Semantics.ResolvedSymbols[e.ID()] + if a.module != nil && a.module.Bindings != nil { + sym = a.module.Bindings.NodeSymbols[e.ID()] found = sym != nil } if !found { @@ -517,7 +160,7 @@ func ownershipTrackedSymbol(sym *symbols.Symbol) bool { } func ownershipTrackedType(t typeinfo.Type) bool { - if t == nil || typeinfo.IsImplicitCopyType(t) { + if t == nil || typeinfo.OwnershipCapabilityOf(t).Copy == typeinfo.CopyImplicit { return false } return true diff --git a/internal/semantics/ownership/ownership.go b/internal/semantics/ownership/ownership.go index 4cc36d94..e6004252 100644 --- a/internal/semantics/ownership/ownership.go +++ b/internal/semantics/ownership/ownership.go @@ -6,13 +6,15 @@ import ( "compiler/internal/diagnostics" "compiler/internal/frontend/ast" + graphcore "compiler/internal/graph" "compiler/internal/ir" "compiler/internal/ir/cfg" "compiler/internal/project" - "compiler/internal/semantics/flowresult" + "compiler/internal/semantics/effect" "compiler/internal/semantics/ownershipresult" "compiler/internal/semantics/place" "compiler/internal/semantics/symbols" + "compiler/internal/semantics/typecheckresult" "compiler/internal/semantics/typeinfo" ) @@ -30,6 +32,7 @@ type analyzer struct { graph *cfg.Graph sites map[cfg.SiteID]*site order []cfg.SiteID + effects effect.SiteOps cleanup *ownershipresult.CleanupPlan function *ast.FnDecl functionScope *symbols.Scope @@ -57,7 +60,7 @@ type state struct { // value-flow rules from becoming ad hoc type rules. func Check(ctx *project.CompilerContext, module *project.Module) ownershipresult.Result { result := make(ownershipresult.Result) - if ctx == nil || module == nil || module.AST == nil || module.ModuleScope == nil || module.Semantics == nil || module.CFG == nil { + if ctx == nil || module == nil || module.AST == nil || module.ModuleScope == nil || module.Bindings == nil || module.Effects == nil || module.CFG == nil { return result } for _, graph := range module.CFG.Functions { @@ -70,26 +73,25 @@ func Check(ctx *project.CompilerContext, module *project.Module) ownershipresult BeforeAssign: make(map[ir.NodeID]struct{}), DiscardedValue: make(map[ir.NodeID]struct{}), ProjectionBase: make(map[ir.NodeID]struct{}), - MatchCarrierMoves: make(map[ir.NodeID]symbols.SymbolID), MatchFieldDrops: make(map[ir.NodeID][]int), MatchWholePayloadDrops: make(map[ir.NodeID]struct{}), } } + for _, sym := range module.ModuleScope.Symbols() { + if sym == nil || (sym.Kind != symbols.SymbolVar && sym.Kind != symbols.SymbolConst) { + continue + } + if ownershipTrackedSymbol(sym) { + ctx.Diagnostics.AddError(diagnostics.ErrInvalidAssignment, + "ownership-tracked module bindings are not supported", ast.LocOf(sym.ASTNode), "") + } + } for _, stmt := range module.AST.Stmts { switch node := stmt.(type) { - case *ast.LetDecl, *ast.ConstDecl: - sym, found := module.ModuleScope.LookupNode(node) - if !found || sym == nil { - continue - } - if ownershipTrackedSymbol(sym) { - ctx.Diagnostics.AddError(diagnostics.ErrInvalidAssignment, - "ownership-tracked module bindings are not supported", ast.LocOf(node), "") - } case *ast.FnDecl: var sym *symbols.Symbol if node.Receiver != nil { - sym = module.Semantics.MethodSymbol[node.ID()] + sym = module.Bindings.MethodsByDecl[node.ID()] } else { sym, _ = module.ModuleScope.Lookup(node.Name.Name) } @@ -107,7 +109,7 @@ func Check(ctx *project.CompilerContext, module *project.Module) ownershipresult } func checkFunction(ctx *project.CompilerContext, module *project.Module, fn *ast.FnDecl, scope *symbols.Scope, cfgFn *cfg.Graph, cleanup *ownershipresult.CleanupPlan) { - if ctx == nil || module == nil || module.Semantics == nil || fn == nil || fn.Body == nil || scope == nil || cfgFn == nil || cleanup == nil { + if ctx == nil || module == nil || module.Bindings == nil || fn == nil || fn.Body == nil || scope == nil || cfgFn == nil || cleanup == nil { return } sites, order := indexSites(module, cfgFn, scope) @@ -117,6 +119,7 @@ func checkFunction(ctx *project.CompilerContext, module *project.Module, fn *ast graph: cfgFn, sites: sites, order: order, + effects: module.Effects[cfgFn.NodeID], cleanup: cleanup, function: fn, functionScope: scope, @@ -127,7 +130,7 @@ func checkFunction(ctx *project.CompilerContext, module *project.Module, fn *ast func indexSites(module *project.Module, cfgFn *cfg.Graph, scope *symbols.Scope) (map[cfg.SiteID]*site, []cfg.SiteID) { sites := make(map[cfg.SiteID]*site) order := make([]cfg.SiteID, 0) - if module == nil || module.Semantics == nil || cfgFn == nil || scope == nil { + if module == nil || module.Bindings == nil || cfgFn == nil || scope == nil { return sites, order } nodes := module.TypedASTNodes @@ -139,7 +142,7 @@ func indexSites(module *project.Module, cfgFn *cfg.Graph, scope *symbols.Scope) if flowSite == nil { continue } - resolvedScope := module.Semantics.BlockScopes[ast.NodeID(flowSite.ScopeID)] + resolvedScope := module.Bindings.BlockScopes[ast.NodeID(flowSite.ScopeID)] if resolvedScope == nil { resolvedScope = scope } @@ -186,20 +189,19 @@ func (a *analyzer) run() { } entry := a.graph.Entry.Sites[0].ID a.inStates = map[cfg.SiteID]state{entry: entryState} - queue := []cfg.SiteID{entry} - queued := map[cfg.SiteID]bool{entry: true} - for len(queue) > 0 { - id := queue[0] - queue = queue[1:] - queued[id] = false + work := graphcore.NewWorklist(entry) + for { + id, pending := work.Next() + if !pending { + break + } node := a.sites[id] next := copyState(a.inStates[id]) - if node != nil && node.cfgBlock != nil && node.cfgBlock.Origin == cfg.BlockNormal { - loopID := ast.NodeID(node.cfgBlock.NodeID) - if evidence, found := a.module.Semantics.ForIterations[loopID]; found && - evidence.Kind == project.ForIterationSequence && evidence.Carrier != nil { - releaseIterationLoans(next, nil, loopID) - } + // Loop-owned loans are keyed by loop identity. Range loops publish no + // Iterate effect, so releasing by loop ID is harmless and avoids asking + // typechecker what kind of loop produced this CFG exit. + if node != nil && node.cfgBlock != nil && node.cfgBlock.Origin == cfg.BlockLoopExit { + releaseIterationLoans(next, nil, ast.NodeID(node.cfgBlock.NodeID)) } if node != nil { switch node.cfgSite.Kind { @@ -211,7 +213,7 @@ func (a *analyzer) run() { a.applyBlockExit(node, next, a.newLoanContext(node, next)) } } - for _, edge := range node.cfgSite.Successors { + for _, edge := range a.graph.SiteEdges.OutEdges(node.cfgSite.ID) { succ := edge.To if a.sites[succ] == nil { continue @@ -226,10 +228,7 @@ func (a *analyzer) run() { continue } a.inStates[succ] = merged - if !queued[succ] { - queue = append(queue, succ) - queued[succ] = true - } + work.Add(succ) } } } @@ -244,11 +243,10 @@ func (a *analyzer) planDeadMatchCarrierCleanup() { } for _, node := range a.sites { - matchStmt, ok := node.stmt.(*ast.MatchStmt) - if !ok || node.cfgSite == nil || node.cfgSite.Kind != cfg.SiteTerminator { + if node == nil || node.cfgSite == nil || node.cfgSite.Kind != cfg.SiteTerminator { continue } - match, found := a.module.Semantics.Matches[matchStmt.ID()] + match, found := a.module.Typechecking.Matches[ast.NodeID(node.cfgSite.NodeID)] if !found { continue } @@ -262,10 +260,14 @@ func (a *analyzer) planDeadMatchCarrierCleanup() { armsByJoin := make(map[cfg.SiteID]map[ast.NodeID]struct{}) for _, arm := range match.Arms { for _, exit := range scopeExits[arm.BodyID] { - if exit == nil || exit.cfgSite == nil || len(exit.cfgSite.Successors) != 1 { + if exit == nil || exit.cfgSite == nil { + continue + } + edges := a.graph.SiteEdges.OutEdges(exit.cfgSite.ID) + if len(edges) != 1 { continue } - join := exit.cfgSite.Successors[0].To + join := edges[0].To for { joinNode := a.sites[join] if joinNode == nil || joinNode.cfgSite == nil { @@ -277,13 +279,14 @@ func (a *analyzer) planDeadMatchCarrierCleanup() { armsByJoin[join] = make(map[ast.NodeID]struct{}) } armsByJoin[join][arm.BodyID] = struct{}{} - movesByJoin[join] = movesByJoin[join] || matchArmMovesCarrier(arm) + movesByJoin[join] = movesByJoin[join] || arm.CarrierUse == typeinfo.UseMove break } - if len(joinNode.cfgSite.Successors) != 1 { + edges = a.graph.SiteEdges.OutEdges(joinNode.cfgSite.ID) + if len(edges) != 1 { break } - join = joinNode.cfgSite.Successors[0].To + join = edges[0].To } } } @@ -339,7 +342,7 @@ func (a *analyzer) mergeState(nodeID cfg.SiteID, dst, src state, exists bool) (s return copyState(src), true } node := a.sites[nodeID] - if node == nil || node.cfgSite == nil || len(node.cfgSite.Predecessors) <= 1 { + if node == nil || node.cfgSite == nil || a.graph.SiteEdges.InDegree(node.cfgSite.ID, nil) <= 1 { if maps.Equal(dst.moved, src.moved) && maps.Equal(dst.live, src.live) && maps.Equal(dst.pointers, src.pointers) && sameReferenceValues(dst.references, src.references) { return dst, false @@ -406,7 +409,7 @@ func (a *analyzer) applyBlockExit(node *site, st state, loans *loanContext) { if carrier := a.deadMatchCarrierAtExit[node.cfgSite.ID]; carrier != nil { if _, live := st.live[carrier]; live { a.reportLoanConflict([]place.Origin{{Root: carrier}}, nil, storageDestroy, node.block, loans) - if typ, ok := symbols.GetSymbolType(carrier); ok && typeinfo.NeedsDrop(typ) { + if typ, ok := symbols.GetSymbolType(carrier); ok && typeinfo.OwnershipCapabilityOf(typ).Drop { cleanup = append(cleanup, carrier) } st.moved[carrier] = node.block @@ -446,7 +449,7 @@ func cleanupSymbols(scope *symbols.Scope, st state) []*symbols.Symbol { continue } typ, ok := symbols.GetSymbolType(sym) - if ok && typeinfo.NeedsDrop(typ) { + if ok && typeinfo.OwnershipCapabilityOf(typ).Drop { cleanup = append(cleanup, sym) } } @@ -484,87 +487,54 @@ func (a *analyzer) checkScopeDestruction(scope *symbols.Scope, site ast.Node, lo } } +// planDiscardedDrops records a drop for each value the site produces and throws +// away. +// +// Only a temporary is discarded in the sense that matters: a value that names +// no binding has nobody left to own it, so the drop happens here. A discarded +// expression that names storage still belongs to that storage. The producer +// makes the distinction, so this no longer asks the syntax. +func (a *analyzer) planDiscardedDrops(node *site) { + if a == nil || a.cleanup == nil || node == nil || node.cfgSite == nil { + return + } + for _, op := range a.effects[node.cfgSite.ID] { + discard, isDiscard := op.(effect.Discard) + if !isDiscard || discard.Place.Root != nil { + continue + } + if typeinfo.OwnershipCapabilityOf(a.module.EffectiveExprType(discard.Node)).Drop { + a.cleanup.DiscardedValue[ir.NodeID(discard.Node)] = struct{}{} + } + } +} + func (a *analyzer) applyStmt(node *site, st state) { if a == nil || node == nil || node.scope == nil || node.stmt == nil { return } scope := node.scope loans := a.newLoanContext(node, st) - switch s := node.stmt.(type) { - case *ast.LetDecl: - a.applyBinding(scope, s, s.Value, st, loans) - case *ast.ConstDecl: - a.applyBinding(scope, s, s.Value, st, loans) - case *ast.AssignStmt: - reference, hasReference := a.referenceValueForExpr(s.Value, st) - delete(a.cleanup.BeforeAssign, ir.NodeID(s.ID())) - a.checkExpr(scope, s.Value, st, useConsume, loans, false) - if _, ok := s.Target.(*ast.Ident); !ok { - a.checkExpr(scope, s.Target, st, useRead, loans, true) - a.checkStorageAccess(s.Target, loans, storageMutate) - if typeinfo.NeedsDrop(a.exprType(s.Target)) { - a.cleanup.BeforeAssign[ir.NodeID(s.ID())] = struct{}{} - } - } - if target, ok := s.Target.(*ast.Ident); ok && scope != nil { - if sym, found := scope.Lookup(target.Name); found { - if _, referenceTarget := referenceMutability(sym); !referenceTarget { - a.checkStorageAccess(target, loans, storageMutate) - } - if typ, ok := symbols.GetSymbolType(sym); ok && typeinfo.NeedsDrop(typ) { - if _, live := st.live[sym]; live { - a.cleanup.BeforeAssign[ir.NodeID(s.ID())] = struct{}{} - } - } - if ownershipTrackedSymbol(sym) { - delete(st.moved, sym) - st.live[sym] = struct{}{} - } - a.updatePointerSymbol(sym, scope, s.Value, st) - a.updateReferenceSymbol(sym, reference, hasReference, st) - } - } - case *ast.ReturnStmt: + + // Return provenance has to be checked against the incoming state, before + // evaluating the returned value can move its source. Storage transitions for + // declarations and assignments are published effects and require no syntax + // cases here. + if s, ok := node.stmt.(*ast.ReturnStmt); ok { a.checkPointerEscape(scope, s.Value, st) a.validateReferenceReturn(scope, s, st) - a.checkExpr(scope, s.Value, st, useConsume, loans, false) + } + + // Evaluation and generic storage transitions come from published effects. + a.applyEffects(node, st, loans) + a.planDiscardedDrops(node) + + // Return remains the one ownership statement policy whose checks straddle + // evaluation: provenance is validated above before the value can move, while + // cleanup happens after its effects have executed. + if s, ok := node.stmt.(*ast.ReturnStmt); ok { releaseIterationLoans(st, loans, 0) a.cleanupBeforeReturn(scope, s, st, loans) - case *ast.ExprStmt: - a.checkExpr(scope, s.Expr, st, useRead, loans, false) - if s.Expr != nil && !place.IsPlaceExpr(s.Expr) && typeinfo.NeedsDrop(a.exprType(s.Expr)) { - a.cleanup.DiscardedValue[ir.NodeID(s.Expr.ID())] = struct{}{} - } - case *ast.IfStmt: - a.checkExpr(scope, s.Cond, st, useRead, loans, false) - case *ast.ForStmt: - if s.Iterable == nil { - a.checkExpr(scope, s.Cond, st, useRead, loans, false) - break - } - evidence, found := a.module.Semantics.ForIterations[s.ID()] - if !found || evidence.Kind != project.ForIterationSequence || evidence.Carrier == nil { - a.checkExpr(scope, s.Iterable, st, useRead, loans, false) - break - } - a.checkExpr(scope, s.Iterable, st, useRead, loans, true) - a.checkStorageAccess(s.Iterable, loans, storageSharedBorrow) - origins := a.originsForExpr(s.Iterable) - if ident, ok := s.Iterable.(*ast.Ident); ok { - sym := a.module.Semantics.ResolvedSymbols[ident.ID()] - if value, found := st.references[sym]; found { - origins = referenceOrigins(value) - } - } else if value, hasValue := a.referenceValueForExpr(s.Iterable, st); hasValue { - origins = referenceOrigins(value) - } - if len(origins) > 0 { - st.references[evidence.Carrier] = []referenceLoan{{ - id: loanID{node: s.Iterable}, origins: origins, site: s.Iterable, loop: s.ID(), - }} - } - case *ast.MatchStmt: - a.checkExpr(scope, s.Subject, st, useRead, loans, false) } } @@ -572,7 +542,7 @@ func (a *analyzer) applyMatchEdge(node *site, edge cfg.Edge, st state) { if a == nil || node == nil || node.cfgSite == nil || edge.Kind != cfg.EdgeVariantCase { return } - match, found := a.module.Semantics.Matches[ast.NodeID(node.cfgSite.NodeID)] + match, found := a.module.Typechecking.Matches[ast.NodeID(node.cfgSite.NodeID)] if !found { return } @@ -584,11 +554,15 @@ func (a *analyzer) applyMatchEdge(node *site, edge cfg.Edge, st state) { if subject == nil { return } - movesCarrier := matchArmMovesCarrier(arm) - listed := make(map[int]bool, len(arm.Fields)) - for _, field := range arm.Fields { - if !field.WholePayload { + movesCarrier := arm.CarrierUse == typeinfo.UseMove + listed := make(map[int]bool, len(arm.Bindings)) + for _, field := range arm.Bindings { + switch field.Projection { + case typecheckresult.MatchPayloadField: listed[field.Field] = field.Discard + case typecheckresult.MatchWholePayload: + default: + panic("ownership: invalid match binding projection") } } if movesCarrier && carrier == nil { @@ -604,9 +578,8 @@ func (a *analyzer) applyMatchEdge(node *site, edge cfg.Edge, st state) { st.moved[carrier] = moveSite delete(st.live, carrier) delete(st.references, carrier) - a.cleanup.MatchCarrierMoves[ir.NodeID(arm.BodyID)] = carrier.ID - if len(arm.Fields) == 1 && arm.Fields[0].WholePayload { - if arm.Fields[0].Discard && typeinfo.NeedsDrop(arm.Fields[0].Type) { + if len(arm.Bindings) == 1 && arm.Bindings[0].Projection == typecheckresult.MatchWholePayload { + if arm.Bindings[0].Discard && typeinfo.OwnershipCapabilityOf(arm.Bindings[0].Type).Drop { a.cleanup.MatchWholePayloadDrops[ir.NodeID(arm.BodyID)] = struct{}{} } } else if payload, payloadFound := typeinfo.Underlying(arm.Payload).(*typeinfo.StructType); payloadFound && payload != nil { @@ -614,7 +587,7 @@ func (a *analyzer) applyMatchEdge(node *site, edge cfg.Edge, st state) { for fieldIndex := len(payload.Fields) - 1; fieldIndex >= 0; fieldIndex-- { field := payload.Fields[fieldIndex] discarded, selected := listed[fieldIndex] - if typeinfo.NeedsDrop(field.Type) && (!selected || discarded) { + if typeinfo.OwnershipCapabilityOf(field.Type).Drop && (!selected || discarded) { drops = append(drops, fieldIndex) } } @@ -623,7 +596,7 @@ func (a *analyzer) applyMatchEdge(node *site, edge cfg.Edge, st state) { } } } - for _, field := range arm.Fields { + for _, field := range arm.Bindings { binding := field.Binding if binding == nil { continue @@ -644,28 +617,19 @@ func (a *analyzer) applyMatchEdge(node *site, edge cfg.Edge, st state) { } } -func (a *analyzer) matchSubjectCarrier(match flowresult.Match) (ast.Expr, *symbols.Symbol) { +func (a *analyzer) matchSubjectCarrier(match typecheckresult.Match) (ast.Expr, *symbols.Symbol) { subject, _ := a.module.TypedASTNodes[match.SubjectID].(ast.Expr) ident, direct := subject.(*ast.Ident) if !direct { return subject, nil } - carrier := a.module.Semantics.ResolvedSymbols[ident.ID()] + carrier := a.module.Bindings.NodeSymbols[ident.ID()] if carrier == nil || (carrier.Kind != symbols.SymbolVar && carrier.Kind != symbols.SymbolConst && carrier.Kind != symbols.SymbolParam) { return subject, nil } return subject, carrier } -func matchArmMovesCarrier(arm flowresult.MatchArm) bool { - for _, field := range arm.Fields { - if !typeinfo.IsImplicitCopyType(field.Type) { - return true - } - } - return false -} - func symbolIDs(values []*symbols.Symbol) []symbols.SymbolID { ids := make([]symbols.SymbolID, 0, len(values)) for _, sym := range values { @@ -675,21 +639,3 @@ func symbolIDs(values []*symbols.Symbol) []symbols.SymbolID { } return ids } - -func (a *analyzer) applyBinding(scope *symbols.Scope, stmt ast.Stmt, value ast.Expr, st state, loans *loanContext) { - if scope == nil || stmt == nil { - return - } - reference, hasReference := a.referenceValueForExpr(value, st) - a.checkExpr(scope, value, st, useConsume, loans, false) - sym, found := scope.LookupNode(stmt) - if !found || sym == nil { - return - } - a.updatePointerSymbol(sym, scope, value, st) - a.updateReferenceSymbol(sym, reference, hasReference, st) - if ownershipTrackedSymbol(sym) { - delete(st.moved, sym) - st.live[sym] = struct{}{} - } -} diff --git a/internal/semantics/ownership/ownership_test.go b/internal/semantics/ownership/ownership_test.go index 580c6ced..5678c5f5 100644 --- a/internal/semantics/ownership/ownership_test.go +++ b/internal/semantics/ownership/ownership_test.go @@ -11,9 +11,11 @@ import ( "compiler/internal/frontend/parser" "compiler/internal/ir" "compiler/internal/ir/cfg" + "compiler/internal/moduleid" "compiler/internal/project" "compiler/internal/semantics/binder" "compiler/internal/semantics/collector" + "compiler/internal/semantics/effect" "compiler/internal/semantics/ownershipresult" "compiler/internal/semantics/place" "compiler/internal/semantics/resolver" @@ -36,24 +38,34 @@ func checkOwnershipSource(t *testing.T, src string) *ownershipResult { ctx := project.New(".", peeper.SourceExt, diag) modAST := parser.New(filePath, lexer.New(filePath, src, diag).Tokenize(), diag).ParseModule() module := &project.Module{ - Key: project.ModuleKeyFor(project.ModuleOriginLocal, filePath), - ImportPath: "ownership_test", - FilePath: filePath, - Content: src, - AST: modAST, - Imports: make(map[string]project.ResolvedImport), + ID: moduleid.ID{Origin: string(project.ModuleOriginLocal), ImportPath: "ownership_test"}, + FilePath: filePath, + Content: src, + AST: modAST, + Imports: make(map[string]project.ResolvedImport), } ctx.AddModule(module) collector.Collect(ctx, module) binder.Bind(ctx, module) resolver.Resolve(ctx, module) typechecker.Check(ctx, module) - module.TypedASTNodes = ast.Index(module.AST) + module.RebuildTypedASTIndex() module.CFG = cfg.BuildModule(module.AST, cfg.BuildQueries{ - MatchCases: module.Semantics.MatchCases, - LoopGuaranteedEntry: module.Semantics.ForLoopGuaranteedEntry, + MatchCases: module.Typechecking.MatchCases, + LoopGuaranteedEntry: module.Typechecking.ForLoopGuaranteedEntry, }) module.Flow = typechecker.CheckFlow(ctx, module) + module.Effects = effect.Build(module.CFG, module.TypedASTNodes, effect.BuildQueries{ + Symbols: module.Bindings.NodeSymbols, + Scopes: module.Bindings.BlockScopes, + CallArguments: module.Typechecking.CallArgumentsOrSource, + ArmBindings: module.Typechecking.ArmBindings, + StringConcatenation: module.Typechecking.StringConcatenation, + ValueUse: module.Typechecking.ValueUse, + ExprType: module.EffectiveExprType, + ReferenceArgument: module.Typechecking.ReferenceArgument, + SequenceCarrier: module.Typechecking.SequenceCarrier, + }) module.Ownership = Check(ctx, module) return &ownershipResult{DiagnosticBag: diag, ctx: ctx, module: module} } @@ -84,6 +96,7 @@ func inspectFunctionAnalysis(t *testing.T, result *ownershipResult, name string) graph: cfgFn, sites: sites, order: order, + effects: result.module.Effects[cfgFn.NodeID], cleanup: cleanup, function: fn, functionScope: scope, @@ -160,8 +173,8 @@ func cleanupSymbolNames(module *project.Module, cleanup []symbols.SymbolID) []st } } } - if module != nil && module.Semantics != nil { - for _, scope := range module.Semantics.BlockScopes { + if module != nil && module.Bindings != nil { + for _, scope := range module.Bindings.BlockScopes { for _, sym := range scope.Symbols() { if sym != nil { names[sym.ID] = sym.Name @@ -248,14 +261,13 @@ func TestOwnershipCheckClearsAllDerivedPlans(t *testing.T) { plan.BeforeAssign[staleID] = struct{}{} plan.DiscardedValue[staleID] = struct{}{} plan.ProjectionBase[staleID] = struct{}{} - plan.MatchCarrierMoves[staleID] = 999999 plan.MatchFieldDrops[staleID] = []int{0} plan.MatchWholePayloadDrops[staleID] = struct{}{} result.module.Ownership = Check(result.ctx, result.module) plan = cleanupPlanForFunction(t, result, fn) if len(plan.AfterScope) != 0 || len(plan.BeforeReturn) != 0 || len(plan.BeforeAssign) != 0 || - len(plan.DiscardedValue) != 0 || len(plan.ProjectionBase) != 0 || len(plan.MatchCarrierMoves) != 0 || + len(plan.DiscardedValue) != 0 || len(plan.ProjectionBase) != 0 || len(plan.MatchFieldDrops) != 0 || len(plan.MatchWholePayloadDrops) != 0 { t.Fatalf("stale ownership plans survived rerun: %#v", plan) } @@ -772,12 +784,11 @@ fn valid(resource: Resource) { fn := result.module.AST.Stmts[1].(*ast.FnDecl) match := fn.Body.Stmts[0].(*ast.MatchStmt) plan := cleanupPlanForFunction(t, result, fn) - function, _ := result.module.ModuleScope.Lookup("valid") - resource, _ := function.Scope.Lookup("resource") - ownedBodyID := ir.NodeID(match.Arms[0].Body.ID()) graph := result.module.CFG.Function(ir.NodeID(fn.ID())) - if got := plan.MatchCarrierMoves[ownedBodyID]; got != resource.ID { - t.Fatalf("owned arm carrier move = %d, want %d", got, resource.ID) + // The owned arm consumes the carrier, so leaving it must not drop the + // carrier again; the pending arm never consumes it, so leaving there must. + if got := cleanupSymbolNames(result.module, plan.AfterScope[scopeExitSiteID(t, graph, match.Arms[0].Body.ID())]); slices.Contains(got, "resource") { + t.Fatalf("consumed carrier dropped on the owned arm: %v", got) } if got := cleanupSymbolNames(result.module, plan.AfterScope[scopeExitSiteID(t, graph, match.Arms[1].Body.ID())]); !slices.Equal(got, []string{"resource"}) { t.Fatalf("pending arm cleanup = %v, want [resource]", got) @@ -1240,11 +1251,6 @@ fn consume(resource: Resource) { if got := plan.MatchFieldDrops[bodyID]; !slices.Equal(got, []int{2, 1}) { t.Fatalf("match field drops = %v, want [2 1]", got) } - function, _ := result.module.ModuleScope.Lookup("consume") - resource, _ := function.Scope.Lookup("resource") - if got := plan.MatchCarrierMoves[bodyID]; got != resource.ID { - t.Fatalf("match carrier move = %d, want %d", got, resource.ID) - } graph := result.module.CFG.Function(ir.NodeID(fn.ID())) if got := cleanupSymbolNames(result.module, plan.AfterScope[scopeExitSiteID(t, graph, match.Arms[0].Body.ID())]); !slices.Equal(got, []string{"selected"}) { t.Fatalf("arm binding cleanup = %v, want [selected]", got) diff --git a/internal/semantics/ownership/reference.go b/internal/semantics/ownership/reference.go index b5c4b8f9..dbe1018f 100644 --- a/internal/semantics/ownership/reference.go +++ b/internal/semantics/ownership/reference.go @@ -6,8 +6,10 @@ import ( "compiler/internal/diagnostics" "compiler/internal/frontend/ast" + graphcore "compiler/internal/graph" "compiler/internal/ir/cfg" "compiler/internal/project" + "compiler/internal/semantics/effect" "compiler/internal/semantics/place" "compiler/internal/semantics/symbols" "compiler/internal/semantics/typeinfo" @@ -19,6 +21,9 @@ type loanID struct { } type referenceLoan struct { + // path identifies the slot within the holder, not the borrowed storage. + // One loan can occupy multiple enum fields and survive replacement of one. + path []place.OriginProjection id loanID origins []place.Origin mutable bool @@ -145,8 +150,13 @@ func (a *analyzer) checkStorageAccess( if a == nil || expr == nil || loans == nil { return } + origins := a.originsForExpr(expr) + if access == storageMutate && a.module != nil && a.module.Flow != nil { + // Replacing a reference slot mutates the carrier, not its old referent. + origins = a.module.Flow.ResolvedStorageOrigins[expr.ID()] + } a.reportLoanConflict( - a.originsForExpr(expr), + origins, a.referenceHolder(expr), access, expr, @@ -215,7 +225,7 @@ func (a *analyzer) reportLoanConflict( addLoanConflictLabels(diag, conflict, reservedConflict, nil) } -func (a *analyzer) activateCallReservations(call *ast.CallExpr, mark int, loans *loanContext) { +func (a *analyzer) activateCallReservations(call ast.Node, mark int, loans *loanContext) { if a == nil || call == nil || loans == nil || mark >= len(loans.reserved) { return } @@ -296,39 +306,28 @@ func overlappingLoan(origins []place.Origin, facts []loanFact, exempt *symbols.S } func (a *analyzer) referenceHolder(expr ast.Expr) *symbols.Symbol { - if a == nil || a.module == nil || a.module.Semantics == nil { + if a == nil || a.module == nil || a.module.Bindings == nil || expr == nil { return nil } - for { - switch node := expr.(type) { - case *ast.AddressExpr: - if node == nil { - return nil - } - expr = node.Expr - case *ast.SelectorExpr: - if node == nil { - return nil - } - expr = node.Expr - case *ast.IndexExpr: - if node == nil { - return nil - } - expr = node.Expr - case *ast.Ident: - if node == nil { - return nil - } - sym := a.module.Semantics.ResolvedSymbols[node.ID()] - if _, reference := referenceMutability(sym); reference { - return sym - } - return nil - default: + if address, taken := expr.(*ast.AddressExpr); taken { + if address == nil { return nil } + expr = address.Expr + } + root, _, ok := place.Decompose(expr) + if !ok { + return nil } + ident, ok := root.(*ast.Ident) + if !ok || ident == nil { + return nil + } + sym := a.module.Bindings.NodeSymbols[ident.ID()] + if _, reference := referenceMutability(sym); reference { + return sym + } + return nil } func (a *analyzer) referenceValueForExpr(expr ast.Expr, st state) ([]referenceLoan, bool) { @@ -336,8 +335,8 @@ func (a *analyzer) referenceValueForExpr(expr ast.Expr, st state) ([]referenceLo return []referenceLoan{}, false } if ident, ok := expr.(*ast.Ident); ok { - sym := a.module.Semantics.ResolvedSymbols[ident.ID()] - if typ, typed := symbols.GetSymbolType(sym); typed && typeinfo.ContainsStoredReference(typ) { + sym := a.module.Bindings.NodeSymbols[ident.ID()] + if referenceHoldingSymbol(sym) { if value, found := st.references[sym]; found { return copyReferenceLoans(value), true } @@ -345,6 +344,20 @@ func (a *analyzer) referenceValueForExpr(expr ast.Expr, st state) ([]referenceLo } _, mutable, ok := typeinfo.ReferenceValueTarget(a.exprType(expr)) if ok { + if _, projected := expr.(*ast.SelectorExpr); projected && a.module.Flow != nil { + var value []referenceLoan + for _, storage := range a.module.Flow.ResolvedStorageOrigins[expr.ID()] { + for _, loan := range st.references[storage.Root] { + if slices.Equal(loan.path, storage.Projections) { + loan.path = nil + value = append(value, loan) + } + } + } + if len(value) > 0 { + return copyReferenceLoans(value), true + } + } origins := a.originsForExpr(expr) if len(origins) == 0 { return []referenceLoan{}, false @@ -359,18 +372,56 @@ func (a *analyzer) referenceValueForExpr(expr ast.Expr, st state) ([]referenceLo if literal, ok := expr.(*ast.StructLit); ok { var loans []referenceLoan for _, field := range literal.Fields { + if field.Name == nil { + continue + } fieldLoans, found := a.referenceValueForExpr(field.Value, st) if found { + for i := range fieldLoans { + fieldLoans[i].path = append([]place.OriginProjection{{Kind: place.OriginField, Field: field.Name.Name}}, fieldLoans[i].path...) + } loans = append(loans, fieldLoans...) } } return loans, len(loans) > 0 } - construction, constructed := a.module.Semantics.VariantConstructions[expr.ID()] + construction, constructed := a.module.Typechecking.VariantConstructions[expr.ID()] if !constructed || construction.Payload == nil { return []referenceLoan{}, false } - return a.referenceValueForExpr(construction.Value, st) + loans, found := a.referenceValueForExpr(construction.Value, st) + for i := range loans { + loans[i].path = append([]place.OriginProjection{{Kind: place.OriginVariantPayload, Case: construction.Case}}, loans[i].path...) + } + return loans, found +} + +// replaceReferenceField consumes flow's exact storage identity. Accepted local +// enum reference fields are direct/optional; nested reference aggregates remain +// rejected by typechecking. Other holders and sibling slots retain their loans. +func (a *analyzer) replaceReferenceField(target ast.Expr, value storedReference, st state) { + if _, _, reference := typeinfo.ReferenceValueTarget(a.exprType(target)); !reference || a.module.Flow == nil { + return + } + storage := a.module.Flow.ResolvedStorageOrigins[target.ID()] + if len(storage) != 1 || len(storage[0].Projections) == 0 { + return + } + destination := storage[0] + if !referenceHoldingSymbol(destination.Root) { + return + } + var kept []referenceLoan + for _, loan := range st.references[destination.Root] { + if !slices.Equal(loan.path, destination.Projections) { + kept = append(kept, loan) + } + } + for _, loan := range copyReferenceLoans(value.loans) { + loan.path = slices.Clone(destination.Projections) + kept = append(kept, loan) + } + a.updateReferenceSymbol(destination.Root, kept, len(kept) > 0, st) } func (a *analyzer) originsForExpr(expr ast.Expr) []place.Origin { @@ -462,6 +513,7 @@ func copyReferenceLoans(value []referenceLoan) []referenceLoan { copy(copyValue, value) for i := range copyValue { copyValue[i].origins = place.CloneOrigins(copyValue[i].origins) + copyValue[i].path = slices.Clone(copyValue[i].path) } return copyValue } @@ -489,9 +541,10 @@ func mergeReferenceValues(dst, src map[*symbols.Symbol][]referenceLoan) bool { continue } for _, srcLoan := range srcValue { - index := referenceLoanIndex(dstValue, srcLoan.id) + index := referenceLoanIndex(dstValue, srcLoan) if index < 0 { srcLoan.origins = place.CloneOrigins(srcLoan.origins) + srcLoan.path = slices.Clone(srcLoan.path) dstValue = append(dstValue, srcLoan) changed = true continue @@ -520,7 +573,7 @@ func sameReferenceLoans(left, right []referenceLoan) bool { return false } for _, leftLoan := range left { - index := referenceLoanIndex(right, leftLoan.id) + index := referenceLoanIndex(right, leftLoan) if index < 0 { return false } @@ -533,9 +586,9 @@ func sameReferenceLoans(left, right []referenceLoan) bool { return true } -func referenceLoanIndex(loans []referenceLoan, id loanID) int { +func referenceLoanIndex(loans []referenceLoan, candidate referenceLoan) int { for i := range loans { - if loans[i].id == id { + if loans[i].id == candidate.id && slices.Equal(loans[i].path, candidate.path) { return i } } @@ -548,23 +601,22 @@ func (a *analyzer) computeSymbolLiveness() { } a.symbolLiveIn = make(map[cfg.SiteID]map[*symbols.Symbol]ast.Node, len(a.order)) a.symbolLiveOut = make(map[cfg.SiteID]map[*symbols.Symbol]ast.Node, len(a.order)) - queue := make([]cfg.SiteID, 0, len(a.order)) - queued := make(map[cfg.SiteID]bool, len(a.order)) + work := graphcore.NewWorklist[cfg.SiteID]() for _, id := range slices.Backward(a.order) { - queue = append(queue, id) - queued[id] = true + work.Add(id) } - for len(queue) > 0 { - id := queue[0] - queue = queue[1:] - queued[id] = false + for { + id, pending := work.Next() + if !pending { + break + } out := make(map[*symbols.Symbol]ast.Node) node := a.sites[id] if node == nil || node.cfgSite == nil { continue } - for _, edge := range node.cfgSite.Successors { + for _, edge := range a.graph.SiteEdges.OutEdges(node.cfgSite.ID) { mergeSymbolLiveSets(out, a.symbolLiveIn[edge.To]) } uses, definitions := a.symbolUsesAndDefinitions(node) @@ -579,106 +631,136 @@ func (a *analyzer) computeSymbolLiveness() { } a.symbolLiveIn[id] = in a.symbolLiveOut[id] = out - for _, edge := range node.cfgSite.Predecessors { - pred := edge.From - if !queued[pred] { - queue = append(queue, pred) - queued[pred] = true - } + for _, edge := range a.graph.SiteEdges.InEdges(node.cfgSite.ID) { + work.Add(edge.From) } } } +// symbolUsesAndDefinitions reports what one site defines and what it reads, +// both from published effects. +// +// A write also counts as a use when the written symbol needs dropping: the +// pre-assignment drop reads the old value, so the target must stay live up to +// the assignment that replaces it. That is ownership policy and stays here. func (a *analyzer) symbolUsesAndDefinitions(node *site) (map[*symbols.Symbol]ast.Node, map[*symbols.Symbol]struct{}) { - uses := make(map[*symbols.Symbol]ast.Node) - definitions := make(map[*symbols.Symbol]struct{}) - if a == nil || node == nil || node.cfgSite == nil || - (node.cfgSite.Kind != cfg.SiteStatement && node.cfgSite.Kind != cfg.SiteTerminator) || node.stmt == nil { - return uses, definitions - } - addDefinition := func(binding ast.Node) { - if node.scope == nil || binding == nil { - return - } - if sym, found := node.scope.LookupNode(binding); found && trackedLiveSymbol(sym) { - definitions[sym] = struct{}{} - } - } - - switch stmt := node.stmt.(type) { - case *ast.LetDecl: - addDefinition(stmt) - case *ast.ConstDecl: - addDefinition(stmt) - case *ast.AssignStmt: - if target, ok := stmt.Target.(*ast.Ident); ok && node.scope != nil { - if sym, found := node.scope.Lookup(target.Name); found && trackedLiveSymbol(sym) { - definitions[sym] = struct{}{} - if typ, typed := symbols.GetSymbolType(sym); typed && typeinfo.NeedsDrop(typ) { - uses[sym] = target - } - } - } + visitor := &livenessEffectVisitor{ + a: a, + uses: make(map[*symbols.Symbol]ast.Node), + definitions: make(map[*symbols.Symbol]struct{}), } - for _, use := range a.symbolUseSequence(node, trackedLiveSymbol) { - if previous, found := uses[use.symbol]; !found { - uses[use.symbol] = use.site - } else { - uses[use.symbol] = earlierNode(previous, use.site) - } + if a == nil || a.module == nil || node == nil || node.cfgSite == nil { + return visitor.uses, visitor.definitions + } + for _, op := range a.effects[node.cfgSite.ID] { + effect.Visit(op, visitor) + } + return visitor.uses, visitor.definitions +} + +type livenessEffectVisitor struct { + a *analyzer + uses map[*symbols.Symbol]ast.Node + definitions map[*symbols.Symbol]struct{} +} + +func (v *livenessEffectVisitor) recordUse(sym *symbols.Symbol, at ast.NodeID) { + if sym == nil || v.a == nil || v.a.module == nil { + return + } + syntax, found := v.a.module.TypedASTNodes[at] + if !found { + return + } + if previous, seen := v.uses[sym]; seen { + v.uses[sym] = earlierNode(previous, syntax) + return + } + v.uses[sym] = syntax +} + +func (v *livenessEffectVisitor) VisitDefine(op effect.Define) { + // A binding that merely arrives at this site was established by the edge + // into it, so killing liveness here would end a borrow one site too early. + if !op.OnEntry && trackedLiveSymbol(op.Symbol) { + v.definitions[op.Symbol] = struct{}{} + } +} + +func (v *livenessEffectVisitor) VisitWrite(op effect.Write) { + if op.Place.Root == nil || !trackedLiveSymbol(op.Place.Root) { + return + } + if len(op.Place.Projections) > 0 { + v.recordUse(op.Place.Root, op.Node) + return + } + v.definitions[op.Place.Root] = struct{}{} + if typ, typed := symbols.GetSymbolType(op.Place.Root); typed && typeinfo.OwnershipCapabilityOf(typ).Drop { + v.recordUse(op.Place.Root, op.Node) + } +} + +func (v *livenessEffectVisitor) VisitUse(op effect.Use) { + if trackedLiveSymbol(op.Place.Root) { + v.recordUse(op.Place.Root, op.Node) } - return uses, definitions } +func (v *livenessEffectVisitor) VisitBorrow(op effect.Borrow) { + if trackedLiveSymbol(op.Place.Root) { + v.recordUse(op.Place.Root, op.Node) + } +} + +func (*livenessEffectVisitor) VisitIterate(effect.Iterate) {} +func (*livenessEffectVisitor) VisitDiscard(effect.Discard) {} +func (*livenessEffectVisitor) VisitCallBegin(effect.CallBegin) {} +func (*livenessEffectVisitor) VisitCallEnd(effect.CallEnd) {} + +// symbolUseSequence returns the symbols this site reads, in evaluation order. +// +// It reads published effects rather than walking the statement itself. The +// walk it replaced enumerated eight statement kinds and missed ForStmt.Iterable, +// which applyStmt does handle, so liveness and borrow-ending saw a different +// program than the effect analysis did. One producer means they cannot disagree. func (a *analyzer) symbolUseSequence(node *site, include func(*symbols.Symbol) bool) []symbolUse { - if a == nil || node == nil || node.cfgSite == nil || - (node.cfgSite.Kind != cfg.SiteStatement && node.cfgSite.Kind != cfg.SiteTerminator) || node.stmt == nil || - a.module == nil || a.module.Semantics == nil || include == nil { + if a == nil || a.module == nil || node == nil || node.cfgSite == nil || include == nil { return nil } - var expressions []ast.Expr - switch stmt := node.stmt.(type) { - case *ast.LetDecl: - expressions = append(expressions, stmt.Value) - case *ast.ConstDecl: - expressions = append(expressions, stmt.Value) - case *ast.AssignStmt: - expressions = append(expressions, stmt.Value) - if _, binding := stmt.Target.(*ast.Ident); !binding { - expressions = append(expressions, stmt.Target) - } - case *ast.ReturnStmt: - expressions = append(expressions, stmt.Value) - case *ast.ExprStmt: - expressions = append(expressions, stmt.Expr) - case *ast.IfStmt: - expressions = append(expressions, stmt.Cond) - case *ast.ForStmt: - expressions = append(expressions, stmt.Cond) - case *ast.MatchStmt: - expressions = append(expressions, stmt.Subject) - } - - var uses []symbolUse - for _, expr := range expressions { - if expr == nil { - continue - } - ast.Inspect(expr, func(current ast.Node) bool { - ident, ok := current.(*ast.Ident) - if !ok || ident == nil { - return true - } - sym := a.module.Semantics.ResolvedSymbols[ident.ID()] - if include(sym) { - uses = append(uses, symbolUse{symbol: sym, site: ident}) - } - return true - }) + visitor := &useSequenceEffectVisitor{a: a, include: include} + for _, op := range a.effects[node.cfgSite.ID] { + effect.Visit(op, visitor) } - return uses + return visitor.uses +} + +type useSequenceEffectVisitor struct { + a *analyzer + include func(*symbols.Symbol) bool + uses []symbolUse } +func (v *useSequenceEffectVisitor) record(at effect.Place, node ast.NodeID) { + if !v.include(at.Root) { + return + } + syntax, found := v.a.module.TypedASTNodes[node] + if !found { + return + } + v.uses = append(v.uses, symbolUse{symbol: at.Root, site: syntax}) +} + +func (*useSequenceEffectVisitor) VisitDefine(effect.Define) {} +func (*useSequenceEffectVisitor) VisitWrite(effect.Write) {} +func (v *useSequenceEffectVisitor) VisitUse(op effect.Use) { v.record(op.Place, op.Node) } +func (v *useSequenceEffectVisitor) VisitBorrow(op effect.Borrow) { v.record(op.Place, op.Node) } +func (*useSequenceEffectVisitor) VisitIterate(effect.Iterate) {} +func (*useSequenceEffectVisitor) VisitDiscard(effect.Discard) {} +func (*useSequenceEffectVisitor) VisitCallBegin(effect.CallBegin) {} +func (*useSequenceEffectVisitor) VisitCallEnd(effect.CallEnd) {} + func mergeSymbolLiveSets(dst, src map[*symbols.Symbol]ast.Node) { for sym, site := range src { if previous, found := dst[sym]; !found { diff --git a/internal/semantics/ownership/variant_rebind_test.go b/internal/semantics/ownership/variant_rebind_test.go new file mode 100644 index 00000000..39bf2a92 --- /dev/null +++ b/internal/semantics/ownership/variant_rebind_test.go @@ -0,0 +1,194 @@ +package ownership + +import ( + "fmt" + "slices" + "testing" + + "compiler/internal/diagnostics" + "compiler/internal/frontend/ast" + "compiler/internal/semantics/place" +) + +func TestProjectedEnumReferenceState(t *testing.T) { + result := checkOwnershipSource(t, `enum Resource { Borrowed: { value: &i32, sibling: &i32 }, Empty } +fn Read(_: &i32) {} +fn probe(mut first: i32, mut second: i32) { + let reference = &first; + let mut resource = Resource::Borrowed with .{ value = reference, sibling = reference }; + if resource is Resource::Borrowed { + resource.value = &second; + resource.value = resource.value; + match resource { + Resource::Borrowed with { value = value, sibling = sibling } => { Read(value); Read(sibling); } + Resource::Empty => {} + } + } +}`) + if result.HasErrors() { + t.Fatalf("unexpected diagnostics:\n%s", result.EmitAllToString()) + } + analysis := inspectFunctionAnalysis(t, result, "probe") + fn := result.module.AST.Stmts[2].(*ast.FnDecl) + branch := fn.Body.Stmts[2].(*ast.IfStmt) + assign := branch.Then.Stmts[0].(*ast.AssignStmt) + match := branch.Then.Stmts[2].(*ast.MatchStmt) + node := analysisNodeForStmt(t, analysis, match) + resource, _ := analysis.functionScope.Lookup("resource") + first, _ := analysis.functionScope.Lookup("first") + second, _ := analysis.functionScope.Lookup("second") + value := analysis.inStates[node.cfgSite.ID].references[resource] + if len(value) != 2 { + t.Fatalf("carrier loans = %#v, want two distinct slots", value) + } + for _, loan := range value { + if len(loan.path) != 2 || loan.path[0].Kind != place.OriginVariantPayload || loan.path[0].Case != 0 { + t.Fatalf("invalid carrier path: %#v", loan.path) + } + want := first + if loan.path[1].Field == "value" { + want = second + if loan.id.node != assign.Value { + t.Fatal("field self-assignment changed loan identity") + } + } else if loan.path[1].Field != "sibling" { + t.Fatalf("unexpected field path: %#v", loan.path) + } + if !place.SameOrigins(loan.origins, []place.Origin{{Root: want}}) { + t.Fatalf("%s loan origins = %#v, want %s", loan.path[1].Field, loan.origins, want.Name) + } + } + storage := result.module.Flow.ResolvedStorageOrigins[assign.Target.ID()] + if len(storage) != 1 || storage[0].Root != resource || !slices.Equal(storage[0].Projections, []place.OriginProjection{ + {Kind: place.OriginVariantPayload, Case: 0}, {Kind: place.OriginField, Field: "value"}, + }) { + t.Fatalf("assignment storage = %#v", storage) + } + binding := match.Arms[0].Fields[0].Binding + if got := result.module.Flow.ResolvedValueOrigins[binding.ID()]; !place.SameOrigins(got, []place.Origin{{Root: second}}) { + t.Fatalf("match value origins = %#v, want second", got) + } +} + +func TestProjectedEnumReferenceRebind(t *testing.T) { + tests := []struct { + name, fields, setup, body, code string + }{ + {name: "new referent stays borrowed", body: `resource.value = &second; +second = 3; +Read(resource.value);`, code: diagnostics.ErrBorrowConflict}, + {name: "old referent released", body: `resource.value = &second; +first = 3; +Read(resource.value);`}, + {name: "sibling retains old loan", fields: "value: &i32, sibling: &i32", setup: `let mut resource = Resource::Borrowed with .{ value = &first, sibling = &first };`, body: `resource.value = &second; +first = 3; +Read(resource.sibling);`, code: diagnostics.ErrBorrowConflict}, + {name: "same loan in two fields survives one replacement", fields: "value: &i32, sibling: &i32", setup: `let reference = &first; +let mut resource = Resource::Borrowed with .{ value = reference, sibling = reference };`, body: `resource.value = &second; +first = 3; +Read(resource.sibling);`, code: diagnostics.ErrBorrowConflict}, + {name: "same loan released after both replacements", fields: "value: &i32, sibling: &i32", setup: `let reference = &first; +let mut resource = Resource::Borrowed with .{ value = reference, sibling = reference };`, body: `resource.value = &second; +resource.sibling = &second; +first = 3; +Read(resource.value); +Read(resource.sibling);`}, + {name: "carrier copy retains old loan", body: `let duplicate = resource; +resource.value = &second; +first = 3; +match duplicate { +Resource::Borrowed with { value = reference } => { Read(reference); } +Resource::Empty => {} +}`, code: diagnostics.ErrBorrowConflict}, + {name: "field copy retains old loan", body: `let duplicate = resource.value; +resource.value = &second; +first = 3; +Read(duplicate);`, code: diagnostics.ErrBorrowConflict}, + {name: "independent copies release independently", body: `let mut duplicate = resource; +resource.value = &second; +if duplicate is Resource::Borrowed { +duplicate.value = &second; +first = 3; +Read(duplicate.value); +Read(resource.value); +}`}, + {name: "self assignment retains loan", body: `resource.value = resource.value; +first = 3; +Read(resource.value);`, code: diagnostics.ErrBorrowConflict}, + {name: "self assignment then replacement releases loan", body: `resource.value = resource.value; +resource.value = &second; +first = 3; +Read(resource.value);`}, + {name: "optional clear releases loan", fields: "value: ?&i32", body: `resource.value = none; +first = 3; +ReadOptional(resource.value);`}, + {name: "optional clear preserves sibling loan", fields: "value: ?&i32, sibling: &i32", setup: `let reference = &first; +let mut resource = Resource::Borrowed with .{ value = reference, sibling = reference };`, body: `resource.value = none; +first = 3; +Read(resource.sibling);`, code: diagnostics.ErrBorrowConflict}, + {name: "mutable replacement releases old loan", fields: "value: &mut i32", setup: `let mut resource = Resource::Borrowed with .{ value = &mut first };`, body: `resource.value = &mut second; +first = 3; +Write(resource.value);`}, + {name: "mutable replacement protects new loan", fields: "value: &mut i32", setup: `let mut resource = Resource::Borrowed with .{ value = &mut first };`, body: `resource.value = &mut second; +second = 3; +match resource { +Resource::Borrowed with { value = reference } => { Write(reference); } +Resource::Empty => {} +}`, code: diagnostics.ErrBorrowConflict}, + {name: "mutable moved carrier retains new loan", fields: "value: &mut i32", setup: `let mut resource = Resource::Borrowed with .{ value = &mut first };`, body: `resource.value = &mut second; +let moved = resource; +second = 3; +match moved { +Resource::Borrowed with { value = reference } => { Write(reference); } +Resource::Empty => {} +}`, code: diagnostics.ErrBorrowConflict}, + {name: "both branches release old loan", body: `if flag { resource.value = &second; } else { resource.value = &third; } +first = 3; +Read(resource.value);`}, + {name: "one branch retains old loan", body: `if flag { resource.value = &second; } +first = 3; +Read(resource.value);`, code: diagnostics.ErrBorrowConflict}, + {name: "branch replacement protects new loan", body: `if flag { resource.value = &second; } +second = 3; +Read(resource.value);`, code: diagnostics.ErrBorrowConflict}, + {name: "zero iteration loop retains old loan", body: `for flag { resource.value = &second; } +first = 3; +Read(resource.value);`, code: diagnostics.ErrBorrowConflict}, + {name: "loop replacement protects new loan", body: `for flag { resource.value = &second; } +second = 3; +Read(resource.value);`, code: diagnostics.ErrBorrowConflict}, + {name: "post loop replacement releases old loan", body: `for flag { resource.value = &second; } +resource.value = &third; +first = 3; +second = 4; +Read(resource.value);`}, + } + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + if test.fields == "" { + test.fields = "value: &i32" + } + if test.setup == "" { + test.setup = "let mut resource = Resource::Borrowed with .{ value = &first };" + } + src := fmt.Sprintf(`enum Resource { Borrowed: { %s }, Empty } +fn Read(_: &i32) {} +fn ReadOptional(_: ?&i32) {} +fn Write(_: &mut i32) {} +fn probe(mut first: i32, mut second: i32, mut third: i32, flag: bool) { +%s +if resource is Resource::Borrowed { +%s +} +}`, test.fields, test.setup, test.body) + result := checkOwnershipSource(t, src) + if test.code == "" { + if result.HasErrors() { + t.Fatalf("unexpected diagnostics:\n%s", result.EmitAllToString()) + } + } else if !hasOwnershipCode(result, test.code) { + t.Fatalf("expected %s:\n%s", test.code, result.EmitAllToString()) + } + }) + } +} diff --git a/internal/semantics/ownershipresult/result.go b/internal/semantics/ownershipresult/result.go index 63589b5f..11ae8371 100644 --- a/internal/semantics/ownershipresult/result.go +++ b/internal/semantics/ownershipresult/result.go @@ -7,13 +7,29 @@ import ( ) // CleanupPlan records ownership effects at CFG and stable HIR source sites. +// +// It is the only source of drop obligations over source values: lowering reads +// the plan and never decides a drop for itself. The two other drops in the +// pipeline are not competing policy — a source-level `free` is the programmer's +// own drop, and MIR's temporary drops destroy temporaries MIR itself +// materializes, which have no source symbol to plan against. +// +// Scope exit and return stay separate channels because the events differ. A +// scope-exit site leaves exactly one scope, and its drops emit while the block's +// sites are processed. A return leaves every enclosing scope at once, and its +// drops must emit after the returned value is computed — a value read from a +// local being unwound would otherwise be freed before it is read. Folding return +// into scope-exit sites therefore requires MIR to defer trailing site drops +// until after the terminator's value expression. type CleanupPlan struct { - AfterScope map[cfg.SiteID][]symbols.SymbolID + // AfterScope drops the symbols owned by the one scope a site exits. + AfterScope map[cfg.SiteID][]symbols.SymbolID + // BeforeReturn drops every scope a return unwinds, after its value is + // computed. Keyed by the return statement, which is the event, not a site. BeforeReturn map[ir.NodeID][]symbols.SymbolID BeforeAssign map[ir.NodeID]struct{} DiscardedValue map[ir.NodeID]struct{} ProjectionBase map[ir.NodeID]struct{} - MatchCarrierMoves map[ir.NodeID]symbols.SymbolID MatchFieldDrops map[ir.NodeID][]int MatchWholePayloadDrops map[ir.NodeID]struct{} } diff --git a/internal/semantics/ownershipresult/validate.go b/internal/semantics/ownershipresult/validate.go new file mode 100644 index 00000000..844168f9 --- /dev/null +++ b/internal/semantics/ownershipresult/validate.go @@ -0,0 +1,179 @@ +package ownershipresult + +import ( + "errors" + "fmt" + "sort" + "strings" + + "compiler/internal/frontend/ast" + "compiler/internal/ir" + "compiler/internal/ir/cfg" + "compiler/internal/semantics/bindingresult" + "compiler/internal/semantics/symbols" + "compiler/internal/semantics/typecheckresult" + "compiler/internal/semantics/typeinfo" +) + +// maxReportedProblems bounds one internal error so a systematic evidence break +// reports a readable sample instead of one line per node in the module. +const maxReportedProblems = 10 + +// Validate checks published ownership evidence against the artifacts it +// describes: every plan key must name a real program point, every published use +// kind must belong to a typed expression and be legal for that type's +// capability, and every call argument the typechecker resolved must carry a +// classification. A failure means the compiler published inconsistent evidence, +// so callers report it as an internal error, never as a source diagnostic. +// +// The validator never re-derives an ownership decision. In particular, proving +// that a symbol is dropped exactly once per path is deliberately out of scope: +// that is the analysis ownership already performs, and repeating it here would +// make the validator a second implementation of the thing it checks rather than +// a check on published shape. +func (r Result) Validate(types *typecheckresult.Result, bindings *bindingresult.Result, graphs *cfg.Module) error { + if len(r) == 0 { + return nil + } + if types == nil || bindings == nil || graphs == nil { + return errors.New("ownership published a cleanup plan without typechecking, binding, or CFG evidence") + } + + problems := validateValueUses(types) + for fnID, plan := range r { + problems = append(problems, validatePlan(fnID, plan, types, bindings, graphs)...) + } + if len(problems) == 0 { + return nil + } + // Plans and evidence are maps, so a stable report needs an explicit order. + sort.Strings(problems) + total := len(problems) + if len(problems) > maxReportedProblems { + problems = problems[:maxReportedProblems] + return fmt.Errorf("%s (%d more)", strings.Join(problems, "; "), total-maxReportedProblems) + } + return errors.New(strings.Join(problems, "; ")) +} + +// validateValueUses checks the published use kinds against the expressions they +// classify: a kind for an untyped node is stale evidence, a kind the type's +// capability forbids is an illegal classification, and a resolved call argument +// with no kind is the gap the ownership fallback used to hide. +func validateValueUses(types *typecheckresult.Result) []string { + problems := make([]string, 0) + for id, use := range types.ValueUses { + valueType, typed := types.ExprTypes[id] + if !typed { + problems = append(problems, fmt.Sprintf("use kind published for node %d with no expression type", id)) + continue + } + if use == typeinfo.UseCopy && typeinfo.OwnershipCapabilityOf(valueType).Copy == typeinfo.CopyNever { + problems = append(problems, fmt.Sprintf("node %d copies %s, which has no copy operation", id, typeinfo.TypeText(valueType))) + } + } + for callID, args := range types.EffectiveCallArguments { + for index, arg := range args { + if arg == nil { + continue + } + if _, published := types.ValueUses[arg.ID()]; !published { + problems = append(problems, fmt.Sprintf("call %d argument %d has no published use kind", callID, index)) + } + } + } + return problems +} + +// validatePlan checks one function's cleanup plan against its CFG and the +// program points each map is keyed by. +func validatePlan(fnID ir.NodeID, plan *CleanupPlan, types *typecheckresult.Result, bindings *bindingresult.Result, graphs *cfg.Module) []string { + if plan == nil { + return []string{fmt.Sprintf("function %d has a nil cleanup plan", fnID)} + } + graph := graphs.Function(fnID) + if graph == nil { + return []string{fmt.Sprintf("function %d has a cleanup plan but no CFG", fnID)} + } + + scopeExits := make(map[cfg.SiteID]struct{}) + siteNodes := make(map[ir.NodeID]struct{}) + for _, block := range graph.Blocks { + if block == nil { + continue + } + for _, site := range block.Sites { + if site == nil { + continue + } + siteNodes[site.NodeID] = struct{}{} + if site.Kind == cfg.SiteScopeExit { + scopeExits[site.ID] = struct{}{} + } + } + } + + problems := make([]string, 0) + for siteID, ids := range plan.AfterScope { + if _, exists := scopeExits[siteID]; !exists { + problems = append(problems, fmt.Sprintf("function %d drops at site %v, which is not a scope exit in its CFG", fnID, siteID)) + } + problems = append(problems, validateSymbols(fnID, "scope exit", ids)...) + } + for nodeID, ids := range plan.BeforeReturn { + if _, exists := siteNodes[nodeID]; !exists { + problems = append(problems, fmt.Sprintf("function %d drops before return %d, which is not a site in its CFG", fnID, nodeID)) + } + problems = append(problems, validateSymbols(fnID, "return", ids)...) + } + for nodeID := range plan.BeforeAssign { + if _, exists := siteNodes[nodeID]; !exists { + problems = append(problems, fmt.Sprintf("function %d drops before assignment %d, which is not a site in its CFG", fnID, nodeID)) + } + } + for nodeID := range plan.DiscardedValue { + problems = append(problems, validateTypedNode(types, fnID, "discarded value", nodeID)...) + } + for nodeID := range plan.ProjectionBase { + problems = append(problems, validateTypedNode(types, fnID, "projection base", nodeID)...) + } + for nodeID := range plan.MatchWholePayloadDrops { + problems = append(problems, validateArmBody(bindings, fnID, "match payload drop", nodeID)...) + } + for nodeID, fields := range plan.MatchFieldDrops { + problems = append(problems, validateArmBody(bindings, fnID, "match field drop", nodeID)...) + for _, field := range fields { + if field < 0 { + problems = append(problems, fmt.Sprintf("function %d drops match field %d at %d", fnID, field, nodeID)) + } + } + } + return problems +} + +// validateSymbols rejects unidentified cleanup targets. Full symbol-identity +// checking waits for a canonical symbol registry; a zero id is already proof the +// plan lost the symbol it meant to drop. +func validateSymbols(fnID ir.NodeID, where string, ids []symbols.SymbolID) []string { + problems := make([]string, 0) + for _, id := range ids { + if id == 0 { + problems = append(problems, fmt.Sprintf("function %d plans an unidentified %s drop", fnID, where)) + } + } + return problems +} + +func validateTypedNode(types *typecheckresult.Result, fnID ir.NodeID, where string, nodeID ir.NodeID) []string { + if _, typed := types.ExprTypes[ast.NodeID(nodeID)]; typed { + return nil + } + return []string{fmt.Sprintf("function %d plans a %s at node %d with no expression type", fnID, where, nodeID)} +} + +func validateArmBody(bindings *bindingresult.Result, fnID ir.NodeID, where string, nodeID ir.NodeID) []string { + if _, scoped := bindings.BlockScopes[ast.NodeID(nodeID)]; scoped { + return nil + } + return []string{fmt.Sprintf("function %d plans a %s at node %d, which is not a block", fnID, where, nodeID)} +} diff --git a/internal/semantics/ownershipresult/validate_test.go b/internal/semantics/ownershipresult/validate_test.go new file mode 100644 index 00000000..04885709 --- /dev/null +++ b/internal/semantics/ownershipresult/validate_test.go @@ -0,0 +1,179 @@ +package ownershipresult + +import ( + "strings" + "testing" + + "compiler/internal/diagnostics" + "compiler/internal/frontend/ast" + "compiler/internal/frontend/lexer" + "compiler/internal/frontend/parser" + "compiler/internal/ir" + "compiler/internal/ir/cfg" + "compiler/internal/semantics/bindingresult" + "compiler/internal/semantics/symbols" + "compiler/internal/semantics/typecheckresult" + "compiler/internal/semantics/typeinfo" +) + +// buildGraph produces real CFG topology so the validator is checked against the +// artifact it actually receives, not a hand-shaped stand-in. +func buildGraph(t *testing.T) (*cfg.Module, ir.NodeID) { + t.Helper() + const file = "validate_test" + ".peep" + diag := diagnostics.NewDiagnosticBag() + source := parser.New(file, lexer.New(file, "fn main() -> i32 {\n\treturn 0;\n}\n", diag).Tokenize(), diag).ParseModule() + graphs := cfg.BuildModule(source, cfg.BuildQueries{}) + if graphs == nil || len(graphs.Functions) == 0 { + t.Fatalf("no CFG built: %s", diag.EmitAllToString()) + } + return graphs, graphs.Functions[0].NodeID +} + +func emptyPlan() *CleanupPlan { + return &CleanupPlan{ + AfterScope: make(map[cfg.SiteID][]symbols.SymbolID), + BeforeReturn: make(map[ir.NodeID][]symbols.SymbolID), + BeforeAssign: make(map[ir.NodeID]struct{}), + DiscardedValue: make(map[ir.NodeID]struct{}), + ProjectionBase: make(map[ir.NodeID]struct{}), + MatchFieldDrops: make(map[ir.NodeID][]int), + MatchWholePayloadDrops: make(map[ir.NodeID]struct{}), + } +} + +func TestValidateAcceptsConsistentEvidence(t *testing.T) { + graphs, fnID := buildGraph(t) + result := Result{fnID: emptyPlan()} + if err := result.Validate(typecheckresult.New(), bindingresult.New(), graphs); err != nil { + t.Fatalf("consistent evidence rejected: %v", err) + } +} + +func TestValidateRejectsEvidenceGaps(t *testing.T) { + graphs, fnID := buildGraph(t) + argument := &ast.Ident{Name: "value"} + argument.SetID(41) + + for _, tt := range []struct { + name string + want string + build func(*typecheckresult.Result, *CleanupPlan) + }{ + { + name: "use kind without a type", + want: "no expression type", + build: func(types *typecheckresult.Result, _ *CleanupPlan) { + types.ValueUses[7] = typeinfo.UseMove + }, + }, + { + name: "copy of a type with no copy operation", + want: "no copy operation", + build: func(types *typecheckresult.Result, _ *CleanupPlan) { + types.ExprTypes[7] = &typeinfo.StringType{} + types.ValueUses[7] = typeinfo.UseCopy + }, + }, + { + name: "call argument with no use kind", + want: "no published use kind", + build: func(types *typecheckresult.Result, _ *CleanupPlan) { + types.EffectiveCallArguments[5] = []ast.Expr{argument} + }, + }, + { + name: "drop at a site that is not a scope exit", + want: "not a scope exit", + build: func(_ *typecheckresult.Result, plan *CleanupPlan) { + plan.AfterScope[cfg.SiteID{}] = []symbols.SymbolID{1} + }, + }, + { + name: "return drop at an unknown node", + want: "not a site in its CFG", + build: func(_ *typecheckresult.Result, plan *CleanupPlan) { + plan.BeforeReturn[9999] = []symbols.SymbolID{1} + }, + }, + { + name: "unidentified drop target", + want: "unidentified", + build: func(_ *typecheckresult.Result, plan *CleanupPlan) { + plan.BeforeReturn[9999] = []symbols.SymbolID{0} + }, + }, + { + name: "projection base with no type", + want: "no expression type", + build: func(_ *typecheckresult.Result, plan *CleanupPlan) { + plan.ProjectionBase[8888] = struct{}{} + }, + }, + { + name: "match drop outside a block", + want: "not a block", + build: func(_ *typecheckresult.Result, plan *CleanupPlan) { + plan.MatchWholePayloadDrops[7777] = struct{}{} + }, + }, + } { + t.Run(tt.name, func(t *testing.T) { + types := typecheckresult.New() + plan := emptyPlan() + tt.build(types, plan) + err := Result{fnID: plan}.Validate(types, bindingresult.New(), graphs) + if err == nil { + t.Fatal("inconsistent evidence accepted") + } + if !strings.Contains(err.Error(), tt.want) { + t.Fatalf("error = %q, want it to mention %q", err, tt.want) + } + }) + } +} + +func TestValidateRejectsPlanWithoutCFG(t *testing.T) { + graphs, fnID := buildGraph(t) + err := Result{fnID + 1000: emptyPlan()}.Validate(typecheckresult.New(), bindingresult.New(), graphs) + if err == nil || !strings.Contains(err.Error(), "no CFG") { + t.Fatalf("error = %v, want a missing-CFG report", err) + } +} + +func TestValidateRejectsNilPlan(t *testing.T) { + graphs, fnID := buildGraph(t) + err := Result{fnID: nil}.Validate(typecheckresult.New(), bindingresult.New(), graphs) + if err == nil || !strings.Contains(err.Error(), "nil cleanup plan") { + t.Fatalf("error = %v, want a nil-plan report", err) + } +} + +// Plans and evidence are maps, so an unsorted report would name different +// problems on different runs for one broken module. +func TestValidateReportsProblemsDeterministically(t *testing.T) { + graphs, fnID := buildGraph(t) + first := "" + for attempt := 0; attempt < 8; attempt++ { + types := typecheckresult.New() + plan := emptyPlan() + for id := ast.NodeID(1); id <= 40; id++ { + types.ValueUses[id] = typeinfo.UseMove + plan.ProjectionBase[ir.NodeID(id)] = struct{}{} + } + err := Result{fnID: plan}.Validate(types, bindingresult.New(), graphs) + if err == nil { + t.Fatal("inconsistent evidence accepted") + } + if attempt == 0 { + first = err.Error() + continue + } + if err.Error() != first { + t.Fatalf("report changed between runs:\n%s\n%s", first, err.Error()) + } + } + if !strings.Contains(first, "more)") { + t.Fatalf("report = %q, want a truncated sample", first) + } +} diff --git a/internal/semantics/place/addressable.go b/internal/semantics/place/addressable.go index 9d482efd..fb9d0158 100644 --- a/internal/semantics/place/addressable.go +++ b/internal/semantics/place/addressable.go @@ -27,12 +27,76 @@ type Binding struct { // escape source. type BindingResolver func(*ast.Ident) (Binding, bool) +// Projection describes one syntactic projection from a base expression. +// +// This is the canonical structural definition of Peeper place projections. +// Consumers that only need to know how selector/index syntax is nested must use +// this API rather than maintaining their own AST switches. Semantic place +// resolution remains in Resolve, which enriches these structural projections +// with pointer, reference, optional-payload, and stable-index information. +type Projection struct { + Base ast.Expr + Step OriginProjection + Index ast.Expr +} + +// Project reports the direct projection represented by expr. Slices are not +// places: they borrow a range rather than select one independently addressable +// element, so an IndexExpr containing a RangeExpr is deliberately rejected. +func Project(expr ast.Expr) (Projection, bool) { + switch node := expr.(type) { + case *ast.SelectorExpr: + if node == nil || node.Expr == nil || node.Name == nil { + return Projection{}, false + } + return Projection{ + Base: node.Expr, + Step: OriginProjection{Kind: OriginField, Field: node.Name.Name}, + }, true + case *ast.IndexExpr: + if node == nil || node.Expr == nil || node.Index == nil { + return Projection{}, false + } + if _, slicing := node.Index.(*ast.RangeExpr); slicing { + return Projection{}, false + } + return Projection{ + Base: node.Expr, + Step: OriginProjection{Kind: OriginIndex}, + Index: node.Index, + }, true + default: + return Projection{}, false + } +} + +// Decompose peels every selector/index projection and returns the expression at +// the root plus the projection path in source order. It does not require that +// the root be an identifier: `make().field` therefore decomposes successfully +// and lets callers distinguish a temporary root from named storage themselves. +func Decompose(expr ast.Expr) (ast.Expr, []OriginProjection, bool) { + if expr == nil { + return nil, nil, false + } + projection, projected := Project(expr) + if !projected { + return expr, nil, true + } + root, path, ok := Decompose(projection.Base) + if !ok { + return nil, nil, false + } + path = append(path, projection.Step) + return root, path, true +} + func IsPlaceExpr(expr ast.Expr) bool { - if node, ok := expr.(*ast.Ident); ok { - return node != nil + root, _, ok := Decompose(expr) + if !ok { + return false } - base, ok := placeProjectionBase(expr) - return ok && IsPlaceExpr(base) + ident, identified := root.(*ast.Ident) + return identified && ident != nil } func Addressable(scope *symbols.Scope, expr ast.Expr, exprType ExprTypeFunc, resolve BindingResolver) bool { @@ -51,10 +115,11 @@ func Addressable(scope *symbols.Scope, expr ast.Expr, exprType ExprTypeFunc, res sym, found := scope.Lookup(e.Name) return found && addressableSymbol(sym) } - base, ok := placeProjectionBase(expr) + projection, ok := Project(expr) if !ok { return false } + base := projection.Base if exprType != nil { if _, ok := typeinfo.PointerTarget(typeinfo.Underlying(exprType(base))); ok { return true @@ -63,7 +128,7 @@ func Addressable(scope *symbols.Scope, expr ast.Expr, exprType ExprTypeFunc, res return true } } - return Addressable(scope, base, exprType, resolve) + return Addressable(scope, projection.Base, exprType, resolve) } func MutableAddressable(scope *symbols.Scope, expr ast.Expr, exprType ExprTypeFunc, resolve BindingResolver) (mutable bool, sharedReference typeinfo.Type, mutableBinding *symbols.Symbol) { @@ -94,10 +159,11 @@ func MutableAddressable(scope *symbols.Scope, expr ast.Expr, exprType ExprTypeFu return MutableAddressable(scope, index.Expr, exprType, resolve) } } - base, ok := placeProjectionBase(expr) + projection, ok := Project(expr) if !ok { return false, nil, nil } + base := projection.Base if exprType != nil { baseType := typeinfo.Underlying(exprType(base)) if _, ok := baseType.(*typeinfo.RawPtrType); ok { @@ -143,10 +209,11 @@ func LocalRoot(scope, moduleScope *symbols.Scope, expr ast.Expr, exprType ExprTy } return nil, false } - base, ok := placeProjectionBase(expr) + projection, ok := Project(expr) if !ok { return nil, false } + base := projection.Base if exprType != nil { if _, ok := typeinfo.PointerTarget(typeinfo.Underlying(exprType(base))); ok { return nil, false @@ -155,26 +222,6 @@ func LocalRoot(scope, moduleScope *symbols.Scope, expr ast.Expr, exprType ExprTy return LocalRoot(scope, moduleScope, base, exprType, resolve) } -func placeProjectionBase(expr ast.Expr) (ast.Expr, bool) { - switch node := expr.(type) { - case *ast.SelectorExpr: - if node == nil || node.Expr == nil { - return nil, false - } - return node.Expr, true - case *ast.IndexExpr: - if node == nil || node.Expr == nil || node.Index == nil { - return nil, false - } - if _, slicing := node.Index.(*ast.RangeExpr); slicing { - return nil, false - } - return node.Expr, true - default: - return nil, false - } -} - func addressableSymbol(sym *symbols.Symbol) bool { if sym == nil { return false diff --git a/internal/semantics/resolver/resolver.go b/internal/semantics/resolver/resolver.go index f4c075fb..26a51e51 100644 --- a/internal/semantics/resolver/resolver.go +++ b/internal/semantics/resolver/resolver.go @@ -7,6 +7,7 @@ import ( "compiler/internal/frontend/ast" "compiler/internal/problems" "compiler/internal/project" + "compiler/internal/semantics/bindingresult" "compiler/internal/semantics/symbols" "compiler/internal/source" ) @@ -20,8 +21,8 @@ func (r *resolver) resolveModule() { if r == nil || r.module == nil || r.module.AST == nil { return } - if r.module.Semantics == nil { - r.module.Semantics = project.NewSemanticInfo() + if r.module.Bindings == nil { + r.module.Bindings = bindingresult.New() } r.markPendingTopLevelBindings() ast.ForEachDecl(r.module.AST, func(decl ast.Decl) bool { @@ -77,7 +78,7 @@ func (r *resolver) resolveFunction(fn *ast.FnDecl) { } var sym *symbols.Symbol if fn.Receiver != nil { - sym = r.module.Semantics.MethodSymbol[fn.ID()] + sym = r.module.Bindings.MethodsByDecl[fn.ID()] } else { sym, _ = r.module.ModuleScope.Lookup(fn.Name.Name) } @@ -119,7 +120,7 @@ func (r *resolver) resolveFunction(fn *ast.FnDecl) { name = fn.Receiver.Name.Name } if source, ok := funcScope.Lookup(name); ok && source != nil && source.Kind == symbols.SymbolParam { - r.module.Semantics.ResolvedSymbols[origin.ID()] = source + r.module.Bindings.NodeSymbols[origin.ID()] = source source.Used = true } } @@ -133,7 +134,7 @@ func (r *resolver) resolveBlock(scope *symbols.Scope, block *ast.BlockStmt) { if block == nil { return } - r.module.Semantics.BlockScopes[block.ID()] = scope + r.module.Bindings.BlockScopes[block.ID()] = scope for _, stmt := range block.Stmts { r.resolveStmt(scope, stmt) } @@ -174,12 +175,12 @@ func (r *resolver) resolveStmt(scope *symbols.Scope, stmt ast.Stmt) { bodyScope := symbols.NewScope(scope) if node.Index != nil { if binding := r.resolveLocalBinding(bodyScope, node.Index, symbols.SymbolVar, nil, node.Index, ast.LocOf(node.Index)); binding != nil { - r.module.Semantics.ResolvedSymbols[node.Index.ID()] = binding + r.module.Bindings.NodeSymbols[node.Index.ID()] = binding } } if node.Value != nil { if binding := r.resolveLocalBinding(bodyScope, node.Value, symbols.SymbolVar, nil, node.Value, ast.LocOf(node.Value)); binding != nil { - r.module.Semantics.ResolvedSymbols[node.Value.ID()] = binding + r.module.Bindings.NodeSymbols[node.Value.ID()] = binding } } r.resolveBlock(bodyScope, node.Body) @@ -200,7 +201,7 @@ func (r *resolver) resolveStmt(scope *symbols.Scope, stmt ast.Stmt) { armScope := symbols.NewScope(scope) if arm.Binding != nil && !arm.Discard { if binding := r.resolveLocalBinding(armScope, arm.Binding, symbols.SymbolVar, nil, arm.Binding, arm.Location); binding != nil { - r.module.Semantics.ResolvedSymbols[arm.Binding.ID()] = binding + r.module.Bindings.NodeSymbols[arm.Binding.ID()] = binding } } for _, field := range arm.Fields { @@ -208,7 +209,7 @@ func (r *resolver) resolveStmt(scope *symbols.Scope, stmt ast.Stmt) { continue } if binding := r.resolveLocalBinding(armScope, field.Binding, symbols.SymbolVar, nil, field.Binding, field.Location); binding != nil { - r.module.Semantics.ResolvedSymbols[field.Binding.ID()] = binding + r.module.Bindings.NodeSymbols[field.Binding.ID()] = binding } } r.resolveBlock(armScope, arm.Body) @@ -263,7 +264,7 @@ func (r *resolver) resolveExpr(scope *symbols.Scope, expr ast.Expr) { case *ast.Ident: sym, ok := scope.Lookup(node.Name) if ok && sym != nil { - r.module.Semantics.ResolvedSymbols[node.ID()] = sym + r.module.Bindings.NodeSymbols[node.ID()] = sym sym.Used = true if sym.Kind == symbols.SymbolImport { r.ctx.Diagnostics.AddError(diagnostics.ErrInvalidExpression, "import alias must be qualified with `::`", ast.LocOf(node), "") @@ -387,7 +388,7 @@ func (r *resolver) resolveScopeResolution(node *ast.ScopeResolution, allowTypeAr if !ok { return false } - r.module.Semantics.ResolvedSymbols[node.ID()] = resolved + r.module.Bindings.NodeSymbols[node.ID()] = resolved return true } @@ -443,9 +444,9 @@ func (r *resolver) resolveVariantPath(scope *symbols.Scope, path *ast.ScopeResol qualifierSymbol.Used = true enumSymbol.Used = true variant.Used = true - r.module.Semantics.ResolvedSymbols[enumName.ID()] = qualifierSymbol - r.module.Semantics.ResolvedSymbols[path.ID()] = variant - r.module.Semantics.ResolvedSymbols[caseName.ID()] = variant + r.module.Bindings.NodeSymbols[enumName.ID()] = qualifierSymbol + r.module.Bindings.NodeSymbols[path.ID()] = variant + r.module.Bindings.NodeSymbols[caseName.ID()] = variant return true } @@ -467,7 +468,26 @@ func (r *resolver) lookupImportedMember(qualifierNode, memberNode *ast.Ident, si r.ctx.Diagnostics.AddError(diagnostics.ErrSymbolNotExported, "`"+member+"` is not exported from `"+qualifier+"`", ast.LocOf(site), "use of unexported symbol"). WithSecondaryLabel(resolved.Symbol.Location, "defined here"). WithNote("symbols with uppercase are exported otherwise private") + r.reportGlobalQualifier(resolved.Symbol, qualifier, site) return nil, false } + r.reportGlobalQualifier(resolved.Symbol, qualifier, site) return resolved.Symbol, true } + +// reportGlobalQualifier notes when a qualified name is the exact symbol already +// injected into global scope, so the `alias::` prefix adds nothing. Comparing +// symbol identity rather than name keeps a same-named unrelated export quiet. +func (r *resolver) reportGlobalQualifier(sym *symbols.Symbol, qualifier string, site ast.Node) { + if sym == nil || r.ctx == nil || r.ctx.GlobalScope == nil { + return + } + global, found := r.ctx.GlobalScope.Lookup(sym.Name) + if !found || global == nil || global.ID != sym.ID { + return + } + r.ctx.Diagnostics.Add(diagnostics.NewInfo("`"+sym.Name+"` is already in scope without `"+qualifier+"::`"). + WithCode(diagnostics.InfoRedundantGlobalQualifier). + WithPrimaryLabel(ast.LocOf(site), "drop the `"+qualifier+"::` prefix"). + WithNote("global symbols are always in scope")) +} diff --git a/internal/semantics/resolver/resolver_test.go b/internal/semantics/resolver/resolver_test.go index 10ac8125..6741a783 100644 --- a/internal/semantics/resolver/resolver_test.go +++ b/internal/semantics/resolver/resolver_test.go @@ -8,6 +8,7 @@ import ( "compiler/internal/frontend/ast" "compiler/internal/frontend/lexer" "compiler/internal/frontend/parser" + "compiler/internal/moduleid" "compiler/internal/project" "compiler/internal/semantics/binder" "compiler/internal/semantics/collector" @@ -23,12 +24,11 @@ func checkResolveSource(t *testing.T, src string) (*project.Module, *diagnostics ctx := project.New(".", peeper.SourceExt, diag) modAST := parser.New(filePath, lexer.New(filePath, src, diag).Tokenize(), diag).ParseModule() module := &project.Module{ - Key: project.ModuleKeyFor(project.ModuleOriginLocal, filePath), - ImportPath: "resolver_test", - FilePath: filePath, - Content: src, - AST: modAST, - Imports: make(map[string]project.ResolvedImport), + ID: moduleid.ID{Origin: string(project.ModuleOriginLocal), ImportPath: "resolver_test"}, + FilePath: filePath, + Content: src, + AST: modAST, + Imports: make(map[string]project.ResolvedImport), } ctx.AddModule(module) collector.Collect(ctx, module) @@ -82,7 +82,7 @@ fn main() { okPath := fn.Body.Stmts[0].(*ast.LetDecl).Value.(*ast.VariantLit).Case pendingPath := fn.Body.Stmts[1].(*ast.LetDecl).Value.(*ast.ScopeResolution) for _, path := range []*ast.ScopeResolution{okPath, pendingPath} { - sym := module.Semantics.ResolvedSymbols[path.ID()] + sym := module.Bindings.NodeSymbols[path.ID()] if sym == nil { t.Fatalf("resolved %s = nil, want child variant symbol", path.TypeText()) } @@ -90,7 +90,7 @@ fn main() { if sym.Kind != symbols.SymbolVariant || !variant || sym.Name != path.Segments[len(path.Segments)-1].Name.Name { t.Fatalf("resolved %s = %#v, want child variant symbol", path.TypeText(), sym) } - if module.Semantics.ResolvedSymbols[path.Segments[len(path.Segments)-1].Name.ID()] != sym { + if module.Bindings.NodeSymbols[path.Segments[len(path.Segments)-1].Name.ID()] != sym { t.Fatalf("final segment of %s does not resolve to variant symbol", path.TypeText()) } } @@ -123,7 +123,7 @@ fn main() { t.Fatalf("invalid variant path %s", path.TypeText()) } canonical, _ := result.Scope.LookupLocal(caseName.Name) - if got := module.Semantics.ResolvedSymbols[path.ID()]; got == nil || got != canonical { + if got := module.Bindings.NodeSymbols[path.ID()]; got == nil || got != canonical { t.Fatalf("resolved %s = %#v, want canonical %#v", path.TypeText(), got, canonical) } } @@ -171,11 +171,11 @@ fn Read(result: Result) -> i32 { match := fn.Body.Stmts[0].(*ast.MatchStmt) binding := match.Arms[0].Fields[0].Binding use := match.Arms[0].Body.Stmts[0].(*ast.ReturnStmt).Value.(*ast.Ident) - bindingSymbol := module.Semantics.ResolvedSymbols[binding.ID()] - if bindingSymbol == nil || module.Semantics.ResolvedSymbols[use.ID()] != bindingSymbol { - t.Fatalf("pattern binding = %#v, use = %#v", bindingSymbol, module.Semantics.ResolvedSymbols[use.ID()]) + bindingSymbol := module.Bindings.NodeSymbols[binding.ID()] + if bindingSymbol == nil || module.Bindings.NodeSymbols[use.ID()] != bindingSymbol { + t.Fatalf("pattern binding = %#v, use = %#v", bindingSymbol, module.Bindings.NodeSymbols[use.ID()]) } - if _, found := module.Semantics.BlockScopes[match.Arms[0].Body.ID()].Lookup("payload"); !found { + if _, found := module.Bindings.BlockScopes[match.Arms[0].Body.ID()].Lookup("payload"); !found { t.Fatal("pattern binding missing from arm body scope") } } diff --git a/internal/semantics/symbols/symbol.go b/internal/semantics/symbols/symbol.go index 34a34f53..00a904b4 100644 --- a/internal/semantics/symbols/symbol.go +++ b/internal/semantics/symbols/symbol.go @@ -6,6 +6,8 @@ import ( "unicode/utf8" "compiler/internal/frontend/ast" + "compiler/internal/moduleid" + "compiler/internal/semantics/typeinfo" "compiler/internal/source" ) @@ -44,23 +46,11 @@ const ( SymbolUnknown Kind = "unknown" ) -type Type interface { - TypeNode() - Text() string -} - -type DefiningModuleKey struct { - Origin string - Namespace string - Dependency string - ImportPath string -} - type Symbol struct { ID SymbolID Name string Kind Kind - Type Type + Type typeinfo.Type IsPub bool Mutable bool IsReceiver bool @@ -68,7 +58,7 @@ type Symbol struct { Used bool RequiresMutable bool CompilerOp CompilerOp - DefiningModule DefiningModuleKey + DefiningModule moduleid.ID Location *source.Location MutableLocation *source.Location ASTNode ast.Node @@ -86,7 +76,7 @@ func New(name string, kind Kind, node ast.Node, location *source.Location) *Symb } } -func (s *Symbol) BindType(typ Type) bool { +func (s *Symbol) BindType(typ typeinfo.Type) bool { if s == nil || typ == nil { return false } @@ -97,7 +87,7 @@ func (s *Symbol) BindType(typ Type) bool { // SymbolType returns the semantic type stored on sym, or (nil, false) if sym // carries no type. // This is the canonical single-source-of-truth lookup shared across all passes. -func GetSymbolType(sym *Symbol) (Type, bool) { +func GetSymbolType(sym *Symbol) (typeinfo.Type, bool) { if sym == nil || sym.Type == nil { return nil, false } diff --git a/internal/semantics/typechecker/assignability.go b/internal/semantics/typechecker/assignability.go index 8221c662..17cb6309 100644 --- a/internal/semantics/typechecker/assignability.go +++ b/internal/semantics/typechecker/assignability.go @@ -9,6 +9,7 @@ import ( "compiler/internal/project" "compiler/internal/semantics/place" "compiler/internal/semantics/symbols" + "compiler/internal/semantics/typecheckresult" "compiler/internal/semantics/typeinfo" ) @@ -47,15 +48,15 @@ func (c *checker) assignable(dst, src typeinfo.Type, site ast.Expr) bool { } func (c *checker) recordImplicitConversion(expr ast.Expr, conversion typeinfo.Conversion) { - if c == nil || c.module == nil || c.module.Semantics == nil || expr == nil || + if c == nil || c.module == nil || c.module.Typechecking == nil || expr == nil || conversion.Kind == typeinfo.ConversionNone || conversion.Kind == typeinfo.ConversionRecovery || conversion.Kind == typeinfo.ConversionIdentity || conversion.Compatibility != typeinfo.Compatible { return } - c.module.Semantics.ImplicitConversions[expr.ID()] = conversion + c.module.Typechecking.ImplicitConversions[expr.ID()] = conversion } -func (c *checker) resolveInterfaceImplementations(iface *typeinfo.InterfaceType, src typeinfo.Type) ([]project.InterfaceImplementation, []string, bool) { +func (c *checker) resolveInterfaceImplementations(iface *typeinfo.InterfaceType, src typeinfo.Type) ([]typecheckresult.InterfaceImplementation, []string, bool) { if c == nil || iface == nil || src == nil { return nil, nil, false } @@ -67,7 +68,7 @@ func (c *checker) resolveInterfaceImplementations(iface *typeinfo.InterfaceType, } return nil, missing, false } - implementations := make([]project.InterfaceImplementation, 0, len(iface.Methods)) + implementations := make([]typecheckresult.InterfaceImplementation, 0, len(iface.Methods)) missing := make([]string, 0) for _, required := range iface.Methods { requiredType := typeinfo.ReplaceAbstractSelf(required.CallableType(), owner) @@ -92,19 +93,18 @@ func (c *checker) resolveInterfaceImplementations(iface *typeinfo.InterfaceType, missing = append(missing, required.Name) continue } - implementations = append(implementations, project.InterfaceImplementation{ - MethodName: required.Name, Symbol: actual.Symbol, - CallableType: actualType, OwnerKey: actual.OwnerKey, + implementations = append(implementations, typecheckresult.InterfaceImplementation{ + Symbol: actual.Symbol, CallableType: actualType, }) } return implementations, missing, len(missing) == 0 } -func (c *checker) storeInterfaceImplementations(expr ast.Expr, implementations []project.InterfaceImplementation) { - if c == nil || c.module == nil || c.module.Semantics == nil || expr == nil { +func (c *checker) storeInterfaceImplementations(expr ast.Expr, implementations []typecheckresult.InterfaceImplementation) { + if c == nil || c.module == nil || c.module.Typechecking == nil || expr == nil { return } - c.module.Semantics.InterfaceImplementations[expr.ID()] = implementations + c.module.Typechecking.InterfaceImplementations[expr.ID()] = implementations } func (c *checker) addInterfaceHint(d *diagnostics.Diagnostic, dst, src typeinfo.Type) { @@ -154,13 +154,12 @@ func (c *checker) matchesReceiverTarget(target, arg typeinfo.Type) bool { } type callableMember struct { - Type typeinfo.Type - Symbol *symbols.Symbol - OwnerKey string + Type typeinfo.Type + Symbol *symbols.Symbol } func (c *checker) lookupCallableMember(baseType typeinfo.Type, name string) (callableMember, bool) { - if c == nil || c.module == nil || c.module.Semantics == nil { + if c == nil { return callableMember{}, false } if iface, ok := typeinfo.InterfaceTypeOf(baseType); ok { @@ -175,18 +174,18 @@ func (c *checker) lookupCallableMember(baseType typeinfo.Type, name string) (cal } func (c *checker) lookupDeclaredCallableMember(baseType typeinfo.Type, name string) (callableMember, bool) { - if c == nil || c.module == nil || c.module.Semantics == nil { + if c == nil || c.module == nil || c.module.Bindings == nil { return callableMember{}, false } for _, key := range typeinfo.GetMethodLookupKeys(baseType) { - methods := c.module.Semantics.MethodSets[key] + methods := c.module.Bindings.MethodsByReceiver[key] for _, method := range methods { if method == nil || method.Name != name { continue } typ, ok := symbols.GetSymbolType(method) if ok && typ != nil { - return callableMember{Type: typ, Symbol: method, OwnerKey: key}, true + return callableMember{Type: typ, Symbol: method}, true } } } @@ -195,7 +194,7 @@ func (c *checker) lookupDeclaredCallableMember(baseType typeinfo.Type, name stri // availableMethods returns the names of all methods defined on baseType. func (c *checker) availableMethods(baseType typeinfo.Type) []string { - if c == nil || c.module == nil || c.module.Semantics == nil { + if c == nil { return nil } var names []string @@ -204,10 +203,12 @@ func (c *checker) availableMethods(baseType typeinfo.Type) []string { names = append(names, m.Name) } } - for _, key := range typeinfo.GetMethodLookupKeys(baseType) { - for _, method := range c.module.Semantics.MethodSets[key] { - if method != nil { - names = append(names, method.Name) + if c.module != nil && c.module.Bindings != nil { + for _, key := range typeinfo.GetMethodLookupKeys(baseType) { + for _, method := range c.module.Bindings.MethodsByReceiver[key] { + if method != nil { + names = append(names, method.Name) + } } } } @@ -250,7 +251,7 @@ func (c *checker) mutableAddressableExpr(scope *symbols.Scope, expr ast.Expr) (b } return place.MutableAddressable(scope, expr, func(e ast.Expr) typeinfo.Type { return c.typeExpr(scope, e, nil) - }, c.expandedDefaultBinding) + }, c.module.ExpandedDefaultBinding) } func (c *checker) mutableImplicitArgumentDiagnostic(scope *symbols.Scope, expr ast.Expr) (ast.Node, string, bool) { @@ -293,8 +294,8 @@ func (c *checker) qualifiedScopeType(scope *symbols.Scope, node *ast.ScopeResolu return &typeinfo.InvalidType{} } var sym *symbols.Symbol - if c.module != nil && c.module.Semantics != nil { - sym = c.module.Semantics.ResolvedSymbols[node.ID()] + if c.module != nil && c.module.Bindings != nil { + sym = c.module.Bindings.NodeSymbols[node.ID()] } if sym == nil { qualifier, member, imported := node.ImportValueMember() diff --git a/internal/semantics/typechecker/check_call.go b/internal/semantics/typechecker/check_call.go index 39b537bd..ee574f8f 100644 --- a/internal/semantics/typechecker/check_call.go +++ b/internal/semantics/typechecker/check_call.go @@ -10,6 +10,7 @@ import ( "compiler/internal/semantics/intrinsics" "compiler/internal/semantics/place" "compiler/internal/semantics/symbols" + "compiler/internal/semantics/typecheckresult" "compiler/internal/semantics/typeinfo" ) @@ -55,8 +56,13 @@ func (c *checker) typePrintExpr(scope *symbols.Scope, node *ast.PrintExpr) typei } func (c *checker) typeCallExpr(scope *symbols.Scope, node *ast.CallExpr) typeinfo.Type { - if path, ok := node.Callee.(*ast.ScopeResolution); ok && path != nil { - if sym := c.module.Semantics.ResolvedSymbols[path.ID()]; sym != nil && sym.Kind == symbols.SymbolVariant { + effectiveArgs := c.module.Typechecking.CallArgumentsOrSource(node) + if c.flow == nil { + effectiveArgs = append([]ast.Expr(nil), node.Args...) + c.module.Typechecking.EffectiveCallArguments[node.ID()] = effectiveArgs + } + if path, ok := node.Callee.(*ast.ScopeResolution); ok && path != nil && c.module.Bindings != nil { + if sym := c.module.Bindings.NodeSymbols[path.ID()]; sym != nil && sym.Kind == symbols.SymbolVariant { for _, arg := range node.Args { c.typeExpr(scope, arg, nil) } @@ -68,13 +74,13 @@ func (c *checker) typeCallExpr(scope *symbols.Scope, node *ast.CallExpr) typeinf if selector, ok := node.Callee.(*ast.SelectorExpr); ok && selector != nil { return c.typeSelectorCall(scope, selector, node) } - if ident, ok := node.Callee.(*ast.Ident); ok && ident != nil { - if sym := c.module.Semantics.ResolvedSymbols[ident.ID()]; sym != nil && sym.CompilerOp != "" { + if ident, ok := node.Callee.(*ast.Ident); ok && ident != nil && c.module.Bindings != nil { + if sym := c.module.Bindings.NodeSymbols[ident.ID()]; sym != nil && sym.CompilerOp != "" { definition, found := intrinsics.LookupFunction(sym.CompilerOp) if !found { panic(fmt.Sprintf("missing intrinsic definition for compiler operation %q", sym.CompilerOp)) } - c.module.Semantics.CompilerCalls[node.ID()] = project.CompilerCall{Operation: definition.Operation, Kind: definition.Kind} + c.module.Typechecking.CompilerCalls[node.ID()] = typecheckresult.CompilerCall{Operation: definition.Operation, Kind: definition.Kind} switch definition.Kind { case intrinsics.FunctionAlloc: return c.typeAllocCall(scope, node) @@ -90,19 +96,20 @@ func (c *checker) typeCallExpr(scope *symbols.Scope, node *ast.CallExpr) typeinf } } calleeType := c.typePayloadExpr(scope, node.Callee, nil) - if sym := c.callableSymbol(node.Callee); sym != nil { - c.expandCallDefaults(node, sym, c.callableModule(node.Callee)) + if sym := c.callableSymbol(node.Callee); sym != nil && c.flow == nil { + effectiveArgs = c.expandCallDefaults(node, effectiveArgs, sym, c.callableModule(node.Callee)) + c.module.Typechecking.EffectiveCallArguments[node.ID()] = effectiveArgs } - argTypes := make([]typeinfo.Type, 0, len(node.Args)) + argTypes := make([]typeinfo.Type, 0, len(effectiveArgs)) fnType, _ := calleeType.(*typeinfo.FuncType) - for i, arg := range node.Args { + for i, arg := range effectiveArgs { var paramExpected typeinfo.Type if fnType != nil && i < len(fnType.Params) { paramExpected = fnType.Params[i] } argTypes = append(argTypes, c.typeExpr(scope, arg, paramExpected)) } - c.checkCall(scope, nil, node, calleeType, argTypes) + c.checkCall(scope, nil, node, calleeType, effectiveArgs, argTypes) return c.callReturnType(node, calleeType) } @@ -115,7 +122,12 @@ func (c *checker) typeFromBytesCall(scope *symbols.Scope, node *ast.CallExpr, de if fnType == nil { panic("missing from_bytes signature") } - c.module.Semantics.ExprTypes[node.Callee.ID()] = fnType + c.module.Typechecking.ExprTypes[node.Callee.ID()] = fnType + for i, arg := range node.Args { + if i < len(fnType.Params) { + c.publishValueUse(arg, fnType.Params[i]) + } + } bytesType := c.typeExpr(scope, node.Args[0], fnType.Params[0]) if !typeinfo.IsInvalidOrUnknown(bytesType) && !typeinfo.SameType(bytesType, fnType.Params[0]) { c.ctx.Diagnostics.Add(invalidTypeError(node.Args[0], @@ -153,8 +165,8 @@ func (c *checker) typeCollectionCall(scope *symbols.Scope, node *ast.CallExpr, d fmt.Sprintf("`%s` does not support %s", definition.Operation, typeinfo.TypeText(baseType)))) return &typeinfo.InvalidType{} } - c.module.Semantics.ExprTypes[node.Callee.ID()] = fnType - c.checkCall(scope, nil, node, fnType, []typeinfo.Type{baseType}) + c.module.Typechecking.ExprTypes[node.Callee.ID()] = fnType + c.checkCall(scope, nil, node, fnType, node.Args, []typeinfo.Type{baseType}) return c.callReturnType(node, fnType) } @@ -198,14 +210,14 @@ func (c *checker) typeDynamicArrayOwnerCall(scope *symbols.Scope, node *ast.Call panic(fmt.Sprintf("missing dynamic-array signature for %q", op)) } - c.module.Semantics.ExprTypes[node.Callee.ID()] = fnType + c.module.Typechecking.ExprTypes[node.Callee.ID()] = fnType argTypes := make([]typeinfo.Type, 0, len(node.Args)) argTypes = append(argTypes, firstArgType) for i, arg := range node.Args[1:] { argTypes = append(argTypes, c.typeExpr(scope, arg, fnType.Params[i+1])) } - c.checkCall(scope, nil, node, fnType, argTypes) - if op == symbols.CompilerOpResize && !typeinfo.IsImplicitCopyType(array.Elem) { + c.checkCall(scope, nil, node, fnType, node.Args, argTypes) + if op == symbols.CompilerOpResize && typeinfo.OwnershipCapabilityOf(array.Elem).Copy != typeinfo.CopyImplicit { c.ctx.Diagnostics.AddError(diagnostics.ErrInvalidCopy, "resize requires implicitly copyable elements; grow Category B arrays with append", ast.LocOf(node), "") @@ -218,6 +230,16 @@ func (c *checker) typeAllocCall(scope *symbols.Scope, node *ast.CallExpr) typein return &typeinfo.InvalidType{} } + // The intrinsic has no parameter types to classify against, so the use kinds + // are published from its own semantics, before any type-dependent exit: the + // analyzer consumes them on diagnostics-continued paths too. + if c.module != nil && c.module.Typechecking != nil { + c.module.Typechecking.ValueUses[node.Args[0].ID()] = typeinfo.UseMove + if len(node.Args) > 1 { + c.module.Typechecking.ValueUses[node.Args[1].ID()] = typeinfo.UseRead + } + } + valueType := c.typeExpr(scope, node.Args[0], nil) if valueType == nil { return &typeinfo.InvalidType{} @@ -248,6 +270,28 @@ func (c *checker) typeAllocCall(scope *symbols.Scope, node *ast.CallExpr) typein return &typeinfo.OwnedPtrType{Target: valueType} } +// publishValueUse records the ownership use kind a parameter position imposes +// on its argument, keyed by the argument expression the ownership analyzer +// walks. Reference parameters publish UseRead: the kind is recorded for +// completeness, while the borrow machinery in ownership still governs them. +func (c *checker) publishValueUse(arg ast.Expr, paramType typeinfo.Type) { + if c == nil || arg == nil || paramType == nil || c.module == nil || c.module.Typechecking == nil { + return + } + use := typeinfo.UseMove + _, mutable, reference := typeinfo.ReferenceValueTarget(paramType) + if reference || typeinfo.OwnershipCapabilityOf(paramType).Copy == typeinfo.CopyImplicit { + use = typeinfo.UseRead + } + c.module.Typechecking.ValueUses[arg.ID()] = use + // A reference parameter borrows its argument. The use kind cannot carry + // that: an implicit-copy argument publishes UseRead too, so a consumer could + // not tell a borrow from a plain read. + if reference { + c.module.Typechecking.ReferenceArguments[arg.ID()] = mutable + } +} + func (c *checker) checkOptionalAllocatorArity(scope *symbols.Scope, node *ast.CallExpr) bool { const minArgs, maxArgs = 1, 2 argCount := len(node.Args) @@ -277,26 +321,30 @@ func (c *checker) typeSelectorCall(scope *symbols.Scope, selector *ast.SelectorE method, ok := c.lookupCallableMember(baseType, selector.Name.Name) if ok { methodType, methodSym := method.Type, method.Symbol - if methodSym != nil && methodSym.CompilerOp == "" { - c.expandCallDefaults(call, methodSym, c.module) - } - if c.module != nil && c.module.Semantics != nil { - c.module.Semantics.ExprTypes[selector.ID()] = methodType - if methodSym != nil { - c.module.Semantics.ResolvedSymbols[selector.Name.ID()] = methodSym + effectiveArgs := c.module.Typechecking.EffectiveCallArguments[call.ID()] + if methodSym != nil && methodSym.CompilerOp == "" && c.flow == nil { + effectiveArgs = c.expandCallDefaults(call, effectiveArgs, methodSym, c.module) + c.module.Typechecking.EffectiveCallArguments[call.ID()] = effectiveArgs + } + if c.module != nil { + if c.module.Typechecking != nil { + c.module.Typechecking.ExprTypes[selector.ID()] = methodType + } + if methodSym != nil && c.module.Bindings != nil { + c.module.Bindings.NodeSymbols[selector.Name.ID()] = methodSym } } - argTypes := make([]typeinfo.Type, 0, len(call.Args)+1) + argTypes := make([]typeinfo.Type, 0, len(effectiveArgs)+1) argTypes = append(argTypes, baseType) fnType, _ := methodType.(*typeinfo.FuncType) - for i, arg := range call.Args { + for i, arg := range effectiveArgs { var paramExpected typeinfo.Type if fnType != nil && i+1 < len(fnType.Params) { paramExpected = fnType.Params[i+1] } argTypes = append(argTypes, c.typeExpr(scope, arg, paramExpected)) } - c.checkCall(scope, selector.Expr, call, methodType, argTypes) + c.checkCall(scope, selector.Expr, call, methodType, effectiveArgs, argTypes) return c.callReturnType(call, methodType) } if field, _, fieldOK := typeinfo.LookupStructField(baseType, selector.Name.Name); fieldOK { @@ -318,7 +366,7 @@ func (c *checker) typeSelectorCall(scope *symbols.Scope, selector *ast.SelectorE return &typeinfo.InvalidType{} } -func (c *checker) checkCall(scope *symbols.Scope, receiverExpr ast.Expr, callExpr *ast.CallExpr, calleeType typeinfo.Type, args []typeinfo.Type) { +func (c *checker) checkCall(scope *symbols.Scope, receiverExpr ast.Expr, callExpr *ast.CallExpr, calleeType typeinfo.Type, argExprs []ast.Expr, args []typeinfo.Type) { if c == nil || callExpr == nil || calleeType == nil { return } @@ -354,17 +402,26 @@ func (c *checker) checkCall(scope *symbols.Scope, receiverExpr ast.Expr, callExp return } for i, argType := range args { + argIndex := i - callArgOffset + var argExpr ast.Expr + if argIndex >= 0 && argIndex < len(argExprs) { + argExpr = argExprs[argIndex] + } var implicitExpr ast.Expr if i == 0 { if receiverExpr != nil { implicitExpr = receiverExpr - } else if callExpr.Piped && len(callExpr.Args) > 0 { - implicitExpr = callExpr.Args[0] + } else if callExpr.Piped && len(argExprs) > 0 { + implicitExpr = argExprs[0] } } if argType == nil { if i >= callArgOffset { - c.ctx.Diagnostics.Add(invalidExpressionError(callExpr.Args[i-callArgOffset], + site := ast.Node(callExpr) + if argExpr != nil { + site = argExpr + } + c.ctx.Diagnostics.Add(invalidExpressionError(site, "argument requires a value-producing expression")) } continue @@ -373,8 +430,12 @@ func (c *checker) checkCall(scope *symbols.Scope, receiverExpr ast.Expr, callExp if paramType == nil { continue } + c.publishValueUse(implicitExpr, paramType) + if argIndex >= 0 { + c.publishValueUse(argExpr, paramType) + } if implicitExpr != nil && c.acceptImplicitCallArgument(scope, implicitExpr, argType, paramType) { - c.module.Semantics.ImplicitCallArguments[implicitExpr.ID()] = paramType + c.module.Typechecking.ImplicitCallArguments[implicitExpr.ID()] = paramType continue } site := ast.Node(callExpr) @@ -382,9 +443,9 @@ func (c *checker) checkCall(scope *symbols.Scope, receiverExpr ast.Expr, callExp if implicitExpr != nil { conversion = implicitExpr } - if i >= callArgOffset && i-callArgOffset < len(callExpr.Args) { - conversion = callExpr.Args[i-callArgOffset] - site = conversion + if argExpr != nil { + conversion = argExpr + site = argExpr } if !c.assignable(paramType, argType, conversion) { d := typeMismatchError(site, @@ -406,7 +467,7 @@ func (c *checker) acceptImplicitCallArgument(scope *symbols.Scope, expr ast.Expr } addressable := place.Addressable(scope, expr, func(e ast.Expr) typeinfo.Type { return c.typeExpr(scope, e, nil) - }, c.expandedDefaultBinding) + }, c.module.ExpandedDefaultBinding) var mutableBinding *symbols.Symbol if mutable { addressable, _, mutableBinding = c.mutableAddressableExpr(scope, expr) @@ -445,8 +506,8 @@ func (c *checker) callableSymbol(callee ast.Expr) *symbols.Symbol { } switch node := callee.(type) { case *ast.Ident: - if c.module.Semantics != nil { - return c.module.Semantics.ResolvedSymbols[node.ID()] + if c.module.Bindings != nil { + return c.module.Bindings.NodeSymbols[node.ID()] } case *ast.ScopeResolution: qualifier, member, imported := node.ImportValueMember() @@ -473,13 +534,14 @@ func (c *checker) callableModule(callee ast.Expr) *project.Module { return c.module } -func (c *checker) expandCallDefaults(call *ast.CallExpr, sym *symbols.Symbol, declModule *project.Module) { - if c == nil || c.module == nil || c.module.Semantics == nil || call == nil || sym == nil { - return +func (c *checker) expandCallDefaults(call *ast.CallExpr, args []ast.Expr, sym *symbols.Symbol, declModule *project.Module) []ast.Expr { + effectiveArgs := append([]ast.Expr(nil), args...) + if c == nil || c.module == nil || c.module.Bindings == nil || c.module.Typechecking == nil || call == nil || sym == nil { + return effectiveArgs } fn, ok := sym.ASTNode.(*ast.FnDecl) if !ok || fn == nil { - return + return effectiveArgs } params := fn.ParamsWithReceiver() offset := 0 @@ -495,16 +557,21 @@ func (c *checker) expandCallDefaults(call *ast.CallExpr, sym *symbols.Symbol, de break } } - provided := len(call.Args) + offset + provided := len(effectiveArgs) + offset if firstDefault < 0 || provided < firstDefault || provided >= len(params) { - return + return effectiveArgs + } + for i := provided; i < len(params); i++ { + if params[i].Default == nil { + return effectiveArgs + } } substitutions := make(map[string]ast.Expr, len(params)) slotExprs := make([]ast.Expr, len(params)) if receiver != nil && len(slotExprs) > 0 { slotExprs[0] = receiver } - for i, arg := range call.Args { + for i, arg := range effectiveArgs { slot := i + offset if slot >= len(slotExprs) { break @@ -518,9 +585,6 @@ func (c *checker) expandCallDefaults(call *ast.CallExpr, sym *symbols.Symbol, de substitutions[params[i].Name.Name] = slotExprs[i] } for i := provided; i < len(params); i++ { - if params[i].Default == nil { - return - } ast.Inspect(params[i].Default, func(node ast.Node) bool { ident, ok := node.(*ast.Ident) if !ok || ident == nil { @@ -533,44 +597,48 @@ func (c *checker) expandCallDefaults(call *ast.CallExpr, sym *symbols.Symbol, de return true }) expanded, defaultClones, argumentClones := ast.SubstituteExpr(params[i].Default, substitutions) - if declModule != nil && declModule.Semantics != nil { + if declModule != nil && declModule.Bindings != nil { for clonedID, originalID := range defaultClones { - if resolved := declModule.Semantics.ResolvedSymbols[originalID]; resolved != nil { - c.module.Semantics.ResolvedSymbols[clonedID] = resolved - c.module.Semantics.ExpandedDefaultBindings[clonedID] = struct{}{} + if resolved := declModule.Bindings.NodeSymbols[originalID]; resolved != nil { + c.module.Bindings.NodeSymbols[clonedID] = resolved + c.module.Typechecking.ExpandedDefaultBindings[clonedID] = struct{}{} } - copyExpressionEvidence(c.module.Semantics, declModule.Semantics, clonedID, originalID) + copyExpressionEvidence(c.module, declModule, clonedID, originalID) } } for clonedID, originalID := range argumentClones { - if resolved := c.module.Semantics.ResolvedSymbols[originalID]; resolved != nil { - c.module.Semantics.ResolvedSymbols[clonedID] = resolved + if resolved := c.module.Bindings.NodeSymbols[originalID]; resolved != nil { + c.module.Bindings.NodeSymbols[clonedID] = resolved } - if _, ok := c.module.Semantics.ExpandedDefaultBindings[originalID]; ok { - c.module.Semantics.ExpandedDefaultBindings[clonedID] = struct{}{} + if _, ok := c.module.Typechecking.ExpandedDefaultBindings[originalID]; ok { + c.module.Typechecking.ExpandedDefaultBindings[clonedID] = struct{}{} } - copyExpressionEvidence(c.module.Semantics, c.module.Semantics, clonedID, originalID) + copyExpressionEvidence(c.module, c.module, clonedID, originalID) } - call.Args = append(call.Args, expanded) + effectiveArgs = append(effectiveArgs, expanded) slotExprs[i] = expanded if params[i].Name != nil { substitutions[params[i].Name.Name] = expanded } } + return effectiveArgs } -func copyExpressionEvidence(dst, src *project.SemanticInfo, dstID, srcID ast.NodeID) { - if dst == nil || src == nil { +func copyExpressionEvidence(dst, src *project.Module, dstID, srcID ast.NodeID) { + if dst == nil || dst.Typechecking == nil || src == nil { return } - if typ := src.ExprTypes[srcID]; typ != nil { - dst.ExprTypes[dstID] = typ + if typ := src.BaseExprType(srcID); typ != nil { + dst.Typechecking.ExprTypes[dstID] = typ + } + if src.Typechecking == nil { + return } - if implementations := src.InterfaceImplementations[srcID]; implementations != nil { - dst.InterfaceImplementations[dstID] = implementations + if implementations := src.Typechecking.InterfaceImplementations[srcID]; implementations != nil { + dst.Typechecking.InterfaceImplementations[dstID] = implementations } - if conversion, ok := src.ImplicitConversions[srcID]; ok { - dst.ImplicitConversions[dstID] = conversion + if conversion, ok := src.Typechecking.ImplicitConversions[srcID]; ok { + dst.Typechecking.ImplicitConversions[dstID] = conversion } } diff --git a/internal/semantics/typechecker/check_expr.go b/internal/semantics/typechecker/check_expr.go index 31e5f03c..b57b4327 100644 --- a/internal/semantics/typechecker/check_expr.go +++ b/internal/semantics/typechecker/check_expr.go @@ -16,6 +16,7 @@ import ( "compiler/internal/semantics/flowresult" "compiler/internal/semantics/place" "compiler/internal/semantics/symbols" + "compiler/internal/semantics/typecheckresult" "compiler/internal/semantics/typeinfo" "compiler/pkg/numeric" ) @@ -37,8 +38,8 @@ func (c *checker) typeExpr(scope *symbols.Scope, expr ast.Expr, expected typeinf if base == nil || expr == nil { return base } - if c.module != nil && c.module.Semantics != nil && c.flow == nil { - c.module.Semantics.ExprTypes[expr.ID()] = base + if c.module != nil && c.module.Typechecking != nil && c.flow == nil { + c.module.Typechecking.ExprTypes[expr.ID()] = base } resolved := c.effectiveExpressionType(scope, expr, base, expected) if c.flow != nil && resolved != nil { @@ -84,8 +85,8 @@ func (c *checker) typeExprBase(scope *symbols.Scope, expr ast.Expr, expected typ case *ast.Ident: var sym *symbols.Symbol var ok bool - if c.module != nil && c.module.Semantics != nil { - sym = c.module.Semantics.ResolvedSymbols[node.ID()] + if c.module != nil && c.module.Bindings != nil { + sym = c.module.Bindings.NodeSymbols[node.ID()] ok = sym != nil } if !ok { @@ -228,9 +229,9 @@ func (c *checker) typeAddressExpr(scope *symbols.Scope, node *ast.AddressExpr, e exprType := func(expr ast.Expr) typeinfo.Type { return c.module.EffectiveExprType(expr.ID()) } - addressable := place.Addressable(scope, node.Expr, exprType, c.expandedDefaultBinding) + addressable := place.Addressable(scope, node.Expr, exprType, c.module.ExpandedDefaultBinding) if node.Mode == ast.AddressMutable { - mutable, sharedReference, mutableBinding := place.MutableAddressable(scope, node.Expr, exprType, c.expandedDefaultBinding) + mutable, sharedReference, mutableBinding := place.MutableAddressable(scope, node.Expr, exprType, c.module.ExpandedDefaultBinding) if addressable && !mutable { diagnostic := c.ctx.Diagnostics.AddError(diagnostics.ErrInvalidExpression, "mutable reference requires mutable addressable storage", ast.LocOf(node.Expr), "") @@ -329,7 +330,7 @@ func (c *checker) typeBinaryExpr(scope *symbols.Scope, node *ast.BinaryExpr, exp if leftString || rightString || leftView || rightView { wantRight := &typeinfo.RefType{Target: &typeinfo.StringType{}} if leftString && typeinfo.SameType(right, wantRight) { - c.module.Semantics.StringConcatenations[node.ID()] = struct{}{} + c.module.Typechecking.StringConcatenations[node.ID()] = struct{}{} return &typeinfo.StringType{} } c.ctx.Diagnostics.Add(invalidOperationError(node, @@ -338,11 +339,11 @@ func (c *checker) typeBinaryExpr(scope *symbols.Scope, node *ast.BinaryExpr, exp } } leftBase, rightBase := left, right - if c.module != nil && c.module.Semantics != nil { - if typ := c.module.Semantics.ExprTypes[node.Left.ID()]; typ != nil { + if c.module != nil { + if typ := c.module.BaseExprType(node.Left.ID()); typ != nil { leftBase = typ } - if typ := c.module.Semantics.ExprTypes[node.Right.ID()]; typ != nil { + if typ := c.module.BaseExprType(node.Right.ID()); typ != nil { rightBase = typ } } @@ -456,7 +457,7 @@ func (c *checker) typeIsExpr(scope *symbols.Scope, node *ast.IsExpr) typeinfo.Ty return &typeinfo.InvalidType{} } if c.flow != nil { - test, found := c.module.Semantics.CaseTests[node.ID()] + test, found := c.module.Typechecking.CaseTests[node.ID()] if !found { return &typeinfo.InvalidType{} } @@ -488,15 +489,15 @@ type resolvedNamedVariant struct { // identity. Expanded defaults retain declaration-module symbols even when their // cloned syntax is typechecked inside a caller module. func (c *checker) resolveNamedVariant(path *ast.ScopeResolution) (resolvedNamedVariant, bool) { - if c == nil || c.module == nil || c.module.Semantics == nil || path == nil { + if c == nil || c.module == nil || c.module.Bindings == nil || path == nil { return resolvedNamedVariant{}, false } typePath, caseName, ok := path.EnumVariantMember() - caseSymbol := c.module.Semantics.ResolvedSymbols[path.ID()] + caseSymbol := c.module.Bindings.NodeSymbols[path.ID()] if !ok || caseName == nil || caseSymbol == nil || caseSymbol.Kind != symbols.SymbolVariant || caseSymbol.Name != caseName.Name { return resolvedNamedVariant{}, false } - qualifierSymbol := c.module.Semantics.ResolvedSymbols[typePath.ID()] + qualifierSymbol := c.module.Bindings.NodeSymbols[typePath.ID()] if qualifierSymbol == nil || qualifierSymbol.Kind != symbols.SymbolType { return resolvedNamedVariant{}, false } @@ -595,8 +596,8 @@ func (c *checker) typeSelectorExpr(scope *symbols.Scope, node *ast.SelectorExpr) return field.Type } if method, ok := c.lookupCallableMember(baseType, node.Name.Name); ok { - if method.Symbol != nil { - c.module.Semantics.ResolvedSymbols[node.Name.ID()] = method.Symbol + if method.Symbol != nil && c.module.Bindings != nil { + c.module.Bindings.NodeSymbols[node.Name.ID()] = method.Symbol } return method.Type } @@ -723,7 +724,7 @@ func (c *checker) typeRangeIndexExpr(scope *symbols.Scope, node *ast.IndexExpr, return c.typeExpr(scope, expr, nil) } if shape == indexableFixedArray || shape == indexableDynamicArray { - if !place.Addressable(scope, node.Expr, exprType, c.expandedDefaultBinding) { + if !place.Addressable(scope, node.Expr, exprType, c.module.ExpandedDefaultBinding) { c.ctx.Diagnostics.Add(invalidExpressionError(node.Expr, "slicing requires addressable array storage")) return &typeinfo.InvalidType{} @@ -732,7 +733,7 @@ func (c *checker) typeRangeIndexExpr(scope *symbols.Scope, node *ast.IndexExpr, mutable := shape == indexableMutableSliceView var mutableBinding *symbols.Symbol if shape == indexableFixedArray || shape == indexableDynamicArray { - mutable, _, mutableBinding = place.MutableAddressable(scope, node.Expr, exprType, c.expandedDefaultBinding) + mutable, _, mutableBinding = place.MutableAddressable(scope, node.Expr, exprType, c.module.ExpandedDefaultBinding) } if mutableBinding != nil { mutableBinding.RequiresMutable = true @@ -909,7 +910,7 @@ func (c *checker) typeVariantConstruction(scope *symbols.Scope, site ast.Expr, p "payloadless enum variant `"+resolved.CaseName.Name+"` does not accept a payload", ast.LocOf(site), "remove `with` and its value") return &typeinfo.InvalidType{} } - c.module.Semantics.VariantConstructions[site.ID()] = project.VariantConstruction{EnumType: resolved.EnumType, Case: resolved.CaseIndex} + c.module.Typechecking.VariantConstructions[site.ID()] = typecheckresult.VariantConstruction{EnumType: resolved.EnumType, Case: resolved.CaseIndex} return resolved.EnumType } if !initialized { @@ -932,7 +933,7 @@ func (c *checker) typeVariantConstruction(scope *symbols.Scope, site ast.Expr, p fmt.Sprintf("cannot assign %s to enum variant payload of type %s", typeinfo.TypeText(valueType), typeinfo.TypeText(resolved.Case.Payload)), ast.LocOf(value), "") return &typeinfo.InvalidType{} } - c.module.Semantics.VariantConstructions[site.ID()] = project.VariantConstruction{ + c.module.Typechecking.VariantConstructions[site.ID()] = typecheckresult.VariantConstruction{ EnumType: resolved.EnumType, Case: resolved.CaseIndex, Payload: resolved.Case.Payload, diff --git a/internal/semantics/typechecker/check_fn.go b/internal/semantics/typechecker/check_fn.go index 3b1f7d6a..ac2499bc 100644 --- a/internal/semantics/typechecker/check_fn.go +++ b/internal/semantics/typechecker/check_fn.go @@ -77,7 +77,7 @@ func (c *checker) checkDefaultParameters(scope *symbols.Scope, fn *ast.FnDecl) { } func (c *checker) rejectOwnedParameterReferences(scope *symbols.Scope, fn *ast.FnDecl, current int, expr ast.Expr) { - if c == nil || c.module == nil || c.module.Semantics == nil || fn == nil || expr == nil { + if c == nil || c.module == nil || c.module.Bindings == nil || fn == nil || expr == nil { return } params := fn.ParamsWithReceiver() @@ -95,13 +95,13 @@ func (c *checker) rejectOwnedParameterReferences(scope *symbols.Scope, fn *ast.F if !ok || ident == nil { return true } - sym := c.module.Semantics.ResolvedSymbols[ident.ID()] + sym := c.module.Bindings.NodeSymbols[ident.ID()] index, isParam := paramIndexes[sym] if !isParam || index >= current || index < 0 || index >= len(params) { return true } paramType := typeinfo.TypeFromSyntax(params[index].Type, project.TypeSyntaxOptions(c.ctx, c.module, nil, false)) - if typeinfo.IsImplicitCopyType(paramType) { + if typeinfo.OwnershipCapabilityOf(paramType).Copy == typeinfo.CopyImplicit { return true } if _, _, reference := typeinfo.ReferenceValueTarget(paramType); reference { @@ -429,7 +429,10 @@ func (c *checker) checkEnumDecl(decl *ast.EnumDecl) { if decl.Name == nil { return } - for _, method := range c.module.Semantics.MethodSets[decl.Name.Name] { + if c.module == nil || c.module.Bindings == nil { + return + } + for _, method := range c.module.Bindings.MethodsByReceiver[decl.Name.Name] { if method == nil || dataFields[method.Name] == nil { continue } diff --git a/internal/semantics/typechecker/check_stmt.go b/internal/semantics/typechecker/check_stmt.go index e593b261..9fcb477d 100644 --- a/internal/semantics/typechecker/check_stmt.go +++ b/internal/semantics/typechecker/check_stmt.go @@ -8,9 +8,9 @@ import ( "compiler/internal/frontend/ast" "compiler/internal/project" "compiler/internal/semantics/consteval" - "compiler/internal/semantics/flowresult" "compiler/internal/semantics/place" "compiler/internal/semantics/symbols" + "compiler/internal/semantics/typecheckresult" "compiler/internal/semantics/typeinfo" ) @@ -19,8 +19,8 @@ func (c *checker) checkBlock(parentScope *symbols.Scope, block *ast.BlockStmt, r return } scope := parentScope - if c.module.Semantics != nil { - if s, ok := c.module.Semantics.BlockScopes[block.ID()]; ok && s != nil { + if c.module.Bindings != nil { + if s, ok := c.module.Bindings.BlockScopes[block.ID()]; ok && s != nil { scope = s } } @@ -145,16 +145,16 @@ func (c *checker) checkMatchStmt(scope *symbols.Scope, node *ast.MatchStmt, retu return } evidenceComplete := true - if typeinfo.NeedsDrop(subjectType) && !place.IsPlaceExpr(node.Subject) { + if typeinfo.OwnershipCapabilityOf(subjectType).Drop && !place.IsPlaceExpr(node.Subject) { c.ctx.Diagnostics.Add(invalidOperationError(node.Subject, "ownership-bearing match subject must be a named place"). WithHelp("bind subject to a local before matching it")) } - evidence := flowresult.Match{ + evidence := typecheckresult.Match{ SubjectID: node.Subject.ID(), EnumType: subjectType, - Cases: append([]typeinfo.VariantCase(nil), descriptor.Cases...), - Arms: make([]flowresult.MatchArm, 0, len(node.Arms)), + CaseCount: len(descriptor.Cases), + Arms: make([]typecheckresult.MatchArm, 0, len(node.Arms)), } seenCases := make(map[int]ast.Node, len(node.Arms)) for _, arm := range node.Arms { @@ -162,7 +162,7 @@ func (c *checker) checkMatchStmt(scope *symbols.Scope, node *ast.MatchStmt, retu evidenceComplete = false continue } - armEvidence := flowresult.MatchArm{ArmID: arm.ID(), Case: -1} + armEvidence := typecheckresult.MatchArm{ArmID: arm.ID(), Case: -1} if arm.Body != nil { armEvidence.BodyID = arm.Body.ID() } @@ -204,14 +204,14 @@ func (c *checker) checkMatchStmt(scope *symbols.Scope, node *ast.MatchStmt, retu c.ctx.Diagnostics.AddError(diagnostics.ErrMissingInitializer, "data match case `"+resolved.CaseName.Name+"` requires a payload pattern", ast.LocOf(arm), "add `with ` or `with _`") } else if arm.Binding != nil || arm.Discard { - fieldEvidence := flowresult.MatchField{WholePayload: true, Type: resolved.Case.Payload, Discard: arm.Discard} + fieldEvidence := typecheckresult.MatchBinding{Projection: typecheckresult.MatchWholePayload, Type: resolved.Case.Payload, Discard: arm.Discard} if arm.Binding != nil { - fieldEvidence.Binding = c.module.Semantics.ResolvedSymbols[arm.Binding.ID()] + fieldEvidence.Binding = c.module.Bindings.NodeSymbols[arm.Binding.ID()] if fieldEvidence.Binding != nil { fieldEvidence.Binding.BindType(resolved.Case.Payload) } } - armEvidence.Fields = append(armEvidence.Fields, fieldEvidence) + armEvidence.Bindings = append(armEvidence.Bindings, fieldEvidence) } else { payload, payloadOK := typeinfo.Underlying(resolved.Case.Payload).(*typeinfo.StructType) if !payloadOK || payload == nil { @@ -238,18 +238,26 @@ func (c *checker) checkMatchStmt(scope *symbols.Scope, node *ast.MatchStmt, retu "unknown match pattern field `"+name+"`", ast.LocOf(pattern.Name), "") continue } - fieldEvidence := flowresult.MatchField{Field: fieldIndex, Type: field.Type, Discard: pattern.Discard} + fieldEvidence := typecheckresult.MatchBinding{Projection: typecheckresult.MatchPayloadField, Field: fieldIndex, Type: field.Type, Discard: pattern.Discard} if !pattern.Discard && pattern.Binding != nil { - fieldEvidence.Binding = c.module.Semantics.ResolvedSymbols[pattern.Binding.ID()] + fieldEvidence.Binding = c.module.Bindings.NodeSymbols[pattern.Binding.ID()] if fieldEvidence.Binding != nil { fieldEvidence.Binding.BindType(field.Type) } } - armEvidence.Fields = append(armEvidence.Fields, fieldEvidence) + armEvidence.Bindings = append(armEvidence.Bindings, fieldEvidence) } } } } + carrierUse := typeinfo.UseRead + for _, field := range armEvidence.Bindings { + if typeinfo.OwnershipCapabilityOf(field.Type).Copy != typeinfo.CopyImplicit { + carrierUse = typeinfo.UseMove + break + } + } + armEvidence.CarrierUse = carrierUse evidence.Arms = append(evidence.Arms, armEvidence) c.checkBlock(scope, arm.Body, returnType) } @@ -261,7 +269,7 @@ func (c *checker) checkMatchStmt(scope *symbols.Scope, node *ast.MatchStmt, retu "match is missing case `"+variant.Name+"`", ast.LocOf(node), "add one arm for every enum case") } if evidenceComplete && len(evidence.Arms) == len(node.Arms) { - c.module.Semantics.Matches[node.ID()] = evidence + c.module.Typechecking.Matches[node.ID()] = evidence } } @@ -318,7 +326,7 @@ func (c *checker) checkAssign(scope *symbols.Scope, node *ast.AssignStmt) { return } case *ast.SelectorExpr: - baseType := c.typeExpr(scope, target.Expr, nil) + baseType := c.recordedExprType(target.Expr) if _, ok := typeinfo.PointerTarget(typeinfo.Underlying(baseType)); ok { return } @@ -368,7 +376,7 @@ func (c *checker) checkIndexAssignmentTarget(scope *symbols.Scope, target *ast.I if typeinfo.IsInvalidOrUnknown(targetType) { return true } - baseType := c.typeExpr(scope, target.Expr, nil) + baseType := c.recordedExprType(target.Expr) if typeinfo.IsInvalidOrUnknown(baseType) { return true } @@ -505,12 +513,12 @@ func (c *checker) checkBinding(scope *symbols.Scope, node ast.Stmt, requireIniti // element access requires an explicit as_bytes/as_chars view. func (c *checker) checkForInStmt(scope *symbols.Scope, node *ast.ForStmt, returnType typeinfo.Type) { indexType := typeinfo.DefaultIntegerType() - evidence := project.ForIteration{} + evidence := typecheckresult.ForIteration{} if node.Index != nil { - evidence.Index = c.module.Semantics.ResolvedSymbols[node.Index.ID()] + evidence.Index = c.module.Bindings.NodeSymbols[node.Index.ID()] } if node.Value != nil { - evidence.Value = c.module.Semantics.ResolvedSymbols[node.Value.ID()] + evidence.Value = c.module.Bindings.NodeSymbols[node.Value.ID()] } valid := node.Value != nil && node.Value.Name != "" && evidence.Value != nil if node.Index != nil && (node.Index.Name == "" || evidence.Index == nil) { @@ -518,8 +526,9 @@ func (c *checker) checkForInStmt(scope *symbols.Scope, node *ast.ForStmt, return } var elemType typeinfo.Type - if rangeExpr, ok := node.Iterable.(*ast.RangeExpr); ok { - evidence.Kind = project.ForIterationRange + var carrierType typeinfo.Type + rangeExpr, isRange := node.Iterable.(*ast.RangeExpr) + if isRange { if !rangeExpr.EndExclusive { valid = false c.ctx.Diagnostics.Add(invalidExpressionError(rangeExpr, "for range requires an exclusive end; use `..` instead of `..=`")) @@ -578,7 +587,6 @@ func (c *checker) checkForInStmt(scope *symbols.Scope, node *ast.ForStmt, return } evidence.ElementType = elemType } else { - evidence.Kind = project.ForIterationSequence var ok bool indexType, ok = typeinfo.NumericTypeFromName("usize", c.ctx.Target) if !ok { @@ -599,21 +607,21 @@ func (c *checker) checkForInStmt(scope *symbols.Scope, node *ast.ForStmt, return exprType := func(expr ast.Expr) typeinfo.Type { return c.typeExpr(scope, expr, nil) } - if !place.Addressable(scope, node.Iterable, exprType, c.expandedDefaultBinding) { + if !place.Addressable(scope, node.Iterable, exprType, c.module.ExpandedDefaultBinding) { valid = false c.ctx.Diagnostics.Add(invalidExpressionError(node.Iterable, "for-in requires addressable array storage")) } } - if !typeinfo.IsImplicitCopyType(elem) { + if typeinfo.OwnershipCapabilityOf(elem).Copy != typeinfo.CopyImplicit { valid = false c.ctx.Diagnostics.Add(invalidExpressionError(node.Iterable, "for-in requires copyable sequence elements; iterate indexes and borrow move-only elements explicitly")) } if _, array := typeinfo.Underlying(iterableType).(*typeinfo.ArrayType); array { - evidence.CarrierType = &typeinfo.RefType{Target: iterableType} + carrierType = &typeinfo.RefType{Target: iterableType} } else { - evidence.CarrierType = iterableType + carrierType = iterableType } evidence.ElementType = elem } else { @@ -631,24 +639,28 @@ func (c *checker) checkForInStmt(scope *symbols.Scope, node *ast.ForStmt, return if c.siteOnly { return } - delete(c.module.Semantics.ForIterations, node.ID()) + delete(c.module.Typechecking.ForIterations, node.ID()) if valid && elemType != nil && !typeinfo.IsInvalidOrUnknown(elemType) { location := ast.LocOf(node) evidence.Cursor = symbols.New("$for.cursor", symbols.SymbolVar, nil, location) - if evidence.Kind == project.ForIterationRange { + if isRange { evidence.Cursor.BindType(elemType) - evidence.End = symbols.New("$for.end", symbols.SymbolVar, nil, location) - evidence.End.BindType(elemType) + plan := &typecheckresult.RangeIteration{ + Limit: symbols.New("$for.end", symbols.SymbolVar, nil, location), + } + plan.Limit.BindType(elemType) if node.Index != nil { - evidence.Ordinal = symbols.New("$for.ordinal", symbols.SymbolVar, nil, location) - evidence.Ordinal.BindType(indexType) + plan.Ordinal = symbols.New("$for.ordinal", symbols.SymbolVar, nil, location) + plan.Ordinal.BindType(indexType) } + evidence.Plan = plan } else { evidence.Cursor.BindType(indexType) - evidence.Carrier = symbols.New("$for.carrier", symbols.SymbolVar, nil, location) - evidence.Carrier.BindType(evidence.CarrierType) + carrier := symbols.New("$for.carrier", symbols.SymbolVar, nil, location) + carrier.BindType(carrierType) + evidence.Plan = &typecheckresult.SequenceIteration{Carrier: carrier, CarrierType: carrierType} } - c.module.Semantics.ForIterations[node.ID()] = evidence + c.module.Typechecking.ForIterations[node.ID()] = evidence } c.loopDepth++ c.checkBlock(scope, node.Body, returnType) @@ -659,7 +671,7 @@ func (c *checker) bindLoopVariable(name *ast.Ident, typ typeinfo.Type) { if typ == nil { return } - if sym := c.module.Semantics.ResolvedSymbols[name.ID()]; sym != nil { + if sym := c.module.Bindings.NodeSymbols[name.ID()]; sym != nil { sym.BindType(typ) } } @@ -716,7 +728,7 @@ func (c *checker) rejectReferenceStorage(typ typeinfo.Type, site ast.Node, conte } func (c *checker) rejectTemporaryBorrowEscape(scope *symbols.Scope, expr ast.Expr, context string) bool { - if c.module == nil || c.module.Semantics == nil { + if c.module == nil { return false } temporary := c.temporaryBorrowSource(scope, expr) @@ -733,31 +745,32 @@ func (c *checker) rejectTemporaryBorrowEscape(scope *symbols.Scope, expr ast.Exp } func (c *checker) temporaryBorrowSource(scope *symbols.Scope, expr ast.Expr) ast.Expr { - if c == nil || c.module == nil || c.module.Semantics == nil || expr == nil { + if c == nil || c.module == nil || expr == nil { return nil } exprType := func(node ast.Expr) typeinfo.Type { if node == nil { return nil } - return c.module.Semantics.ExprTypes[node.ID()] + return c.module.BaseExprType(node.ID()) } if _, _, reference := typeinfo.ReferenceValueTarget(exprType(expr)); !reference { return nil } switch node := expr.(type) { case *ast.AddressExpr: - if node == nil || node.Expr == nil || node.Mode == ast.AddressRaw || place.Addressable(scope, node.Expr, exprType, c.expandedDefaultBinding) { + if node == nil || node.Expr == nil || node.Mode == ast.AddressRaw || place.Addressable(scope, node.Expr, exprType, c.module.ExpandedDefaultBinding) { return nil } return node case *ast.CallExpr: fn, _ := typeinfo.Underlying(exprType(node.Callee)).(*typeinfo.FuncType) - for _, source := range typeinfo.ReturnOriginSources(node, fn) { + args := c.module.Typechecking.CallArgumentsOrSource(node) + for _, source := range typeinfo.ReturnOriginSources(node, args, fn) { if temporary := c.temporaryBorrowSource(scope, source); temporary != nil { return temporary } - if c.module.Semantics.ImplicitCallArguments[source.ID()] != nil && !place.Addressable(scope, source, exprType, c.expandedDefaultBinding) { + if c.module.Typechecking.ImplicitCallArguments[source.ID()] != nil && !place.Addressable(scope, source, exprType, c.module.ExpandedDefaultBinding) { if _, _, reference := typeinfo.ReferenceValueTarget(exprType(source)); !reference { return source } @@ -769,14 +782,14 @@ func (c *checker) temporaryBorrowSource(scope *symbols.Scope, expr ast.Expr) ast if temporary := c.temporaryBorrowSource(scope, node.Expr); temporary != nil { return temporary } - if !place.Addressable(scope, node.Expr, exprType, c.expandedDefaultBinding) { + if !place.Addressable(scope, node.Expr, exprType, c.module.ExpandedDefaultBinding) { return node } case *ast.IndexExpr: if temporary := c.temporaryBorrowSource(scope, node.Expr); temporary != nil { return temporary } - if !place.Addressable(scope, node.Expr, exprType, c.expandedDefaultBinding) { + if !place.Addressable(scope, node.Expr, exprType, c.module.ExpandedDefaultBinding) { return node } } diff --git a/internal/semantics/typechecker/flow.go b/internal/semantics/typechecker/flow.go index 3fb02b8c..961947f3 100644 --- a/internal/semantics/typechecker/flow.go +++ b/internal/semantics/typechecker/flow.go @@ -3,6 +3,7 @@ package typechecker import ( "compiler/internal/constvalue" "compiler/internal/frontend/ast" + graphcore "compiler/internal/graph" "compiler/internal/ir" "compiler/internal/ir/cfg" "compiler/internal/project" @@ -10,6 +11,7 @@ import ( "compiler/internal/semantics/flowresult" "compiler/internal/semantics/place" "compiler/internal/semantics/symbols" + "compiler/internal/semantics/typecheckresult" "compiler/internal/semantics/typeinfo" ) @@ -78,7 +80,7 @@ func CheckFlow(ctx *project.CompilerContext, module *project.Module) *flowresult ResolvedStorageOrigins: make(map[ast.NodeID][]place.Origin), ResolvedValueOrigins: make(map[ast.NodeID][]place.Origin), } - if ctx == nil || module == nil || module.CFG == nil || module.Semantics == nil || module.ModuleScope == nil { + if ctx == nil || module == nil || module.CFG == nil || module.Bindings == nil || module.ModuleScope == nil { return result } for _, graph := range module.CFG.Functions { @@ -91,7 +93,7 @@ func CheckFlow(ctx *project.CompilerContext, module *project.Module) *flowresult } var sym *symbols.Symbol if fn.Receiver != nil { - sym = module.Semantics.MethodSymbol[fn.ID()] + sym = module.Bindings.MethodsByDecl[fn.ID()] } else if fn.Name != nil { sym, _ = module.ModuleScope.Lookup(fn.Name.Name) } @@ -233,28 +235,28 @@ func (c *checker) recordCaseTest(node ast.Expr, subject ast.Expr, caseIndex, cas if c == nil || node == nil || subject == nil { return } - test := flowresult.CaseTest{ + test := typecheckresult.CaseTest{ SubjectID: subject.ID(), Case: caseIndex, CaseWhenTrue: caseWhenTrue, CaseCount: caseCount, Family: family, } if c.flow == nil { - if c.module != nil && c.module.Semantics != nil { - c.module.Semantics.CaseTests[node.ID()] = test + if c.module != nil && c.module.Typechecking != nil { + c.module.Typechecking.CaseTests[node.ID()] = test } return } - base, found := c.module.Semantics.CaseTests[node.ID()] + base, found := c.module.Typechecking.CaseTests[node.ID()] if !found || base.SubjectID != subject.ID() { return } - test = base + refined := flowresult.CaseTest{CaseTest: base} if payload, ok := c.flow.result.Payloads[subject.ID()]; ok { storage := c.flow.result.ResolvedStorageOrigins[subject.ID()] if payload.AppliesTo(storage) { - test.PayloadPath = append([]int(nil), payload.Cases...) + refined.PayloadPath = append([]int(nil), payload.Cases...) } } - c.flow.result.CaseTests[node.ID()] = test + c.flow.result.CaseTests[node.ID()] = refined if c.flow.events != nil { c.flow.events.next++ c.flow.events.tests[node.ID()] = c.flow.events.next @@ -283,23 +285,27 @@ func (c *checker) recordFlowResolution(expr ast.Expr, resolution place.Resolutio c.flow.result.ResolvedValueOrigins[id] = place.CloneOrigins(resolution.ValueOrigins) } +// recordedExprType reads an already typed operand without replaying calls or +// erasing the payload evidence established while typing its enclosing place. +func (c *checker) recordedExprType(expr ast.Expr) typeinfo.Type { + if expr == nil { + return nil + } + if c.flow != nil { + if typ := c.flow.result.ExprTypes[expr.ID()]; typ != nil { + return typ + } + } + return c.module.BaseExprType(expr.ID()) +} + func (c *checker) resolveFlowPlace(scope *symbols.Scope, expr ast.Expr, st flowState) place.Resolution { - if c == nil || c.module == nil || c.module.Semantics == nil { + if c == nil || c.module == nil { return place.Resolution{} } return place.Resolve(scope, expr, place.ResolveOptions{ - ExprType: func(node ast.Expr) typeinfo.Type { - if node == nil { - return nil - } - if c.flow != nil { - if typ := c.flow.result.ExprTypes[node.ID()]; typ != nil { - return typ - } - } - return c.module.Semantics.ExprTypes[node.ID()] - }, - ResolveBinding: c.expandedDefaultBinding, + ExprType: c.recordedExprType, + ResolveBinding: c.module.ExpandedDefaultBinding, ReferenceOrigins: func(storage []place.Origin) []place.Origin { return originValues(st.references, storage) }, @@ -310,19 +316,17 @@ func (c *checker) resolveFlowPlace(scope *symbols.Scope, expr ast.Expr, st flowS if call == nil || call.Callee == nil { return nil } - calleeType := c.module.Semantics.ExprTypes[call.Callee.ID()] - if c.flow != nil && c.flow.result.ExprTypes[call.Callee.ID()] != nil { - calleeType = c.flow.result.ExprTypes[call.Callee.ID()] - } + calleeType := c.recordedExprType(call.Callee) fn, _ := typeinfo.Underlying(calleeType).(*typeinfo.FuncType) var origins []place.Origin - for _, source := range typeinfo.ReturnOriginSources(call, fn) { + args := c.module.Typechecking.CallArgumentsOrSource(call) + for _, source := range typeinfo.ReturnOriginSources(call, args, fn) { origins = place.MergeOrigins(origins, c.resolveFlowPlace(scope, source, st).ValueOrigins) } return origins }, ConstantIndex: func(index ast.Expr) (string, bool) { - expected := c.module.Semantics.ExprTypes[index.ID()] + expected := c.module.BaseExprType(index.ID()) if !typeinfo.IsIntegral(expected) { expected = typeinfo.DefaultIntegerType() } @@ -379,34 +383,32 @@ func (a *flowAnalyzer) run() { } entry := a.graph.Entry.Sites[0].ID a.inStates[entry] = entryState - queue := []cfg.SiteID{entry} - queued := map[cfg.SiteID]bool{entry: true} + work := graphcore.NewWorklist(entry) for _, id := range order { - if id == entry || len(a.sites[id].Predecessors) != 0 { + if id == entry || a.graph.SiteEdges.InDegree(id, nil) != 0 { continue } a.inStates[id] = copyFlowState(entryState) - queue = append(queue, id) - queued[id] = true + work.Add(id) } for { - if len(queue) == 0 { + id, pending := work.Next() + if !pending { + seeded := false for _, id := range order { if _, visited := a.inStates[id]; visited || !disconnected[id] { continue } a.inStates[id] = copyFlowState(entryState) - queue = append(queue, id) - queued[id] = true + work.Add(id) + seeded = true break } - if len(queue) == 0 { + if !seeded { break } + continue } - id := queue[0] - queue = queue[1:] - queued[id] = false site := a.sites[id] if site == nil { continue @@ -418,7 +420,7 @@ func (a *flowAnalyzer) run() { a.result.SiteFacts[a.graph.NodeID][id] = snapshotFlowState(input) next := copyFlowState(input) events := a.applySite(site, &next) - for _, edge := range site.Successors { + for _, edge := range a.graph.SiteEdges.OutEdges(site.ID) { if a.sites[edge.To] == nil { continue } @@ -439,10 +441,7 @@ func (a *flowAnalyzer) run() { continue } a.inStates[edge.To] = merged - if !queued[edge.To] { - queue = append(queue, edge.To) - queued[edge.To] = true - } + work.Add(edge.To) } } } @@ -451,7 +450,7 @@ func (a *flowAnalyzer) applyVariantCaseEdge(site *cfg.Site, edge cfg.Edge, st *f if a == nil || site == nil || st == nil || edge.Kind != cfg.EdgeVariantCase { return } - match, found := a.module.Semantics.Matches[ast.NodeID(site.NodeID)] + match, found := a.module.Typechecking.Matches[ast.NodeID(site.NodeID)] if !found { return } @@ -459,7 +458,7 @@ func (a *flowAnalyzer) applyVariantCaseEdge(site *cfg.Site, edge cfg.Edge, st *f if subject == nil { return } - scope := a.module.Semantics.BlockScopes[ast.NodeID(site.ScopeID)] + scope := a.module.Bindings.BlockScopes[ast.NodeID(site.ScopeID)] if scope == nil { scope = a.functionScope } @@ -469,7 +468,7 @@ func (a *flowAnalyzer) applyVariantCaseEdge(site *cfg.Site, edge cfg.Edge, st *f restrictVariantFact(st, variantStateFact{ origins: resolution.StorageOrigins, cases: []int{edge.Case}, - caseCount: len(match.Cases), + caseCount: match.CaseCount, dependencies: append([]*symbols.Symbol(nil), resolution.Dependencies...), }) } @@ -478,17 +477,21 @@ func (a *flowAnalyzer) applyVariantCaseEdge(site *cfg.Site, edge cfg.Edge, st *f return } payloadOrigins := place.VariantPayloadOrigins(resolution.ValueOrigins, []int{edge.Case}) - for _, field := range arm.Fields { - if field.Binding == nil { - continue - } + for _, field := range arm.Bindings { fieldOrigins := payloadOrigins - if !field.WholePayload { + switch field.Projection { + case typecheckresult.MatchPayloadField: payload, payloadFound := typeinfo.Underlying(arm.Payload).(*typeinfo.StructType) if !payloadFound || payload == nil || field.Field < 0 || field.Field >= len(payload.Fields) { continue } fieldOrigins = place.FieldOrigins(payloadOrigins, payload.Fields[field.Field].Name) + case typecheckresult.MatchWholePayload: + default: + panic("flow typechecking: invalid match binding projection") + } + if field.Binding == nil { + continue } bindingOrigins := []place.Origin{{Root: field.Binding}} valueOrigins := fieldOrigins @@ -564,7 +567,7 @@ func (a *flowAnalyzer) applySite(site *cfg.Site, st *flowState) *flowExpressionE if site == nil || st == nil { return events } - scope := a.module.Semantics.BlockScopes[ast.NodeID(site.ScopeID)] + scope := a.module.Bindings.BlockScopes[ast.NodeID(site.ScopeID)] if scope == nil { scope = a.functionScope } @@ -583,7 +586,7 @@ func (a *flowAnalyzer) applySite(site *cfg.Site, st *flowState) *flowExpressionE case cfg.SiteScopeExit: block, _ := a.module.TypedASTNodes[ast.NodeID(site.NodeID)].(*ast.BlockStmt) if block != nil { - blockScope := a.module.Semantics.BlockScopes[block.ID()] + blockScope := a.module.Bindings.BlockScopes[block.ID()] if blockScope == nil { return events } @@ -614,7 +617,7 @@ func (a *flowAnalyzer) applyStatementEffects(c *checker, scope *symbols.Scope, s invalidateVariantOrigins(st, resolution.StorageOrigins) typ := a.result.ExprTypes[node.Target.ID()] if typ == nil { - typ = a.module.Semantics.ExprTypes[node.Target.ID()] + typ = a.module.BaseExprType(node.Target.ID()) } a.updateOriginPlace(c, scope, resolution.StorageOrigins, typ, node.Value, sourceState, st) } @@ -625,7 +628,7 @@ func (a *flowAnalyzer) assignedSymbol(scope *symbols.Scope, expr ast.Expr) *symb if !ok || ident == nil { return nil } - if sym := a.module.Semantics.ResolvedSymbols[ident.ID()]; sym != nil { + if sym := a.module.Bindings.NodeSymbols[ident.ID()]; sym != nil { return sym } sym, _ := scope.Lookup(ident.Name) @@ -674,7 +677,7 @@ func (a *flowAnalyzer) updateOriginPlace( } return } - construction, constructed := a.module.Semantics.VariantConstructions[value.ID()] + construction, constructed := a.module.Typechecking.VariantConstructions[value.ID()] if !constructed || construction.Payload == nil || construction.Case < 0 { source := c.resolveFlowPlace(scope, value, sourceState) a.copyStoredOriginPlace(storage, source.ValueOrigins, typ, sourceState, st) @@ -729,10 +732,10 @@ func (a *flowAnalyzer) invalidateCall(c *checker, scope *symbols.Scope, call *as } calleeType := a.result.ExprTypes[call.Callee.ID()] if calleeType == nil { - calleeType = a.module.Semantics.ExprTypes[call.Callee.ID()] + calleeType = a.module.BaseExprType(call.Callee.ID()) } fn, _ := typeinfo.Underlying(calleeType).(*typeinfo.FuncType) - args := append([]ast.Expr(nil), call.Args...) + args := a.module.Typechecking.CallArgumentsOrSource(call) if selector, method := call.Callee.(*ast.SelectorExpr); method && selector != nil { args = append([]ast.Expr{selector.Expr}, args...) } @@ -778,21 +781,29 @@ func (a *flowAnalyzer) rawPointerOrigins(c *checker, scope *symbols.Scope, expr } func (a *flowAnalyzer) applyConditionEdge(site *cfg.Site, edge cfg.EdgeKind, st *flowState, events *flowExpressionEvents) { - if st == nil || (edge != cfg.EdgeTrue && edge != cfg.EdgeFalse) { + if a == nil || a.graph == nil || site == nil || st == nil || + (edge != cfg.EdgeTrue && edge != cfg.EdgeFalse) { return } - stmt, _ := a.module.TypedASTNodes[ast.NodeID(site.NodeID)].(ast.Stmt) - var condition ast.Expr - switch node := stmt.(type) { - case *ast.IfStmt: - condition = node.Cond - case *ast.ForStmt: - condition = node.Cond + // CFG owns control topology. Once a source construct has become a Branch, + // flow analysis must consume the branch's published condition identity + // rather than rediscovering whether the source statement was an if or loop. + if site.ID.Block < 0 || site.ID.Block >= len(a.graph.Blocks) { + return + } + block := a.graph.Blocks[site.ID.Block] + if block == nil { + return + } + branch, ok := block.Terminator.(*cfg.Branch) + if !ok || branch == nil || branch.ConditionID == 0 { + return } + condition, _ := a.module.TypedASTNodes[ast.NodeID(branch.ConditionID)].(ast.Expr) if condition == nil { return } - scope := a.module.Semantics.BlockScopes[ast.NodeID(site.ScopeID)] + scope := a.module.Bindings.BlockScopes[ast.NodeID(site.ScopeID)] if scope == nil { scope = a.functionScope } diff --git a/internal/semantics/typechecker/flow_test.go b/internal/semantics/typechecker/flow_test.go index af684611..044ad1a4 100644 --- a/internal/semantics/typechecker/flow_test.go +++ b/internal/semantics/typechecker/flow_test.go @@ -8,6 +8,7 @@ import ( "compiler/internal/frontend/lexer" "compiler/internal/frontend/parser" "compiler/internal/ir/cfg" + "compiler/internal/moduleid" "compiler/internal/project" "compiler/internal/semantics/binder" "compiler/internal/semantics/collector" @@ -26,22 +27,21 @@ func checkFlowSource(t *testing.T, src string) (*project.Module, *diagnostics.Di diag.AddSourceContent(filePath, src) ctx := project.New(".", peeper.SourceExt, diag) module := &project.Module{ - Key: project.ModuleKeyFor(project.ModuleOriginLocal, filePath), - ImportPath: "flow_test", - FilePath: filePath, - Content: src, - AST: parser.New(filePath, lexer.New(filePath, src, diag).Tokenize(), diag).ParseModule(), - Imports: make(map[string]project.ResolvedImport), + ID: moduleid.ID{Origin: string(project.ModuleOriginLocal), ImportPath: "flow_test"}, + FilePath: filePath, + Content: src, + AST: parser.New(filePath, lexer.New(filePath, src, diag).Tokenize(), diag).ParseModule(), + Imports: make(map[string]project.ResolvedImport), } ctx.AddModule(module) collector.Collect(ctx, module) binder.Bind(ctx, module) resolver.Resolve(ctx, module) Check(ctx, module) - module.TypedASTNodes = ast.Index(module.AST) + module.RebuildTypedASTIndex() module.CFG = cfg.BuildModule(module.AST, cfg.BuildQueries{ - MatchCases: module.Semantics.MatchCases, - LoopGuaranteedEntry: module.Semantics.ForLoopGuaranteedEntry, + MatchCases: module.Typechecking.MatchCases, + LoopGuaranteedEntry: module.Typechecking.ForLoopGuaranteedEntry, }) module.Flow = CheckFlow(ctx, module) return module, diag @@ -69,7 +69,7 @@ fn Read(choice: Choice) -> i32 { fn := module.AST.Stmts[1].(*ast.FnDecl) leftBranch := fn.Body.Stmts[0].(*ast.IfStmt) leftTest := leftBranch.Cond.(*ast.IsExpr) - baseTest, baseFound := module.Semantics.CaseTests[leftTest.ID()] + baseTest, baseFound := module.Typechecking.CaseTests[leftTest.ID()] flowTest, flowFound := module.Flow.CaseTests[leftTest.ID()] if !baseFound || !flowFound || baseTest.Case != 0 || flowTest.Case != baseTest.Case || flowTest.SubjectID != baseTest.SubjectID || flowTest.CaseCount != baseTest.CaseCount { @@ -333,7 +333,7 @@ func TestInvalidateCallClearsMutableModuleVariableFacts(t *testing.T) { } state := flowState{variants: []variantStateFact{{origins: []place.Origin{{Root: global}}, cases: []int{1}, caseCount: 2}}} analyzer := flowAnalyzer{ - module: &project.Module{ModuleScope: moduleScope, Semantics: project.NewSemanticInfo()}, + module: &project.Module{ModuleScope: moduleScope}, result: &flowresult.Result{ExprTypes: make(map[ast.NodeID]typeinfo.Type)}, } diff --git a/internal/semantics/typechecker/for_in_test.go b/internal/semantics/typechecker/for_in_test.go index 48616e7f..ab0dfa74 100644 --- a/internal/semantics/typechecker/for_in_test.go +++ b/internal/semantics/typechecker/for_in_test.go @@ -5,7 +5,7 @@ import ( "testing" "compiler/internal/frontend/ast" - "compiler/internal/project" + "compiler/internal/semantics/typecheckresult" "compiler/internal/semantics/typeinfo" "compiler/internal/target" ) @@ -24,7 +24,7 @@ return total; } fn := module.AST.Stmts[0].(*ast.FnDecl) loop := fn.Body.Stmts[1].(*ast.ForStmt) - binding := module.Semantics.ResolvedSymbols[loop.Value.ID()] + binding := module.Bindings.NodeSymbols[loop.Value.ID()] if binding == nil { t.Fatal("missing resolved loop binding") } @@ -41,7 +41,7 @@ return total; if reference == nil { t.Fatal("missing loop binding reference") } - if resolved := module.Semantics.ResolvedSymbols[reference.ID()]; resolved != binding { + if resolved := module.Bindings.NodeSymbols[reference.ID()]; resolved != binding { t.Fatalf("loop reference resolved to %#v, want declaration symbol %#v", resolved, binding) } } @@ -60,20 +60,24 @@ return total; } fn := module.AST.Stmts[0].(*ast.FnDecl) loop := fn.Body.Stmts[1].(*ast.ForStmt) - evidence, ok := module.Semantics.ForIterations[loop.ID()] + evidence, ok := module.Typechecking.ForIterations[loop.ID()] if !ok { t.Fatal("missing range iteration evidence") } - if evidence.Kind != project.ForIterationRange || evidence.Cursor == nil || evidence.End == nil || evidence.Ordinal == nil { + plan, isRange := evidence.Plan.(*typecheckresult.RangeIteration) + if !isRange { t.Fatalf("range iteration evidence = %#v", evidence) } - if evidence.Index != module.Semantics.ResolvedSymbols[loop.Index.ID()] || evidence.Value != module.Semantics.ResolvedSymbols[loop.Value.ID()] { + if evidence.Cursor == nil || plan.Limit == nil || plan.Ordinal == nil { + t.Fatalf("range iteration evidence is incomplete = %#v", evidence) + } + if evidence.Index != module.Bindings.NodeSymbols[loop.Index.ID()] || evidence.Value != module.Bindings.NodeSymbols[loop.Value.ID()] { t.Fatal("range evidence does not preserve source binding symbols") } for name, symbol := range map[string]string{ "cursor": typeinfo.TypeText(evidence.Cursor.Type), - "end": typeinfo.TypeText(evidence.End.Type), - "ordinal": typeinfo.TypeText(evidence.Ordinal.Type), + "end": typeinfo.TypeText(plan.Limit.Type), + "ordinal": typeinfo.TypeText(plan.Ordinal.Type), "index": typeinfo.TypeText(evidence.Index.Type), "value": typeinfo.TypeText(evidence.Value.Type), } { @@ -98,14 +102,14 @@ func TestCheckForInRangeTypeIsBoundOrderIndependent(t *testing.T) { } fn := module.AST.Stmts[0].(*ast.FnDecl) loop := fn.Body.Stmts[0].(*ast.ForStmt) - evidence, found := module.Semantics.ForIterations[loop.ID()] + evidence, found := module.Typechecking.ForIterations[loop.ID()] if !found { t.Fatal("missing range iteration evidence") } for name, typ := range map[string]typeinfo.Type{ "element": evidence.ElementType, "cursor": evidence.Cursor.Type, - "end": evidence.End.Type, + "end": evidence.Plan.(*typecheckresult.RangeIteration).Limit.Type, "value": evidence.Value.Type, } { if got := typeinfo.TypeText(typ); got != "i64" { @@ -132,11 +136,11 @@ return 0i64; } fn := module.AST.Stmts[0].(*ast.FnDecl) loop := fn.Body.Stmts[0].(*ast.ForStmt) - evidence := module.Semantics.ForIterations[loop.ID()] + evidence := module.Typechecking.ForIterations[loop.ID()] for name, typ := range map[string]typeinfo.Type{ "element": evidence.ElementType, "cursor": evidence.Cursor.Type, - "end": evidence.End.Type, + "end": evidence.Plan.(*typecheckresult.RangeIteration).Limit.Type, "value": evidence.Value.Type, } { if got := typeinfo.TypeText(typ); got != "i64" { @@ -163,7 +167,7 @@ func TestCheckForInRecordsGuaranteedRangeEntry(t *testing.T) { } fn := module.AST.Stmts[0].(*ast.FnDecl) loop := fn.Body.Stmts[0].(*ast.ForStmt) - evidence, found := module.Semantics.ForIterations[loop.ID()] + evidence, found := module.Typechecking.ForIterations[loop.ID()] if !found { t.Fatal("missing range iteration evidence") } @@ -189,14 +193,18 @@ return total; } fn := module.AST.Stmts[0].(*ast.FnDecl) loop := fn.Body.Stmts[2].(*ast.ForStmt) - evidence, ok := module.Semantics.ForIterations[loop.ID()] + evidence, ok := module.Typechecking.ForIterations[loop.ID()] if !ok { t.Fatal("missing sequence iteration evidence") } - if evidence.Kind != project.ForIterationSequence || evidence.Carrier == nil || evidence.Cursor == nil { + plan, isSequence := evidence.Plan.(*typecheckresult.SequenceIteration) + if !isSequence { t.Fatalf("sequence iteration evidence = %#v", evidence) } - if got := typeinfo.TypeText(evidence.Carrier.Type); got != "&[3]i32" { + if evidence.Cursor == nil || plan.Carrier == nil { + t.Fatalf("sequence iteration evidence is incomplete = %#v", evidence) + } + if got := typeinfo.TypeText(plan.Carrier.Type); got != "&[3]i32" { t.Fatalf("carrier type = %s, want &[3]i32", got) } wantCursor, ok := typeinfo.NumericTypeFromName("usize", target.Host()) @@ -331,7 +339,7 @@ func TestRejectedForInDoesNotPublishIterationEvidence(t *testing.T) { if loop == nil { t.Fatal("missing recovered for-in loop") } - if _, found := module.Semantics.ForIterations[loop.ID()]; found { + if _, found := module.Typechecking.ForIterations[loop.ID()]; found { t.Fatal("rejected for-in loop retained semantic evidence") } }) @@ -350,7 +358,7 @@ func TestRejectedForInStillChecksBody(t *testing.T) { } fn := module.AST.Stmts[0].(*ast.FnDecl) loop := fn.Body.Stmts[0].(*ast.ForStmt) - if _, found := module.Semantics.ForIterations[loop.ID()]; found { + if _, found := module.Typechecking.ForIterations[loop.ID()]; found { t.Fatal("rejected loop retained semantic evidence") } } diff --git a/internal/semantics/typechecker/typechecker.go b/internal/semantics/typechecker/typechecker.go index 29ca1f3d..26e42209 100644 --- a/internal/semantics/typechecker/typechecker.go +++ b/internal/semantics/typechecker/typechecker.go @@ -3,8 +3,8 @@ package typechecker import ( "compiler/internal/frontend/ast" "compiler/internal/project" - "compiler/internal/semantics/place" "compiler/internal/semantics/symbols" + "compiler/internal/semantics/typecheckresult" "compiler/internal/semantics/typeinfo" ) @@ -51,16 +51,6 @@ func (c *checker) requireValueType(expr ast.Expr, typ typeinfo.Type, context str return &typeinfo.InvalidType{} } -func (c *checker) expandedDefaultBinding(ident *ast.Ident) (place.Binding, bool) { - if c == nil || c.module == nil || c.module.Semantics == nil || ident == nil { - return place.Binding{}, false - } - if _, ok := c.module.Semantics.ExpandedDefaultBindings[ident.ID()]; !ok { - return place.Binding{}, false - } - return place.Binding{Symbol: c.module.Semantics.ResolvedSymbols[ident.ID()]}, true -} - func (c *checker) checkModule() { if c == nil || c.module == nil || c.module.AST == nil { return @@ -102,7 +92,9 @@ func (c *checker) checkModule() { } var sym *symbols.Symbol if node.Receiver != nil { - sym = c.module.Semantics.MethodSymbol[node.ID()] + if c.module.Bindings != nil { + sym = c.module.Bindings.MethodsByDecl[node.ID()] + } c.checkReceiverFunction(node) } else { sym, _ = c.module.ModuleScope.Lookup(node.Name.Name) @@ -120,6 +112,7 @@ func Check(ctx *project.CompilerContext, module *project.Module) { if module == nil || ctx == nil { return } + module.Typechecking = typecheckresult.New() (&checker{ctx: ctx, module: module}).checkModule() } diff --git a/internal/semantics/typechecker/typechecker_test.go b/internal/semantics/typechecker/typechecker_test.go index 8669c767..a4f66b6a 100644 --- a/internal/semantics/typechecker/typechecker_test.go +++ b/internal/semantics/typechecker/typechecker_test.go @@ -9,17 +9,176 @@ import ( "compiler/internal/frontend/ast" "compiler/internal/frontend/lexer" "compiler/internal/frontend/parser" + "compiler/internal/moduleid" "compiler/internal/project" "compiler/internal/semantics/binder" "compiler/internal/semantics/collector" "compiler/internal/semantics/intrinsics" "compiler/internal/semantics/resolver" "compiler/internal/semantics/symbols" + "compiler/internal/semantics/typecheckresult" "compiler/internal/semantics/typeinfo" "compiler/internal/target" "compiler/pkg/peeper" ) +func TestCheckCallPublishesValueUses(t *testing.T) { + module, diag := checkTypeModule(t, `struct Box { value: i32 } +fn Take(box: Box) -> i32 { return box.value; } +fn Read(v: i32) -> i32 { return v; } +fn Borrow(b: &Box) -> i32 { return b.value; } +fn main() -> i32 { + let stack = .Box { value = 1 }; + let copied = Read(5); + let borrowed = Borrow(&stack); + let moved = Take(stack); + return copied + borrowed + moved; +}`) + if diag.HasErrors() { + t.Fatalf("unexpected diagnostics:\n%s", diag.EmitAllToString()) + } + type callUse struct { + param int + use typeinfo.UseKind + found bool + } + walk := func(visit func(ast.Node) bool) { + for _, stmt := range module.AST.Stmts { + ast.Inspect(stmt, visit) + } + } + uses := make(map[string][]callUse) + walk(func(node ast.Node) bool { + call, ok := node.(*ast.CallExpr) + if !ok { + return true + } + callee, ok := call.Callee.(*ast.Ident) + if !ok { + return true + } + for _, arg := range call.Args { + kind, found := module.Typechecking.ValueUses[arg.ID()] + uses[callee.Name] = append(uses[callee.Name], callUse{use: kind, found: found}) + } + return true + }) + for _, arg := range uses["Read"] { + if !arg.found || arg.use != typeinfo.UseRead { + t.Fatalf("copyable parameter argument = %+v, want published UseRead", arg) + } + } + for _, arg := range uses["Take"] { + if !arg.found || arg.use != typeinfo.UseMove { + t.Fatalf("owned parameter argument = %+v, want published UseMove", arg) + } + } + for _, arg := range uses["Borrow"] { + if !arg.found || arg.use != typeinfo.UseRead { + t.Fatalf("reference parameter argument = %+v, want published UseRead", arg) + } + } + if len(uses["Take"]) != 1 || len(uses["Read"]) != 1 || len(uses["Borrow"]) != 1 { + t.Fatalf("unexpected call counts: %#v", uses) + } +} + +func TestCheckAllocPublishesConsumingUse(t *testing.T) { + module, diag := checkTypeModule(t, `fn main() -> i32 { + let heap = alloc(7); + return 0; +}`) + if diag.HasErrors() { + t.Fatalf("unexpected diagnostics:\n%s", diag.EmitAllToString()) + } + published := 0 + for _, stmt := range module.AST.Stmts { + ast.Inspect(stmt, func(node ast.Node) bool { + lit, ok := node.(*ast.NumberLit) + if !ok { + return true + } + kind, found := module.Typechecking.ValueUses[lit.ID()] + if !found { + return true + } + published++ + if kind != typeinfo.UseMove { + t.Fatalf("alloc operand = %v, want UseMove", kind) + } + return true + }) + } + if published != 1 { + t.Fatalf("alloc operand published uses = %d, want 1", published) + } +} + +// Ownership reads the published kind for alloc rather than deriving it, and its +// fallback for a missing entry is a read. An operand that produces no value type +// exits typing early, so publication must already have happened by then or the +// move is silently lost. +func TestCheckAllocPublishesConsumingUseForUntypedOperand(t *testing.T) { + module, _ := checkTypeModule(t, `fn Nothing() {} + +fn main() -> i32 { + let heap = alloc(Nothing()); + return 0; +}`) + published := 0 + for _, stmt := range module.AST.Stmts { + ast.Inspect(stmt, func(node ast.Node) bool { + call, ok := node.(*ast.CallExpr) + if !ok { + return true + } + kind, found := module.Typechecking.ValueUses[call.ID()] + if !found { + return true + } + published++ + if kind != typeinfo.UseMove { + t.Fatalf("alloc operand = %v, want UseMove", kind) + } + return true + }) + } + if published != 1 { + t.Fatalf("alloc operand published uses = %d, want 1", published) + } +} + +func TestCheckMatchPublishesCarrierUse(t *testing.T) { + module, diag := checkTypeModule(t, `struct Box { value: i32 } +enum Resource { + Owned: { box: Box }, + Free, +} +fn main() -> i32 { + let resource = Resource::Owned with .{ box = .Box { value = 1 } }; + match resource { + Resource::Owned with { box = b } => { return b.value; } + Resource::Free => { return 0; } + } +}`) + if diag.HasErrors() { + t.Fatalf("unexpected diagnostics:\n%s", diag.EmitAllToString()) + } + if len(module.Typechecking.Matches) != 1 { + t.Fatalf("published matches = %d, want 1", len(module.Typechecking.Matches)) + } + for _, match := range module.Typechecking.Matches { + for _, arm := range match.Arms { + if arm.Case == 0 && arm.CarrierUse != typeinfo.UseMove { + t.Fatalf("owned-payload arm carrier use = %v, want UseMove", arm.CarrierUse) + } + if arm.Case == 1 && arm.CarrierUse != typeinfo.UseRead { + t.Fatalf("payloadless arm carrier use = %v, want UseRead", arm.CarrierUse) + } + } + } +} + func checkTypeSource(t *testing.T, src string) *diagnostics.DiagnosticBag { t.Helper() const filePath = "typechecker_test" + peeper.SourceExt @@ -28,12 +187,11 @@ func checkTypeSource(t *testing.T, src string) *diagnostics.DiagnosticBag { ctx := project.New(".", peeper.SourceExt, diag) modAST := parser.New(filePath, lexer.New(filePath, src, diag).Tokenize(), diag).ParseModule() module := &project.Module{ - Key: project.ModuleKeyFor(project.ModuleOriginLocal, filePath), - ImportPath: "typechecker_test", - FilePath: filePath, - Content: src, - AST: modAST, - Imports: make(map[string]project.ResolvedImport), + ID: moduleid.ID{Origin: string(project.ModuleOriginLocal), ImportPath: "typechecker_test"}, + FilePath: filePath, + Content: src, + AST: modAST, + Imports: make(map[string]project.ResolvedImport), } ctx.AddModule(module) collector.Collect(ctx, module) @@ -57,12 +215,11 @@ func checkTypeSourceWithExternalImport(t *testing.T, src string) (*project.Modul extAST := parser.New(externalPath, lexer.New(externalPath, externalSrc, diag).Tokenize(), diag).ParseModule() extModule := &project.Module{ - Key: project.ModuleKeyFor(project.ModuleOriginLocal, externalPath), - ImportPath: "external", - FilePath: externalPath, - Content: externalSrc, - AST: extAST, - Imports: make(map[string]project.ResolvedImport), + ID: moduleid.ID{Origin: string(project.ModuleOriginLocal), ImportPath: "external"}, + FilePath: externalPath, + Content: externalSrc, + AST: extAST, + Imports: make(map[string]project.ResolvedImport), } ctx.AddModule(extModule) collector.Collect(ctx, extModule) @@ -72,17 +229,14 @@ func checkTypeSourceWithExternalImport(t *testing.T, src string) (*project.Modul modAST := parser.New(filePath, lexer.New(filePath, src, diag).Tokenize(), diag).ParseModule() module := &project.Module{ - Key: project.ModuleKeyFor(project.ModuleOriginLocal, filePath), - ImportPath: "typechecker_test", - FilePath: filePath, - Content: src, - AST: modAST, + ID: moduleid.ID{Origin: string(project.ModuleOriginLocal), ImportPath: "typechecker_test"}, + FilePath: filePath, + Content: src, + AST: modAST, Imports: map[string]project.ResolvedImport{ "external": { - Key: extModule.Key, - ImportPath: "external", - FilePath: externalPath, - Origin: project.ModuleOriginLocal, + ID: extModule.ID, + FilePath: externalPath, }, }, } @@ -168,12 +322,11 @@ func checkTypeModule(t *testing.T, src string) (*project.Module, *diagnostics.Di ctx := project.New(".", peeper.SourceExt, diag) modAST := parser.New(filePath, lexer.New(filePath, src, diag).Tokenize(), diag).ParseModule() module := &project.Module{ - Key: project.ModuleKeyFor(project.ModuleOriginLocal, filePath), - ImportPath: "typechecker_test", - FilePath: filePath, - Content: src, - AST: modAST, - Imports: make(map[string]project.ResolvedImport), + ID: moduleid.ID{Origin: string(project.ModuleOriginLocal), ImportPath: "typechecker_test"}, + FilePath: filePath, + Content: src, + AST: modAST, + Imports: make(map[string]project.ResolvedImport), } ctx.AddModule(module) collector.Collect(ctx, module) @@ -221,12 +374,12 @@ fn main() { fn := module.AST.Stmts[1].(*ast.FnDecl) ok := fn.Body.Stmts[0].(*ast.LetDecl).Value pending := fn.Body.Stmts[1].(*ast.LetDecl).Value - okEvidence, found := module.Semantics.VariantConstructions[ok.ID()] + okEvidence, found := module.Typechecking.VariantConstructions[ok.ID()] if !found || typeinfo.TypeText(okEvidence.EnumType) != "Result" || okEvidence.Case != 0 || okEvidence.Payload == nil || ast.ExprText(okEvidence.Value) != ".{code = 7, value = 42}" { t.Fatalf("Ok construction evidence = %#v", okEvidence) } - pendingEvidence, found := module.Semantics.VariantConstructions[pending.ID()] + pendingEvidence, found := module.Typechecking.VariantConstructions[pending.ID()] if !found || typeinfo.TypeText(pendingEvidence.EnumType) != "Result" || pendingEvidence.Case != 1 || pendingEvidence.Payload != nil || pendingEvidence.Value != nil { t.Fatalf("Pending construction evidence = %#v", pendingEvidence) @@ -354,14 +507,16 @@ fn Read(result: Result) -> i32 { } fn := module.AST.Stmts[1].(*ast.FnDecl) match := fn.Body.Stmts[0].(*ast.MatchStmt) - evidence, found := module.Semantics.Matches[match.ID()] + evidence, found := module.Typechecking.Matches[match.ID()] if !found || evidence.SubjectID != match.Subject.ID() || typeinfo.TypeText(evidence.EnumType) != "Result" || len(evidence.Arms) != 3 { t.Fatalf("match evidence = %#v", evidence) } - if evidence.Arms[0].Case != 0 || len(evidence.Arms[0].Fields) != 1 || evidence.Arms[0].Fields[0].Field != 0 || evidence.Arms[0].Fields[0].Binding == nil { + if evidence.CaseCount != 3 || evidence.Arms[0].Case != 0 || len(evidence.Arms[0].Bindings) != 1 || + evidence.Arms[0].Bindings[0].Projection != typecheckresult.MatchPayloadField || + evidence.Arms[0].Bindings[0].Field != 0 || evidence.Arms[0].Bindings[0].Binding == nil { t.Fatalf("Ok arm evidence = %#v", evidence.Arms[0]) } - if evidence.Arms[1].Case != 1 || !evidence.Arms[1].Fields[0].Discard || evidence.Arms[2].Case != 2 { + if evidence.Arms[1].Case != 1 || !evidence.Arms[1].Bindings[0].Discard || evidence.Arms[2].Case != 2 { t.Fatalf("remaining arm evidence = %#v", evidence.Arms[1:]) } } @@ -902,8 +1057,8 @@ func TestMutablePointerFieldDefaultsTypeToNoCopy(t *testing.T) { if !ok || typ == nil { t.Fatalf("missing Buffer type") } - if !typeinfo.IsNoCopyType(typ) { - t.Fatalf("Buffer should default to no-copy") + if got := typeinfo.OwnershipCapabilityOf(typ); got.Copy != typeinfo.CopyNever { + t.Fatalf("Buffer should default to no-copy, got %v", got.Copy) } } @@ -966,8 +1121,8 @@ func TestRawPointerFieldStructSupportsExplicitCopy(t *testing.T) { if !ok || typ == nil { t.Fatalf("missing View type") } - if typeinfo.IsImplicitCopyType(typ) || typeinfo.IsNoCopyType(typ) { - t.Fatalf("View should implicitly and support explicit copy") + if got := typeinfo.OwnershipCapabilityOf(typ); got.Copy != typeinfo.CopyExplicit || got.Drop { + t.Fatalf("View should support explicit copy without drop, got %v", got) } } @@ -1499,7 +1654,7 @@ func TestIndexExprRejectsFloatPostfixBeforeConstEvaluation(t *testing.T) { fn := module.AST.Stmts[0].(*ast.FnDecl) ret := fn.Body.Stmts[0].(*ast.ReturnStmt) index := ret.Value.(*ast.IndexExpr) - if !typeinfo.IsInvalidOrUnknown(module.Semantics.ExprTypes[index.ID()]) { + if !typeinfo.IsInvalidOrUnknown(module.Typechecking.ExprTypes[index.ID()]) { t.Fatalf("index expression should have invalid semantic type") } } @@ -1549,7 +1704,7 @@ func TestArrayLiteralTypechecksExplicitLength(t *testing.T) { } fn := module.AST.Stmts[0].(*ast.FnDecl) letDecl := fn.Body.Stmts[0].(*ast.LetDecl) - got := module.Semantics.ExprTypes[letDecl.Value.ID()] + got := module.Typechecking.ExprTypes[letDecl.Value.ID()] if typeinfo.TypeText(got) != "[3]i32" { t.Fatalf("array literal type = %s, want [3]i32", typeinfo.TypeText(got)) } @@ -1565,7 +1720,7 @@ func TestArrayLiteralTypechecksInferredLength(t *testing.T) { } fn := module.AST.Stmts[0].(*ast.FnDecl) letDecl := fn.Body.Stmts[0].(*ast.LetDecl) - got := module.Semantics.ExprTypes[letDecl.Value.ID()] + got := module.Typechecking.ExprTypes[letDecl.Value.ID()] if typeinfo.TypeText(got) != "[3]i32" { t.Fatalf("array literal type = %s, want [3]i32", typeinfo.TypeText(got)) } @@ -1581,7 +1736,7 @@ func TestArrayLiteralTypechecksDynamicArray(t *testing.T) { } fn := module.AST.Stmts[0].(*ast.FnDecl) letDecl := fn.Body.Stmts[0].(*ast.LetDecl) - got := module.Semantics.ExprTypes[letDecl.Value.ID()] + got := module.Typechecking.ExprTypes[letDecl.Value.ID()] if typeinfo.TypeText(got) != "[]i32" { t.Fatalf("array literal type = %s, want []i32", typeinfo.TypeText(got)) } @@ -1608,9 +1763,9 @@ func TestDynamicArrayOwnerOperationsTypecheck(t *testing.T) { } for i, stmt := range fn.Body.Stmts[1:] { call := stmt.(*ast.ExprStmt).Expr.(*ast.CallExpr) - fnType, ok := module.Semantics.ExprTypes[call.Callee.ID()].(*typeinfo.FuncType) + fnType, ok := module.Typechecking.ExprTypes[call.Callee.ID()].(*typeinfo.FuncType) if !ok { - t.Fatalf("operation %d callee type = %#v, want function", i, module.Semantics.ExprTypes[call.Callee.ID()]) + t.Fatalf("operation %d callee type = %#v, want function", i, module.Typechecking.ExprTypes[call.Callee.ID()]) } if fnType.Return != nil { t.Fatalf("operation %d return = %s, want void", i, typeinfo.TypeText(fnType.Return)) @@ -1733,7 +1888,7 @@ func TestAllocTypecheck(t *testing.T) { } fn := module.AST.Stmts[0].(*ast.FnDecl) letDecl := fn.Body.Stmts[1].(*ast.LetDecl) - if got := typeinfo.TypeText(module.Semantics.ExprTypes[letDecl.Value.ID()]); got != "*i32" { + if got := typeinfo.TypeText(module.Typechecking.ExprTypes[letDecl.Value.ID()]); got != "*i32" { t.Fatalf("alloc type = %s, want *i32", got) } } @@ -1785,7 +1940,7 @@ func TestArrayLiteralRejectsFloatPostfixLengthBeforeElementChecks(t *testing.T) } fn := module.AST.Stmts[0].(*ast.FnDecl) letDecl := fn.Body.Stmts[0].(*ast.LetDecl) - if !typeinfo.IsInvalidOrUnknown(module.Semantics.ExprTypes[letDecl.Value.ID()]) { + if !typeinfo.IsInvalidOrUnknown(module.Typechecking.ExprTypes[letDecl.Value.ID()]) { t.Fatalf("array literal should have invalid semantic type") } } @@ -2869,11 +3024,11 @@ func TestIntrinsicFunctionResolutionStoredForLaterPhases(t *testing.T) { if callee == nil || callee.Name != "len" { t.Fatal("len function missing from parsed module") } - resolved := module.Semantics.ResolvedSymbols[callee.ID()] + resolved := module.Bindings.NodeSymbols[callee.ID()] if resolved == nil || resolved.CompilerOp != symbols.CompilerOpLen { t.Fatalf("resolved function = %#v, want len intrinsic", resolved) } - evidence, ok := module.Semantics.CompilerCalls[call.ID()] + evidence, ok := module.Typechecking.CompilerCalls[call.ID()] if !ok || evidence.Operation != symbols.CompilerOpLen || evidence.Kind != intrinsics.FunctionCollection { t.Fatalf("compiler call evidence = %#v, want collection len", evidence) } @@ -2916,13 +3071,12 @@ fn main() { if conversion == nil { t.Fatal("reader interface conversion missing from parsed module") } - implementations := module.Semantics.InterfaceImplementations[conversion.ID()] + implementations := module.Typechecking.InterfaceImplementations[conversion.ID()] if len(implementations) != 1 { t.Fatalf("implementation evidence = %#v, want one method", implementations) } implementation := implementations[0] - if implementation.MethodName != "read" || implementation.Symbol == nil || - implementation.CallableType == nil || implementation.OwnerKey != "Counter" { + if implementation.Symbol == nil || implementation.Symbol.Name != "read" || implementation.CallableType == nil { t.Fatalf("implementation evidence = %#v, want exact Counter.read symbol and type", implementation) } } @@ -2947,7 +3101,7 @@ fn main() -> i32 { for _, stmt := range module.AST.Stmts { ast.Inspect(stmt, func(node ast.Node) bool { candidate, ok := node.(*ast.CallExpr) - if ok && len(candidate.Args) == 3 { + if ok && ast.ExprText(candidate.Callee) == "use" { call = candidate } return call == nil @@ -2957,14 +3111,22 @@ fn main() -> i32 { } } if call == nil { - t.Fatal("expanded use call not found") + t.Fatal("use call not found") + } + if len(call.Args) != 1 { + t.Fatalf("source argument count = %d, want 1", len(call.Args)) + } + effectiveArgs := module.Typechecking.EffectiveCallArguments[call.ID()] + if len(effectiveArgs) != 3 { + t.Fatalf("effective argument count = %d, want 3", len(effectiveArgs)) } - first := module.Semantics.InterfaceImplementations[call.Args[1].ID()] - second := module.Semantics.InterfaceImplementations[call.Args[2].ID()] - if call.Args[1].ID() == call.Args[2].ID() || len(first) != 1 || len(second) != 1 { - t.Fatalf("default evidence IDs/evidence = %d:%#v %d:%#v", call.Args[1].ID(), first, call.Args[2].ID(), second) + first := module.Typechecking.InterfaceImplementations[effectiveArgs[1].ID()] + second := module.Typechecking.InterfaceImplementations[effectiveArgs[2].ID()] + if effectiveArgs[1].ID() == effectiveArgs[2].ID() || len(first) != 1 || len(second) != 1 { + t.Fatalf("default evidence IDs/evidence = %d:%#v %d:%#v", effectiveArgs[1].ID(), first, effectiveArgs[2].ID(), second) } - if first[0].MethodName != "read_a" || second[0].MethodName != "read_b" { + if first[0].Symbol == nil || second[0].Symbol == nil || + first[0].Symbol.Name != "read_a" || second[0].Symbol.Name != "read_b" { t.Fatalf("default evidence overwritten: %#v %#v", first, second) } } @@ -3001,7 +3163,7 @@ fn valid() -> i32 { func TestCanAdaptFirstCallArgumentUsesCallConversionRules(t *testing.T) { ctx := project.New(".", peeper.SourceExt, diagnostics.NewDiagnosticBag()) - module := &project.Module{Semantics: project.NewSemanticInfo()} + module := &project.Module{} element, ok := typeinfo.NumericTypeFromName("i32", ctx.Target) if !ok { t.Fatal("missing i32 type") diff --git a/internal/semantics/typecheckresult/result.go b/internal/semantics/typecheckresult/result.go new file mode 100644 index 00000000..1e68d7d1 --- /dev/null +++ b/internal/semantics/typecheckresult/result.go @@ -0,0 +1,299 @@ +// Package typecheckresult defines semantic evidence produced by base typechecking. +package typecheckresult + +import ( + "compiler/internal/frontend/ast" + "compiler/internal/semantics/intrinsics" + "compiler/internal/semantics/symbols" + "compiler/internal/semantics/typeinfo" +) + +// InterfaceImplementation proves that one declared method materializes an interface slot. +type InterfaceImplementation struct { + Symbol *symbols.Symbol + CallableType *typeinfo.FuncType +} + +type CaseTest struct { + SubjectID ast.NodeID + Case int + CaseWhenTrue bool + CaseCount int + Family typeinfo.VariantFamily +} + +// Match records typechecker-owned case and binding evidence consumed by CFG +// and later semantic phases without resolving source paths again. +type Match struct { + SubjectID ast.NodeID + EnumType typeinfo.Type + CaseCount int + Arms []MatchArm +} + +type MatchProjection uint8 + +const ( + MatchProjectionInvalid MatchProjection = iota + MatchPayloadField + MatchWholePayload +) + +type MatchArm struct { + ArmID ast.NodeID + BodyID ast.NodeID + Case int + Payload typeinfo.Type + Bindings []MatchBinding + // CarrierUse is the published use kind applied to the match subject + // carrier when this arm is selected: UseMove when the arm binds any + // move-only payload part, UseRead when everything binds by copy. + CarrierUse typeinfo.UseKind +} + +type MatchBinding struct { + Projection MatchProjection + Field int + Type typeinfo.Type + Binding *symbols.Symbol + Discard bool +} + +func (m Match) Arm(caseIndex int) (MatchArm, bool) { + for _, arm := range m.Arms { + if arm.Case == caseIndex { + return arm, true + } + } + return MatchArm{}, false +} + +// ForIteration records typechecker-owned loop lowering and CFG evidence. +// Generated symbols carry hidden loop state; source bindings remain body-scoped. +// +// Evidence is published only for a loop that typed cleanly, so a record that +// exists is complete: Cursor, Value and ElementType are always set, and Plan +// carries exactly one iteration kind. Consumers switch on the plan and read its +// fields; they must not re-check them, because a nil there would be a compiler +// bug rather than a shape the source can produce. +type ForIteration struct { + GuaranteedEntry bool + + ElementType typeinfo.Type + Cursor *symbols.Symbol + Value *symbols.Symbol + // Index is nil unless the source binds an index name. + Index *symbols.Symbol + + // Plan carries the iteration kind and that kind's state as one value. + // There is no separate kind tag to disagree with the state, and no way to + // hold both kinds at once. + Plan IterationPlan +} + +// IterationPlan is the state one iteration kind needs. The interface is closed +// by its unexported method: only the two plans below implement it, so a +// consumer's type switch over both is exhaustive and a third kind cannot be +// introduced outside this package. +type IterationPlan interface { + iterationPlan() +} + +// RangeIteration is the state of a `for i in a..b` loop. Limit holds the +// evaluated exclusive upper bound; Ordinal counts iterations and is non-nil +// exactly when ForIteration.Index is. +type RangeIteration struct { + Limit *symbols.Symbol + Ordinal *symbols.Symbol +} + +func (*RangeIteration) iterationPlan() {} + +// SequenceIteration is the state of a `for v in seq` loop. Carrier holds the +// iterated storage for the loop's lifetime, borrowed when CarrierType is a +// reference; the cursor indexes through it. +type SequenceIteration struct { + Carrier *symbols.Symbol + CarrierType typeinfo.Type +} + +func (*SequenceIteration) iterationPlan() {} + +// VariantConstruction records resolved enum construction without later path or field resolution. +type VariantConstruction struct { + EnumType typeinfo.Type + Case int + Payload typeinfo.Type + Value ast.Expr +} + +// CompilerCall records intrinsic dispatch selected by typechecking. +type CompilerCall struct { + Operation symbols.CompilerOp + Kind intrinsics.FunctionKind +} + +// Result owns base semantic evidence for one typecheck generation. +type Result struct { + ExpandedDefaultBindings map[ast.NodeID]struct{} + EffectiveCallArguments map[ast.NodeID][]ast.Expr + InterfaceImplementations map[ast.NodeID][]InterfaceImplementation + ImplicitConversions map[ast.NodeID]typeinfo.Conversion + ImplicitCallArguments map[ast.NodeID]typeinfo.Type + CompilerCalls map[ast.NodeID]CompilerCall + StringConcatenations map[ast.NodeID]struct{} + VariantConstructions map[ast.NodeID]VariantConstruction + CaseTests map[ast.NodeID]CaseTest + Matches map[ast.NodeID]Match + ForIterations map[ast.NodeID]ForIteration + 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. + ValueUses map[ast.NodeID]typeinfo.UseKind + // ReferenceArguments records an argument whose parameter is a reference. + // Presence is the fact; the value reports whether that reference is mutable, + // which separates a shared borrow from a mutable reservation. + // + // The borrow follows from the parameter type, so it is invisible in the + // argument: passing a reference-typed value to a reference parameter writes + // no ampersand and produces no address expression. + ReferenceArguments map[ast.NodeID]bool +} + +func New() *Result { + return &Result{ + ExpandedDefaultBindings: make(map[ast.NodeID]struct{}), + EffectiveCallArguments: make(map[ast.NodeID][]ast.Expr), + InterfaceImplementations: make(map[ast.NodeID][]InterfaceImplementation), + ImplicitConversions: make(map[ast.NodeID]typeinfo.Conversion), + ImplicitCallArguments: make(map[ast.NodeID]typeinfo.Type), + CompilerCalls: make(map[ast.NodeID]CompilerCall), + StringConcatenations: make(map[ast.NodeID]struct{}), + VariantConstructions: make(map[ast.NodeID]VariantConstruction), + CaseTests: make(map[ast.NodeID]CaseTest), + Matches: make(map[ast.NodeID]Match), + ForIterations: make(map[ast.NodeID]ForIteration), + ExprTypes: make(map[ast.NodeID]typeinfo.Type), + ValueUses: make(map[ast.NodeID]typeinfo.UseKind), + ReferenceArguments: make(map[ast.NodeID]bool), + } +} + +// MatchCases exposes resolved case indexes without leaking match artifacts +// into CFG's source-topology package. +func (r *Result) MatchCases(id ast.NodeID) ([]int, bool) { + if r == nil { + return nil, false + } + match, found := r.Matches[id] + if !found { + return nil, false + } + cases := make([]int, len(match.Arms)) + for index, arm := range match.Arms { + if arm.Case < 0 || arm.Case >= match.CaseCount { + return nil, false + } + cases[index] = arm.Case + } + return cases, true +} + +// StringConcatenation reports whether a binary expression was resolved as a +// string concatenation, which consumes its left operand. +func (r *Result) StringConcatenation(id ast.NodeID) bool { + if r == nil { + return false + } + _, found := r.StringConcatenations[id] + return found +} + +// ValueUse exposes the use kind the typechecker decided for one expression. +// Coverage is call arguments today, so an absent answer is normal rather than +// a missing decision. +func (r *Result) ValueUse(id ast.NodeID) (typeinfo.UseKind, bool) { + if r == nil { + return typeinfo.UseRead, false + } + kind, found := r.ValueUses[id] + return kind, found +} + +// ReferenceArgument reports whether an argument's parameter is a reference and, +// when it is, whether that reference is mutable. +func (r *Result) ReferenceArgument(id ast.NodeID) (mutable bool, found bool) { + if r == nil { + return false, false + } + mutable, found = r.ReferenceArguments[id] + return mutable, found +} + +// ArmBindings exposes the payload symbols one match arm binds, without leaking +// match artifacts into the effect producer. A discarded binding still binds +// storage, so it is reported like any other. +func (r *Result) ArmBindings(match ast.NodeID, caseIndex int) []*symbols.Symbol { + if r == nil { + return nil + } + evidence, found := r.Matches[match] + if !found { + return nil + } + arm, found := evidence.Arm(caseIndex) + if !found { + return nil + } + bound := make([]*symbols.Symbol, 0, len(arm.Bindings)) + for _, binding := range arm.Bindings { + if binding.Binding != nil { + bound = append(bound, binding.Binding) + } + } + return bound +} + +// ForLoopGuaranteedEntry exposes typechecker proof that one loop executes its +// body before its first condition check. +func (r *Result) ForLoopGuaranteedEntry(id ast.NodeID) bool { + if r == nil { + return false + } + iteration, found := r.ForIterations[id] + return found && iteration.GuaranteedEntry +} + +// SequenceCarrier exposes the hidden carrier a typed sequence loop keeps for +// the loop lifetime. Range loops have no carrier. Consumers ask this query +// instead of inspecting the concrete iteration plan themselves. +func (r *Result) SequenceCarrier(id ast.NodeID) (*symbols.Symbol, bool) { + if r == nil { + return nil, false + } + iteration, found := r.ForIterations[id] + if !found { + return nil, false + } + sequence, ok := iteration.Plan.(*SequenceIteration) + if !ok || sequence == nil || sequence.Carrier == nil { + return nil, false + } + return sequence.Carrier, true +} + +// CallArgumentsOrSource returns published effective arguments when available. +// Semantic phases that continue after diagnostics use source arguments when +// typechecking could not publish complete call evidence. +func (r *Result) CallArgumentsOrSource(call *ast.CallExpr) []ast.Expr { + if call == nil { + return nil + } + if r != nil { + if args, found := r.EffectiveCallArguments[call.ID()]; found { + return args + } + } + return call.Args +} diff --git a/internal/semantics/typeinfo/capabilities.go b/internal/semantics/typeinfo/capabilities.go index adb0d16f..e0ad86b4 100644 --- a/internal/semantics/typeinfo/capabilities.go +++ b/internal/semantics/typeinfo/capabilities.go @@ -88,52 +88,24 @@ func IsCondition(t Type) bool { return ok } -func IsImplicitCopyType(t Type) bool { - visiting := make(map[*DefinedType]bool) - var check func(Type, bool) bool - check = func(current Type, enumPayload bool) bool { - if defined, ok := current.(*DefinedType); ok { - if defined == nil || visiting[defined] { - return false - } - visiting[defined] = true - defer delete(visiting, defined) - return check(defined.Underlying, enumPayload) - } - switch typ := Underlying(current).(type) { - case *IntegerType, *ByteType, *CharType, *FloatType, *BoolType, *CStrType, *RawPtrType, *AllocatorType: - return true - case *RefType: - return typ != nil && !typ.Mutable - case *OptionalType: - return typ != nil && check(typ.Inner, false) - case *StructType: - if typ == nil || !enumPayload { - return false - } - for _, field := range typ.Fields { - if !check(field.Type, false) { - return false - } - } - return true - case *EnumType: - if typ == nil { - return false - } - for _, variant := range typ.Cases { - if variant.Payload != nil && !check(variant.Payload, true) { - return false - } - } - return true - default: - return false - } - } - return check(t, false) -} - +// IsSizedType and IsLowerableType are deliberately separate walkers, and a +// consolidation pass should leave them that way. +// +// They look alike — both recurse over the same structure with a cycle guard — +// but they share neither the guard nor its meaning. Sized keys its guard on +// *DefinedType and answers false on a cycle, because a type that contains +// itself inline has no size. Lowerable keys on the underlying type and answers +// whatever the recursion reached it through, because a self-referential type +// *is* representable when the cycle passes through a pointer. A linked list is +// lowerable and not sized, and one traversal cannot hold both answers without +// carrying two guards. +// +// They also disagree on ordinary types for real reasons: an interface has no +// inline size but does lower, and a type parameter is sized before +// instantiation but has nothing to emit. +// +// Copy and drop were merged into one traversal because they genuinely share a +// walk and a cycle rule. These do not. func IsSizedType(t Type) bool { visiting := make(map[*DefinedType]bool) var check func(Type) bool @@ -147,7 +119,12 @@ func IsSizedType(t Type) bool { } visiting[defined] = true defer delete(visiting, defined) - return check(defined.Underlying) + result := false + ForEachChild(defined, func(child TypeChild) bool { + result = check(child.Type) + return false + }) + return result } switch typ := Underlying(current).(type) { case *InvalidType, *UnknownType, *InterfaceType: @@ -161,7 +138,15 @@ func IsSizedType(t Type) bool { case *RawPtrType: return typ != nil case *OptionalType: - return typ != nil && check(typ.Inner) + if typ == nil { + return false + } + result := false + ForEachChild(typ, func(child TypeChild) bool { + result = check(child.Type) + return false + }) + return result case *ArrayType: if typ == nil || typ.Elem == nil { return false @@ -169,37 +154,42 @@ func IsSizedType(t Type) bool { if typ.Shape == ArraySlice { return false } - return check(typ.Elem) + result := false + ForEachChild(typ, func(child TypeChild) bool { + result = check(child.Type) + return false + }) + return result case *StructType: if typ == nil { return false } - for _, field := range typ.Fields { - if !check(field.Type) { - return false - } - } - return true + allSized := true + ForEachChild(typ, func(child TypeChild) bool { + allSized = check(child.Type) + return allSized + }) + return allSized case *EnumType: if typ == nil { return false } - for _, variant := range typ.Cases { - if variant.Payload != nil && !check(variant.Payload) { - return false - } - } - return true + allSized := true + ForEachChild(typ, func(child TypeChild) bool { + allSized = check(child.Type) + return allSized + }) + return allSized case *FuncType: if typ == nil { return false } - for _, param := range typ.Params { - if !check(param) { - return false - } - } - return typ.Return == nil || check(typ.Return) + allSized := true + ForEachChild(typ, func(child TypeChild) bool { + allSized = check(child.Type) + return allSized + }) + return allSized default: return false } @@ -207,51 +197,6 @@ func IsSizedType(t Type) bool { return check(t) } -func IsNoCopyType(t Type) bool { - seen := make(map[*DefinedType]bool) - var check func(Type) bool - check = func(current Type) bool { - switch typ := current.(type) { - case *DefinedType: - if typ == nil || seen[typ] { - return false - } - seen[typ] = true - return check(typ.Underlying) - } - switch typ := Underlying(current).(type) { - case *OwnedPtrType, *StringType, *InterfaceType: - return true - case *RefType: - return typ != nil && typ.Mutable - case *OptionalType: - return typ != nil && check(typ.Inner) - case *ArrayType: - return typ != nil && (typ.Shape == ArrayOwner || check(typ.Elem)) - case *StructType: - if typ == nil { - return false - } - for _, field := range typ.Fields { - if check(field.Type) { - return true - } - } - case *EnumType: - if typ == nil { - return false - } - for _, variant := range typ.Cases { - if variant.Payload != nil && check(variant.Payload) { - return true - } - } - } - return false - } - return check(t) -} - // IsLowerableType reports whether current backend lowering can represent type. // Owned pointers and safe references close recursive named composites without expanding storage; // abstract Self parameters remain semantic-only interface metadata. @@ -289,19 +234,35 @@ func IsLowerableType(t Type) bool { } return check(typ.Target, true) case *OptionalType: - return typ != nil && typ.Inner != nil && check(typ.Inner, throughIndirection) + if typ == nil || typ.Inner == nil { + return false + } + result := false + ForEachChild(typ, func(child TypeChild) bool { + result = check(child.Type, throughIndirection) + return false + }) + return result case *ArrayType: - return typ != nil && typ.Shape != ArraySlice && (typ.Shape == ArrayOwner || typ.Len != "") && typ.Elem != nil && check(typ.Elem, throughIndirection) + if typ == nil || typ.Shape == ArraySlice || typ.Shape != ArrayOwner && typ.Len == "" || typ.Elem == nil { + return false + } + result := false + ForEachChild(typ, func(child TypeChild) bool { + result = check(child.Type, throughIndirection) + return false + }) + return result case *StructType: if typ == nil { return false } - for _, field := range typ.Fields { - if !check(field.Type, throughIndirection) { - return false - } - } - return true + allLowerable := true + ForEachChild(typ, func(child TypeChild) bool { + allLowerable = check(child.Type, throughIndirection) + return allLowerable + }) + return allLowerable case *InterfaceType: if typ == nil { return false @@ -310,39 +271,36 @@ func IsLowerableType(t Type) bool { if len(method.Params) == 0 { return false } - for i, param := range method.Params { - if i == 0 { - continue - } - if ContainsAbstractSelf(param.Type) || !check(param.Type, throughIndirection) { - return false - } - } - if method.Return != nil && (ContainsAbstractSelf(method.Return) || !check(method.Return, throughIndirection)) { - return false - } } - return true + allLowerable := true + ForEachChild(typ, func(child TypeChild) bool { + if child.Relation == TypeChildMethodReceiver { + return true + } + allLowerable = !ContainsAbstractSelf(child.Type) && check(child.Type, throughIndirection) + return allLowerable + }) + return allLowerable case *FuncType: if typ == nil { return false } - for _, param := range typ.Params { - if !check(param, throughIndirection) { - return false - } - } - return typ.Return == nil || check(typ.Return, throughIndirection) + allLowerable := true + ForEachChild(typ, func(child TypeChild) bool { + allLowerable = check(child.Type, throughIndirection) + return allLowerable + }) + return allLowerable case *EnumType: if typ == nil { return false } - for _, variant := range typ.Cases { - if variant.Payload != nil && !check(variant.Payload, throughIndirection) { - return false - } - } - return true + allLowerable := true + ForEachChild(typ, func(child TypeChild) bool { + allLowerable = check(child.Type, throughIndirection) + return allLowerable + }) + return allLowerable default: return false } @@ -350,49 +308,51 @@ func IsLowerableType(t Type) bool { return check(t, false) } -// NeedsDrop reports whether normal scope cleanup must destroy runtime-owned -// state reachable through a value. Move-only borrows and plain composites do -// not need destruction; this is intentionally narrower than IsNoCopyType. -func NeedsDrop(t Type) bool { - seen := make(map[*DefinedType]bool) - var check func(Type) bool - check = func(current Type) bool { - switch typ := current.(type) { - case *DefinedType: - if typ == nil || seen[typ] { - return false - } - seen[typ] = true - defer delete(seen, typ) - return check(typ.Underlying) - } - switch typ := Underlying(current).(type) { - case *OwnedPtrType, *StringType: - return true - case *OptionalType: - return typ != nil && check(typ.Inner) - case *ArrayType: - return typ != nil && (typ.Shape == ArrayOwner || check(typ.Elem)) - case *StructType: - if typ == nil { - return false - } - for _, field := range typ.Fields { - if check(field.Type) { - return true - } - } - case *EnumType: - if typ == nil { - return false - } - for _, variant := range typ.Cases { - if variant.Payload != nil && check(variant.Payload) { - return true - } - } - } - return false - } - return check(t) +// CopyClass classifies how a value of a type may be duplicated. +type CopyClass uint8 + +const ( + // CopyImplicit: a read use copies the value; it is never moved. + CopyImplicit CopyClass = iota + // CopyExplicit: a plain use moves the value; an explicit copy + // operation exists for types that want one. + CopyExplicit + // CopyNever: a plain use moves the value; no copy operation exists. + CopyNever +) + +// OwnershipCapability is the canonical classification: how a type duplicates +// (Copy) and whether scope cleanup must destroy it (Drop). One traversal +// answers both, so the two cannot drift apart. +// +// Deliberate asymmetries preserved from the current language semantics: +// - top-level structs and arrays never copy implicitly (bulk storage), +// while enum payloads of copyable fields do; +// - Interface values never copy implicitly but do not yet require source- +// level drop (owned-interface drop activation is tracked separately); +// - TypeParameterType is conservatively move-on-use until instantiation- +// aware capability queries arrive with generic support. +type OwnershipCapability struct { + Copy CopyClass + Drop bool } + +// OwnershipCapabilityOf classifies a type's ownership behavior in one traversal. +func OwnershipCapabilityOf(t Type) OwnershipCapability { + return ownershipCapability(t) +} + +// UseKind is the ownership classification of one value use: what happens to +// the value at a specific expression, as decided by the typechecker and +// consumed by ownership and lowering. It is the per-use counterpart of +// OwnershipCapability: the capability constrains which use kinds are legal. +type UseKind uint8 + +const ( + // UseRead: the value is observed; its owner keeps it. + UseRead UseKind = iota + // UseCopy: the value is duplicated; the source keeps it. + UseCopy + // UseMove: the value is consumed; the source is dead afterwards. + UseMove +) diff --git a/internal/semantics/typeinfo/capability_walk.go b/internal/semantics/typeinfo/capability_walk.go new file mode 100644 index 00000000..c7f2a196 --- /dev/null +++ b/internal/semantics/typeinfo/capability_walk.go @@ -0,0 +1,200 @@ +package typeinfo + +import "compiler/pkg/typednil" + +// ownershipShapeKind describes how a semantic type contributes copy/drop +// behavior. It is part of the sealed Type contract: a new type must declare +// both its structural children and how ownership composes across them. +type ownershipShapeKind uint8 + +const ( + ownershipLeaf ownershipShapeKind = iota + ownershipAlias + ownershipOptional + ownershipBulk + ownershipStruct + ownershipEnum +) + +type ownershipShape struct { + kind ownershipShapeKind + facts capabilityFacts +} + +func leafOwnership(implicitCopy, noCopy, drop bool) ownershipShape { + return ownershipShape{kind: ownershipLeaf, facts: capabilityFacts{ + implicitCopy: implicitCopy, + noCopy: noCopy, + drop: drop, + }} +} + +func (*InvalidType) ownershipShape() ownershipShape { return leafOwnership(false, false, false) } +func (*UnknownType) ownershipShape() ownershipShape { return leafOwnership(false, false, false) } +func (*IntegerType) ownershipShape() ownershipShape { return leafOwnership(true, false, false) } +func (*ByteType) ownershipShape() ownershipShape { return leafOwnership(true, false, false) } +func (*CharType) ownershipShape() ownershipShape { return leafOwnership(true, false, false) } +func (*FloatType) ownershipShape() ownershipShape { return leafOwnership(true, false, false) } +func (*BoolType) ownershipShape() ownershipShape { return leafOwnership(true, false, false) } +func (*CStrType) ownershipShape() ownershipShape { return leafOwnership(true, false, false) } +func (*StringType) ownershipShape() ownershipShape { return leafOwnership(false, true, true) } +func (*NoneType) ownershipShape() ownershipShape { return leafOwnership(true, false, false) } +func (*AllocatorType) ownershipShape() ownershipShape { return leafOwnership(true, false, false) } +func (*NamedType) ownershipShape() ownershipShape { return leafOwnership(false, false, false) } +func (*TypeParameterType) ownershipShape() ownershipShape { return leafOwnership(false, false, false) } +func (*RawPtrType) ownershipShape() ownershipShape { return leafOwnership(true, false, false) } +func (*FuncType) ownershipShape() ownershipShape { return leafOwnership(false, false, false) } + +func (*DefinedType) ownershipShape() ownershipShape { + return ownershipShape{kind: ownershipAlias} +} + +// An owned pointer owns its allocation as one value. The target remains a +// structural child for type queries, but copy/drop do not recursively inherit +// from the pointee because destroying the pointer is already the ownership act. +func (*OwnedPtrType) ownershipShape() ownershipShape { return leafOwnership(false, true, true) } + +func (t *RefType) ownershipShape() ownershipShape { + if t == nil { + return leafOwnership(false, false, false) + } + return leafOwnership(!t.Mutable, t.Mutable, false) +} + +func (*OptionalType) ownershipShape() ownershipShape { + return ownershipShape{kind: ownershipOptional} +} + +func (t *ArrayType) ownershipShape() ownershipShape { + if t != nil && t.Shape == ArrayOwner { + return leafOwnership(false, true, true) + } + return ownershipShape{kind: ownershipBulk} +} + +func (*StructType) ownershipShape() ownershipShape { + return ownershipShape{kind: ownershipStruct} +} + +func (*InterfaceType) ownershipShape() ownershipShape { + // Owned-interface drop activation is tracked separately by ownership. + return leafOwnership(false, true, false) +} + +func (*EnumType) ownershipShape() ownershipShape { + return ownershipShape{kind: ownershipEnum} +} + +// ownershipCapability answers copy class and drop obligation in one generic +// traversal. Type implementations declare structure and composition locally; +// the walker never names a concrete Type kind. +func ownershipCapability(t Type) OwnershipCapability { + visiting := make(map[Type]bool) + + var walk func(Type, bool) capabilityFacts + walk = func(current Type, enumPayload bool) capabilityFacts { + if current == nil || typednil.IsNil(current) || visiting[current] { + return capabilityFacts{} + } + shape := current.ownershipShape() + if shape.kind == ownershipLeaf { + return shape.facts + } + + visiting[current] = true + defer delete(visiting, current) + + switch shape.kind { + case ownershipAlias: + facts := capabilityFacts{} + ForEachChild(current, func(child TypeChild) bool { + if ownsChild(child.Relation) { + facts = walk(child.Type, enumPayload) + return false + } + return true + }) + return facts + case ownershipOptional: + facts := capabilityFacts{} + ForEachChild(current, func(child TypeChild) bool { + if ownsChild(child.Relation) { + // Optional payloads are ordinary value storage; being nested in an + // enum does not turn bulk payload storage into implicit-copy data. + facts = walk(child.Type, false) + return false + } + return true + }) + return facts + case ownershipBulk: + facts := capabilityFacts{} + ForEachChild(current, func(child TypeChild) bool { + if ownsChild(child.Relation) { + inner := walk(child.Type, false) + // Fixed/slice array values never copy implicitly as bulk storage, + // but a non-copy/drop element still propagates through the value. + facts.noCopy = inner.noCopy + facts.drop = inner.drop + return false + } + return true + }) + return facts + case ownershipStruct: + facts := capabilityFacts{implicitCopy: enumPayload} + ForEachChild(current, func(child TypeChild) bool { + if !ownsChild(child.Relation) { + return true + } + inner := walk(child.Type, false) + facts.implicitCopy = facts.implicitCopy && inner.implicitCopy + facts.noCopy = facts.noCopy || inner.noCopy + facts.drop = facts.drop || inner.drop + return true + }) + return facts + case ownershipEnum: + facts := capabilityFacts{implicitCopy: true} + ForEachChild(current, func(child TypeChild) bool { + if !ownsChild(child.Relation) { + return true + } + inner := walk(child.Type, true) + facts.implicitCopy = facts.implicitCopy && inner.implicitCopy + facts.noCopy = facts.noCopy || inner.noCopy + facts.drop = facts.drop || inner.drop + return true + }) + return facts + default: + return capabilityFacts{} + } + } + + facts := walk(t, false) + switch { + case facts.implicitCopy: + return OwnershipCapability{Copy: CopyImplicit, Drop: facts.drop} + case facts.noCopy: + return OwnershipCapability{Copy: CopyNever, Drop: facts.drop} + default: + return OwnershipCapability{Copy: CopyExplicit, Drop: facts.drop} + } +} + +func ownsChild(relation TypeChildRelation) bool { + switch relation { + case TypeChildUnderlying, TypeChildOwnedTarget, TypeChildOptionalPayload, + TypeChildArrayElement, TypeChildStructField, TypeChildEnumPayload: + return true + default: + return false + } +} + +type capabilityFacts struct { + implicitCopy bool + noCopy bool + drop bool +} diff --git a/internal/semantics/typeinfo/capability_walk_test.go b/internal/semantics/typeinfo/capability_walk_test.go new file mode 100644 index 00000000..a84523ef --- /dev/null +++ b/internal/semantics/typeinfo/capability_walk_test.go @@ -0,0 +1,136 @@ +package typeinfo + +import ( + "strings" + "testing" +) + +// leafTypes covers every base case the three ownership predicates distinguish. +func leafTypes() []Type { + return []Type{ + &IntegerType{}, &ByteType{}, &CharType{}, &FloatType{}, &BoolType{}, + &CStrType{}, &RawPtrType{}, &AllocatorType{}, &NoneType{}, + &StringType{}, &InterfaceType{}, + &OwnedPtrType{Target: &IntegerType{}}, + &RefType{Target: &IntegerType{}}, + &RefType{Target: &IntegerType{}, Mutable: true}, + &TypeParameterType{Name: "T"}, + &InvalidType{}, + &UnknownType{}, + } +} + +// wrap produces every one-level composite around a type, so the matrix reaches +// the recursive branches of all three predicates. +func wrap(inner Type) []Type { + return []Type{ + &OptionalType{Inner: inner}, + &ArrayType{Shape: ArrayOwner, Elem: inner}, + &ArrayType{Shape: ArraySlice, Elem: inner}, + &StructType{Fields: []Field{{Name: "a", Type: inner}}}, + &StructType{Fields: []Field{{Name: "a", Type: &IntegerType{}}, {Name: "b", Type: inner}}}, + &EnumType{Cases: []VariantCase{{Name: "None"}, {Name: "Some", Payload: inner}}}, + &DefinedType{Name: "Named", Underlying: inner}, + } +} + +// capabilityMatrix builds leaves, one-level and two-level composites. +func capabilityMatrix() []Type { + matrix := leafTypes() + for _, leaf := range leafTypes() { + matrix = append(matrix, wrap(leaf)...) + } + for _, leaf := range []Type{&IntegerType{}, &StringType{}, &OwnedPtrType{Target: &IntegerType{}}} { + for _, once := range wrap(leaf) { + matrix = append(matrix, wrap(once)...) + } + } + return matrix +} + +// capabilityGolden records the answer for every type in capabilityMatrix, in +// matrix order, as a copy-class letter (i implicit, e explicit, n never) +// followed by a drop marker (+ or -). +// +// It was captured from the traversal at the point a differential test proved it +// agreed with IsImplicitCopyType, noCopyType and NeedsDrop on every entry. +// Those predicates are gone, so this table is what preserves their coverage. It +// records decided language behavior, not whatever the code happens to do now: a +// diff here means an ownership rule changed and wants a deliberate decision. +const capabilityGolden = "i-i-i-i-i-i-i-i-i-n+n-n+i-n-e-e-e-i-n+e-e-e-i-i-i-n+e-e-e-i-i-i-n+e-e-e-" + + "i-i-i-n+e-e-e-i-i-i-n+e-e-e-i-i-i-n+e-e-e-i-i-i-n+e-e-e-i-i-i-n+e-e-e-i-" + + "i-i-n+e-e-e-i-i-n+n+n+n+n+n+n+n-n+n-n-n-n-n-n+n+n+n+n+n+n+i-n+e-e-e-i-i-" + + "n-n+n-n-n-n-n-e-n+e-e-e-e-e-e-n+e-e-e-e-e-e-n+e-e-e-e-e-i-n+e-e-e-i-" + + "i-n+n+n+n+n+n+n+e-n+e-e-e-e-e-e-n+e-e-e-i-e-e-n+e-e-e-i-e-i-n+e-e-e-i-i-" + + "i-n+e-e-e-i-i-" + + "n+n+n+n+n+n+n+n+n+n+n+n+n+n+n+n+n+n+n+n+n+n+n+n+n+n+n+n+n+n+n+n+n+n+n+n+" + + "n+n+n+n+n+n+n+n+n+n+n+n+n+n+n+n+n+n+n+n+n+n+n+n+n+n+n+n+n+n+n+n+n+n+n+n+" + + "n+n+n+n+n+n+n+n+n+n+n+n+n+n+n+n+n+n+n+n+n+n+n+n+n+n+" + +func TestOwnershipCapabilityMatchesGolden(t *testing.T) { + matrix := capabilityMatrix() + if len(matrix)*2 != len(capabilityGolden) { + t.Fatalf("matrix has %d types but golden covers %d; regenerate deliberately", + len(matrix), len(capabilityGolden)/2) + } + var b strings.Builder + for _, typ := range matrix { + got := ownershipCapability(typ) + switch got.Copy { + case CopyImplicit: + b.WriteByte('i') + case CopyExplicit: + b.WriteByte('e') + case CopyNever: + b.WriteByte('n') + } + if got.Drop { + b.WriteByte('+') + } else { + b.WriteByte('-') + } + } + got := b.String() + if got == capabilityGolden { + return + } + for i, typ := range matrix { + want := capabilityGolden[i*2 : i*2+2] + if got[i*2:i*2+2] != want { + t.Errorf("type %d (%s): capability = %s, golden = %s", + i, TypeText(typ), got[i*2:i*2+2], want) + } + } +} + +// A type that contains itself must terminate and answer what the guarded +// predicates answer, rather than recursing forever. +func TestOwnershipCapabilityWalkTerminatesOnRecursiveType(t *testing.T) { + node := &DefinedType{Name: "Node"} + node.Underlying = &StructType{Fields: []Field{ + {Name: "next", Type: &OptionalType{Inner: node}}, + {Name: "value", Type: &IntegerType{}}, + }} + + // Bulk storage never copies implicitly, and the guard firing on the second + // visit is what makes the self-reference terminate without a drop claim. + want := OwnershipCapability{Copy: CopyExplicit} + if got := ownershipCapability(node); got != want { + t.Fatalf("recursive type: capability = %+v, want %+v", got, want) + } +} + +// A type reached twice down separate branches is not a cycle, so the guard must +// not suppress the second visit. +func TestOwnershipCapabilityWalkVisitsRepeatedTypeTwice(t *testing.T) { + owned := &DefinedType{Name: "Owned", Underlying: &OwnedPtrType{Target: &IntegerType{}}} + pair := &StructType{Fields: []Field{{Name: "a", Type: owned}, {Name: "b", Type: owned}}} + + got := ownershipCapability(pair) + if !got.Drop { + t.Fatalf("repeated owned field: walk = %+v, want a drop obligation", got) + } + if got.Copy != CopyNever { + t.Fatalf("repeated owned field: walk = %+v, want CopyNever", got) + } +} diff --git a/internal/semantics/typeinfo/relations.go b/internal/semantics/typeinfo/relations.go index 44624109..04fdd19f 100644 --- a/internal/semantics/typeinfo/relations.go +++ b/internal/semantics/typeinfo/relations.go @@ -290,68 +290,40 @@ func containsType(t Type, traversal typeTraversal, matches func(Type, bool) bool } seen[key] = struct{}{} - switch typ := current.(type) { - case *DefinedType: - return traversal.followDefined && typ != nil && visit(typ.Underlying, stored) - case *OwnedPtrType: - return typ != nil && visit(typ.Target, true) - case *RefType: - if traversal.referenceLeaf { - return false + matched := false + ForEachChild(current, func(child TypeChild) bool { + childStored, follow := traversedChildState(child.Relation, stored, traversal) + if !follow { + return true } - return typ != nil && visit(typ.Target, stored) - case *OptionalType: - return typ != nil && visit(typ.Inner, stored) - case *ArrayType: - return typ != nil && visit(typ.Elem, true) - case *FuncType: - if typ == nil || !traversal.followCallable { + if visit(child.Type, childStored) { + matched = true return false } - for _, param := range typ.Params { - if visit(param, false) { - return true - } - } - return visit(typ.Return, false) - case *StructType: - if typ == nil { - return false - } - for _, field := range typ.Fields { - if visit(field.Type, true) { - return true - } - } - case *EnumType: - if typ == nil { - return false - } - for _, variant := range typ.Cases { - if visit(variant.Payload, true) { - return true - } - } - case *InterfaceType: - if typ == nil || !traversal.followCallable { - return false - } - for _, method := range typ.Methods { - for _, param := range method.Params { - if visit(param.Type, false) { - return true - } - } - if visit(method.Return, false) { - return true - } - } - } - return false + return true + }) + return matched } return visit(t, false) } +func traversedChildState(relation TypeChildRelation, stored bool, traversal typeTraversal) (bool, bool) { + switch relation { + case TypeChildUnderlying: + return stored, traversal.followDefined + case TypeChildOwnedTarget, TypeChildArrayElement, TypeChildStructField, TypeChildEnumPayload: + return true, true + case TypeChildBorrowedTarget: + return stored, !traversal.referenceLeaf + case TypeChildOptionalPayload: + return stored, true + case TypeChildMethodReceiver, TypeChildCallableParameter, TypeChildCallableReturn: + return false, traversal.followCallable + default: + panic("typeinfo: unknown semantic type child relation") + } +} + func ReplaceAbstractSelf(t Type, ownerType Type) Type { switch typ := t.(type) { case *NamedType: diff --git a/internal/semantics/typeinfo/structure.go b/internal/semantics/typeinfo/structure.go new file mode 100644 index 00000000..9b606661 --- /dev/null +++ b/internal/semantics/typeinfo/structure.go @@ -0,0 +1,156 @@ +package typeinfo + +// TypeChildRelation describes why one semantic type contains another. The +// relation is structural evidence, not an analysis result: ownership, sizing, +// lowerability, substitution, and future queries may interpret the same child +// differently while sharing one canonical declaration of where that child is. +type TypeChildRelation uint8 + +const ( + TypeChildUnderlying TypeChildRelation = iota + TypeChildOwnedTarget + TypeChildBorrowedTarget + TypeChildOptionalPayload + TypeChildArrayElement + TypeChildStructField + TypeChildEnumPayload + TypeChildMethodReceiver + TypeChildCallableParameter + TypeChildCallableReturn +) + +// TypeChild is one immediate semantic-type edge. A new composite Type must +// expose its children here through Type.forEachChild; recursive consumers must +// not rediscover fields with their own type switches. +type TypeChild struct { + Type Type + Relation TypeChildRelation +} + +// ForEachChild visits the immediate semantic children of typ in source/semantic +// order. It returns false when yield asks traversal to stop. +// +// This is the semantic-type equivalent of ast.Node.forEachChild: it owns type +// structure once while leaving recursion policy to the consumer. Cycle rules +// deliberately stay with each analysis because sizedness, lowerability, and +// ownership do not assign the same meaning to recursive edges. +func ForEachChild(typ Type, yield func(TypeChild) bool) bool { + if typ == nil || yield == nil { + return true + } + return typ.forEachChild(yield) +} + +func (*InvalidType) forEachChild(func(TypeChild) bool) bool { return true } +func (*UnknownType) forEachChild(func(TypeChild) bool) bool { return true } +func (*IntegerType) forEachChild(func(TypeChild) bool) bool { return true } +func (*ByteType) forEachChild(func(TypeChild) bool) bool { return true } +func (*CharType) forEachChild(func(TypeChild) bool) bool { return true } +func (*FloatType) forEachChild(func(TypeChild) bool) bool { return true } +func (*BoolType) forEachChild(func(TypeChild) bool) bool { return true } +func (*CStrType) forEachChild(func(TypeChild) bool) bool { return true } +func (*StringType) forEachChild(func(TypeChild) bool) bool { return true } +func (*NoneType) forEachChild(func(TypeChild) bool) bool { return true } +func (*AllocatorType) forEachChild(func(TypeChild) bool) bool { return true } +func (*NamedType) forEachChild(func(TypeChild) bool) bool { return true } +func (*TypeParameterType) forEachChild(func(TypeChild) bool) bool { return true } +func (*RawPtrType) forEachChild(func(TypeChild) bool) bool { return true } + +func (t *DefinedType) forEachChild(yield func(TypeChild) bool) bool { + if t == nil { + return true + } + return yieldTypeChild(yield, t.Underlying, TypeChildUnderlying) +} + +func (t *OwnedPtrType) forEachChild(yield func(TypeChild) bool) bool { + if t == nil { + return true + } + return yieldTypeChild(yield, t.Target, TypeChildOwnedTarget) +} + +func (t *RefType) forEachChild(yield func(TypeChild) bool) bool { + if t == nil { + return true + } + return yieldTypeChild(yield, t.Target, TypeChildBorrowedTarget) +} + +func (t *OptionalType) forEachChild(yield func(TypeChild) bool) bool { + if t == nil { + return true + } + return yieldTypeChild(yield, t.Inner, TypeChildOptionalPayload) +} + +func (t *ArrayType) forEachChild(yield func(TypeChild) bool) bool { + if t == nil { + return true + } + return yieldTypeChild(yield, t.Elem, TypeChildArrayElement) +} + +func (t *FuncType) forEachChild(yield func(TypeChild) bool) bool { + if t == nil { + return true + } + for _, param := range t.Params { + if param != nil && !yield(TypeChild{Type: param, Relation: TypeChildCallableParameter}) { + return false + } + } + return t.Return == nil || yield(TypeChild{Type: t.Return, Relation: TypeChildCallableReturn}) +} + +func (t *StructType) forEachChild(yield func(TypeChild) bool) bool { + if t == nil { + return true + } + for _, field := range t.Fields { + if field.Type != nil && !yield(TypeChild{Type: field.Type, Relation: TypeChildStructField}) { + return false + } + } + return true +} + +func (t *InterfaceType) forEachChild(yield func(TypeChild) bool) bool { + if t == nil { + return true + } + for _, method := range t.Methods { + for index, param := range method.Params { + relation := TypeChildCallableParameter + if index == 0 { + relation = TypeChildMethodReceiver + } + if param.Type != nil && !yield(TypeChild{Type: param.Type, Relation: relation}) { + return false + } + } + if method.Return != nil && !yield(TypeChild{Type: method.Return, Relation: TypeChildCallableReturn}) { + return false + } + } + return true +} + +func (t *EnumType) forEachChild(yield func(TypeChild) bool) bool { + if t == nil { + return true + } + for _, variant := range t.Cases { + if variant.Payload != nil && !yield(TypeChild{Type: variant.Payload, Relation: TypeChildEnumPayload}) { + return false + } + } + return true +} + +func yieldTypeChild(yield func(TypeChild) bool, child Type, relation TypeChildRelation) bool { + if child == nil { + return true + } + return yield(TypeChild{Type: child, Relation: relation}) +} diff --git a/internal/semantics/typeinfo/structure_test.go b/internal/semantics/typeinfo/structure_test.go new file mode 100644 index 00000000..2dfa7e68 --- /dev/null +++ b/internal/semantics/typeinfo/structure_test.go @@ -0,0 +1,144 @@ +package typeinfo + +import ( + "reflect" + "testing" +) + +func TestForEachChildOwnsCompositeTypeStructure(t *testing.T) { + i32 := &IntegerType{Signed: true, Bits: 32} + text := &StringType{} + receiver := &RefType{Target: i32} + tests := []struct { + name string + typ Type + want []TypeChild + }{ + { + name: "defined", + typ: &DefinedType{Underlying: i32}, + want: []TypeChild{{Type: i32, Relation: TypeChildUnderlying}}, + }, + { + name: "owned target", + typ: &OwnedPtrType{Target: text}, + want: []TypeChild{{Type: text, Relation: TypeChildOwnedTarget}}, + }, + { + name: "borrowed target", + typ: &RefType{Target: text}, + want: []TypeChild{{Type: text, Relation: TypeChildBorrowedTarget}}, + }, + { + name: "optional payload", + typ: &OptionalType{Inner: text}, + want: []TypeChild{{Type: text, Relation: TypeChildOptionalPayload}}, + }, + { + name: "array element", + typ: &ArrayType{Len: "4", Elem: text}, + want: []TypeChild{{Type: text, Relation: TypeChildArrayElement}}, + }, + { + name: "struct fields", + typ: &StructType{Fields: []Field{{Name: "left", Type: i32}, {Name: "right", Type: text}}}, + want: []TypeChild{{Type: i32, Relation: TypeChildStructField}, {Type: text, Relation: TypeChildStructField}}, + }, + { + name: "enum payloads", + typ: &EnumType{Cases: []VariantCase{{Name: "Empty"}, {Name: "Value", Payload: text}}}, + want: []TypeChild{{Type: text, Relation: TypeChildEnumPayload}}, + }, + { + name: "function signature", + typ: &FuncType{Params: []Type{i32}, Return: text}, + want: []TypeChild{{Type: i32, Relation: TypeChildCallableParameter}, {Type: text, Relation: TypeChildCallableReturn}}, + }, + { + name: "interface methods", + typ: &InterfaceType{Methods: []Method{{ + Name: "read", + Params: []Field{{Name: "self", Type: receiver}}, + Return: text, + }}}, + want: []TypeChild{{Type: receiver, Relation: TypeChildMethodReceiver}, {Type: text, Relation: TypeChildCallableReturn}}, + }, + } + + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + var got []TypeChild + ForEachChild(test.typ, func(child TypeChild) bool { + got = append(got, child) + return true + }) + if !reflect.DeepEqual(got, test.want) { + t.Fatalf("ForEachChild(%T) = %#v, want %#v", test.typ, got, test.want) + } + }) + } +} + +func TestTypeStructureDrivesRecursiveContainment(t *testing.T) { + stored := &StructType{Fields: []Field{{Name: "borrow", Type: &RefType{Target: &IntegerType{Signed: true, Bits: 32}}}}} + wrapped := &OptionalType{Inner: &ArrayType{Len: "2", Elem: stored}} + + if !ContainsReference(wrapped) { + t.Fatal("nested structural type should contain a reference") + } + if !ContainsStoredReference(wrapped) { + t.Fatal("reference inside stored composite children should be stored") + } +} + +func TestLeafTypeTraversalCompletesWithoutYield(t *testing.T) { + for _, typ := range []Type{ + &InvalidType{}, &UnknownType{}, &IntegerType{}, &ByteType{}, &CharType{}, + &FloatType{}, &BoolType{}, &CStrType{}, &StringType{}, &NoneType{}, + &AllocatorType{}, &NamedType{}, &TypeParameterType{}, &RawPtrType{}, + } { + if !typ.forEachChild(func(TypeChild) bool { + t.Errorf("leaf %T yielded a child", typ) + return false + }) || !typ.forEachChild(nil) { + t.Errorf("leaf %T traversal did not complete", typ) + } + } +} + +func TestNilTypeTraversalAndOwnership(t *testing.T) { + for _, typ := range []Type{ + nil, (*InvalidType)(nil), (*UnknownType)(nil), (*IntegerType)(nil), + (*ByteType)(nil), (*CharType)(nil), (*FloatType)(nil), (*BoolType)(nil), + (*CStrType)(nil), (*StringType)(nil), (*NoneType)(nil), (*AllocatorType)(nil), + (*NamedType)(nil), (*TypeParameterType)(nil), (*RawPtrType)(nil), + (*DefinedType)(nil), (*OwnedPtrType)(nil), (*RefType)(nil), + (*OptionalType)(nil), (*ArrayType)(nil), (*FuncType)(nil), + (*StructType)(nil), (*InterfaceType)(nil), (*EnumType)(nil), + } { + if !ForEachChild(typ, func(TypeChild) bool { + t.Errorf("nil %T yielded a child", typ) + return false + }) { + t.Errorf("nil %T traversal did not complete", typ) + } + if got := ownershipCapability(typ); got != (OwnershipCapability{Copy: CopyExplicit}) { + t.Errorf("nil %T capability = %+v; want explicit copy, no drop", typ, got) + } + } +} + +func TestForEachChildAcceptsTypedNilTypes(t *testing.T) { + var optional *OptionalType + var typ Type = optional + called := false + if !ForEachChild(typ, func(TypeChild) bool { + called = true + return true + }) { + t.Fatal("typed nil traversal should complete") + } + if called { + t.Fatal("typed nil type should have no children") + } +} diff --git a/internal/semantics/typeinfo/syntax.go b/internal/semantics/typeinfo/syntax.go index b2d04c80..2ee431b2 100644 --- a/internal/semantics/typeinfo/syntax.go +++ b/internal/semantics/typeinfo/syntax.go @@ -336,7 +336,7 @@ func returnOriginContract(clause *ast.ReturnOriginClause, params []ast.Param, ha return contract } -func ReturnOriginSources(call *ast.CallExpr, fn *FuncType) []ast.Expr { +func ReturnOriginSources(call *ast.CallExpr, args []ast.Expr, fn *FuncType) []ast.Expr { if call == nil || call.Callee == nil || fn == nil || fn.ReturnOrigins == nil { return nil } @@ -346,11 +346,11 @@ func ReturnOriginSources(call *ast.CallExpr, fn *FuncType) []ast.Expr { if methodCall { if slot == 0 { sources = append(sources, selector.Expr) - } else if slot > 0 && slot <= len(call.Args) { - sources = append(sources, call.Args[slot-1]) + } else if slot > 0 && slot <= len(args) { + sources = append(sources, args[slot-1]) } - } else if slot >= 0 && slot < len(call.Args) { - sources = append(sources, call.Args[slot]) + } else if slot >= 0 && slot < len(args) { + sources = append(sources, args[slot]) } } return sources diff --git a/internal/semantics/typeinfo/types.go b/internal/semantics/typeinfo/types.go index 8359d224..6da45288 100644 --- a/internal/semantics/typeinfo/types.go +++ b/internal/semantics/typeinfo/types.go @@ -8,6 +8,8 @@ import ( type Type interface { TypeNode() Text() string + forEachChild(func(TypeChild) bool) bool + ownershipShape() ownershipShape } type InvalidType struct{} diff --git a/internal/semantics/typeinfo/types_test.go b/internal/semantics/typeinfo/types_test.go index 1782931f..66602a2f 100644 --- a/internal/semantics/typeinfo/types_test.go +++ b/internal/semantics/typeinfo/types_test.go @@ -74,23 +74,26 @@ func TestSliceIsUnsizedButSliceReferenceIsSized(t *testing.T) { func TestCopyCapabilitiesFollowStructuralModel(t *testing.T) { i32 := &IntegerType{Signed: true, Bits: 32} - if !IsImplicitCopyType(i32) || !IsImplicitCopyType(&RawPtrType{}) || !IsImplicitCopyType(&RefType{Target: i32}) { + if OwnershipCapabilityOf(i32).Copy != CopyImplicit || OwnershipCapabilityOf(&RawPtrType{}).Copy != CopyImplicit || OwnershipCapabilityOf(&RefType{Target: i32}).Copy != CopyImplicit { t.Fatalf("scalar, raw pointer, and shared reference should copy implicitly") } - if !IsImplicitCopyType(&OptionalType{Inner: i32}) || IsImplicitCopyType(&OptionalType{Inner: &StructType{}}) { + if OwnershipCapabilityOf(&OptionalType{Inner: i32}).Copy != CopyImplicit || OwnershipCapabilityOf(&OptionalType{Inner: &StructType{}}).Copy == CopyImplicit { t.Fatalf("optional copyability should follow payload copyability") } - if IsImplicitCopyType(&StructType{Fields: []Field{{Name: "value", Type: i32}}}) { + if OwnershipCapabilityOf(&StructType{Fields: []Field{{Name: "value", Type: i32}}}).Copy == CopyImplicit { t.Fatalf("struct should not copy implicitly") } - if IsNoCopyType(&StructType{Fields: []Field{{Name: "value", Type: i32}}}) { - t.Fatalf("scalar-only struct should support structural copy") + if got := OwnershipCapabilityOf(&StructType{Fields: []Field{{Name: "value", Type: i32}}}); got.Copy != CopyExplicit { + t.Fatalf("scalar-only struct should support structural copy, got %v", got.Copy) } - if !IsNoCopyType(&StructType{Fields: []Field{{Name: "owner", Type: &OwnedPtrType{Target: i32}}}}) { - t.Fatalf("owned pointer should propagate nocopy through struct") + if got := OwnershipCapabilityOf(&StructType{Fields: []Field{{Name: "owner", Type: &OwnedPtrType{Target: i32}}}}); got.Copy != CopyNever || !got.Drop { + t.Fatalf("owned pointer should propagate nocopy and drop through struct, got %v", got) } - if !IsNoCopyType(&ArrayType{Shape: ArrayOwner, Elem: i32}) { - t.Fatalf("dynamic array should be intrinsically nocopy") + if got := OwnershipCapabilityOf(&ArrayType{Shape: ArrayOwner, Elem: i32}); got.Copy != CopyNever || !got.Drop { + t.Fatalf("dynamic array should be intrinsically nocopy, got %v", got) + } + if got := OwnershipCapabilityOf(&NoneType{}); got.Copy != CopyImplicit { + t.Fatalf("none should copy implicitly, got %v", got.Copy) } } @@ -99,7 +102,7 @@ func TestAllocatorCapabilities(t *testing.T) { if !IsSizedType(allocator) { t.Fatal("allocator must be sized") } - if !IsImplicitCopyType(allocator) { + if OwnershipCapabilityOf(allocator).Copy != CopyImplicit { t.Fatal("allocator must copy implicitly") } if !IsEquatable(allocator) { @@ -308,19 +311,17 @@ func TestTypeFromSyntaxPreservesReferenceReturnContract(t *testing.T) { func TestReturnOriginSourcesMapDirectAndMethodSlots(t *testing.T) { first := &ast.Ident{Name: "first"} second := &ast.Ident{Name: "second"} - direct := &ast.CallExpr{Callee: &ast.Ident{Name: "choose"}, Args: []ast.Expr{first, second}} + args := []ast.Expr{first, second} + direct := &ast.CallExpr{Callee: &ast.Ident{Name: "choose"}} fn := &FuncType{ReturnOrigins: &ReturnOriginContract{Sources: []int{1, 0, -1, 2}}} - if got := ReturnOriginSources(direct, fn); !slices.Equal(got, []ast.Expr{second, first}) { + if got := ReturnOriginSources(direct, args, fn); !slices.Equal(got, []ast.Expr{second, first}) { t.Fatalf("direct return sources = %#v", got) } receiver := &ast.Ident{Name: "receiver"} - method := &ast.CallExpr{ - Callee: &ast.SelectorExpr{Expr: receiver, Name: &ast.Ident{Name: "choose"}}, - Args: []ast.Expr{first, second}, - } + method := &ast.CallExpr{Callee: &ast.SelectorExpr{Expr: receiver, Name: &ast.Ident{Name: "choose"}}} fn.ReturnOrigins.Sources = []int{0, 2, 3, -1} - if got := ReturnOriginSources(method, fn); !slices.Equal(got, []ast.Expr{receiver, second}) { + if got := ReturnOriginSources(method, args, fn); !slices.Equal(got, []ast.Expr{receiver, second}) { t.Fatalf("method return sources = %#v", got) } } @@ -434,8 +435,8 @@ func TestNeedsDropSeparatesOwnershipFromMoveOnlyTypes(t *testing.T) { } for _, tc := range cases { t.Run(tc.name, func(t *testing.T) { - if got := NeedsDrop(tc.typ); got != tc.want { - t.Fatalf("NeedsDrop(%s) = %v, want %v", TypeText(tc.typ), got, tc.want) + if got := OwnershipCapabilityOf(tc.typ).Drop; got != tc.want { + t.Fatalf("drop obligation for %s = %v, want %v", TypeText(tc.typ), got, tc.want) } }) } @@ -494,11 +495,11 @@ func TestNamedEnumPayloadCapabilitiesFollowEveryCaseField(t *testing.T) { }}}, }}} - if !IsImplicitCopyType(copyable) || IsNoCopyType(copyable) || NeedsDrop(copyable) { - t.Fatal("scalar enum payload should remain copyable and require no drop") + if got := OwnershipCapabilityOf(copyable); got.Copy != CopyImplicit || got.Drop { + t.Fatalf("scalar enum payload should remain copyable and require no drop, got %v", got) } - if IsImplicitCopyType(owned) || !IsNoCopyType(owned) || !NeedsDrop(owned) { - t.Fatal("owned enum payload should be move-only and require drop") + if got := OwnershipCapabilityOf(owned); got.Copy != CopyNever || !got.Drop { + t.Fatalf("owned enum payload should be move-only and require drop, got %v", got) } if IsSizedType(unsized) || IsLowerableType(unsized) { t.Fatal("enum payload capabilities must reject unsized cases") diff --git a/internal/semantics/usage/usage.go b/internal/semantics/usage/usage.go index fbbf4b6a..792839ec 100644 --- a/internal/semantics/usage/usage.go +++ b/internal/semantics/usage/usage.go @@ -4,6 +4,7 @@ import ( "fmt" "compiler/internal/diagnostics" + "compiler/internal/prelude" "compiler/internal/project" "compiler/internal/semantics/symbols" ) @@ -25,7 +26,7 @@ func Analyze(ctx *project.CompilerContext, module *project.Module) { // 2. Check for unused private module-level symbols (functions, types, constants, variables) // Do not warn about prelude/global symbols since they represent a library - if module.Key != "core:prelude/global" { + if module.ID != prelude.ModuleID(ctx) { for _, sym := range module.ModuleScope.Symbols() { if sym.Kind == symbols.SymbolImport { continue @@ -56,8 +57,8 @@ func Analyze(ctx *project.CompilerContext, module *project.Module) { } // 3. Check for unused local variables and parameters - if module.Semantics != nil { - for _, scope := range module.Semantics.BlockScopes { + if module.Bindings != nil { + for _, scope := range module.Bindings.BlockScopes { if scope == nil { continue } diff --git a/internal/semantics/usage/usage_test.go b/internal/semantics/usage/usage_test.go index 9716935c..918f9eff 100644 --- a/internal/semantics/usage/usage_test.go +++ b/internal/semantics/usage/usage_test.go @@ -9,6 +9,7 @@ import ( "compiler/internal/diagnostics" "compiler/internal/frontend/lexer" "compiler/internal/frontend/parser" + "compiler/internal/moduleid" "compiler/internal/project" "compiler/internal/semantics/binder" "compiler/internal/semantics/collector" @@ -32,12 +33,11 @@ func checkUsageSource(t *testing.T, src string, setupImports bool) *diagnostics. fn GetValue() -> i32 { return 42; }` extAST := parser.New("external"+peeper.SourceExt, lexer.New("external"+peeper.SourceExt, extSrc, diag).Tokenize(), diag).ParseModule() extMod := &project.Module{ - Key: "local:external" + peeper.SourceExt, - ImportPath: "external", - FilePath: "external" + peeper.SourceExt, - Content: extSrc, - AST: extAST, - Imports: make(map[string]project.ResolvedImport), + ID: moduleid.ID{Origin: string(project.ModuleOriginLocal), ImportPath: "external"}, + FilePath: "external" + peeper.SourceExt, + Content: extSrc, + AST: extAST, + Imports: make(map[string]project.ResolvedImport), } ctx.AddModule(extMod) collector.Collect(ctx, extMod) @@ -49,20 +49,17 @@ fn GetValue() -> i32 { return 42; }` stream := lexer.New(filePath, src, diag).Tokenize() modAST := parser.New(filePath, stream, diag).ParseModule() module := &project.Module{ - Key: project.ModuleKeyFor(project.ModuleOriginLocal, filePath), - ImportPath: "usage_test", - FilePath: filePath, - Content: src, - AST: modAST, - Imports: make(map[string]project.ResolvedImport), + ID: moduleid.ID{Origin: string(project.ModuleOriginLocal), ImportPath: "usage_test"}, + FilePath: filePath, + Content: src, + AST: modAST, + Imports: make(map[string]project.ResolvedImport), } if setupImports { module.Imports["external"] = project.ResolvedImport{ - Key: "local:external" + peeper.SourceExt, - ImportPath: "external", - FilePath: "external" + peeper.SourceExt, - Origin: project.ModuleOriginLocal, + ID: moduleid.ID{Origin: string(project.ModuleOriginLocal), ImportPath: "external"}, + FilePath: "external" + peeper.SourceExt, } } diff --git a/pkg/registry/download.go b/pkg/registry/download.go index 54a13572..dbe256b9 100644 --- a/pkg/registry/download.go +++ b/pkg/registry/download.go @@ -315,7 +315,7 @@ func fetchGitHubVersions(ctx context.Context, httpClient *http.Client, repoName, } func githubNextPage(linkHeader string) (string, error) { - for part := range strings.SplitSeq(linkHeader, ",") { + for _, part := range strings.Split(linkHeader, ",") { part = strings.TrimSpace(part) if !strings.Contains(part, `rel="next"`) { continue diff --git a/pkg/typednil/typednil.go b/pkg/typednil/typednil.go new file mode 100644 index 00000000..fb10e146 --- /dev/null +++ b/pkg/typednil/typednil.go @@ -0,0 +1,24 @@ +// Package typednil provides one canonical check for the Go interface nil +// trap: a non-nil interface value wrapping a nil pointer is not equal to nil. +// Every compiler slot that accepts an interface kind and every validator that +// dereferences one must probe through this package, so a change to the +// detection policy lands in exactly one place. +package typednil + +import "reflect" + +// IsNil reports whether value is nil, including the typed-nil case where a +// non-nil interface holds a nil pointer. Plain equality checks miss that case +// because the interface itself carries type information. +// +// Policy, enforced here for every caller: pointer-kind values are probed for +// nil-ness; nil maps, slices, channels, and functions report false because the +// compiler's interface slots hold pointer-backed kinds. Changing that policy +// means changing this one function. +func IsNil(value any) bool { + if value == nil { + return true + } + reflected := reflect.ValueOf(value) + return reflected.Kind() == reflect.Pointer && reflected.IsNil() +} diff --git a/pkg/typednil/typednil_test.go b/pkg/typednil/typednil_test.go new file mode 100644 index 00000000..22e5790c --- /dev/null +++ b/pkg/typednil/typednil_test.go @@ -0,0 +1,28 @@ +package typednil + +import "testing" + +func TestIsNil(t *testing.T) { + type probe struct{ field int } + var typedNil *probe + tests := []struct { + name string + value any + want bool + }{ + {"untyped nil", nil, true}, + {"typed-nil pointer in interface", typedNil, true}, + {"nil map", map[string]int(nil), false}, + {"nil slice", []int(nil), false}, + {"live pointer", &probe{}, false}, + {"non-pointer value", probe{}, false}, + {"string", "peeper", false}, + } + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + if got := IsNil(test.value); got != test.want { + t.Fatalf("IsNil(%v) = %v, want %v", test.value, got, test.want) + } + }) + } +} diff --git a/task.md b/task.md new file mode 100644 index 00000000..8c35a6d0 --- /dev/null +++ b/task.md @@ -0,0 +1,663 @@ +# Compiler Maintainability Migration Handoff + +> Historical migration proposal, retained below without rewriting its original scope. +> Current implementation contract: [`docs/compiler-architecture.md`](docs/compiler-architecture.md) +> and [`COMPILER_FRAMEWORK_REPORT.md`](COMPILER_FRAMEWORK_REPORT.md). The old branch/workflow, +> effects-based usage target and blanket reflection ban below are not current delivery +> requirements: usage remains lexical; bounded typed-nil capability reflection is retained. +> New reference-bearing shapes still require provenance audits even when effects exist. +> Go baseline is 1.23.2. This history authorizes no branch changes, commits or shipping. + +## Objective + +Make Peeper compiler clean, readable, and difficult to extend incorrectly. + +When contributor adds or changes syntax that uses a symbol, contributor should +declare semantic meaning once. Existing infrastructure should then handle ordinary +reads, writes, moves, copies, borrows, initialization, usage, and cleanup without +new feature-specific logic in every analysis. + +Goal is not zero edits. Syntax, scope, type rules, genuinely new control flow, and +new runtime representation still need explicit owners. Goal is eliminating repeated +decisions and silent omissions. + +No new Peeper language feature is part of this task. Do not add catch, error-union +syntax, or copy another language's syntax or semantics. Existing optional, enum, +loop, call, match, ownership, and default-argument behavior are validation cases, +not feature targets. + +## Mandatory workflow + +Follow `AGENTS.md`, `RULES.md`, and `go-style.md`. Repository rules override this +handoff. + +1. Read this file and `compiler-maintainability.localplan.md` completely. +2. Work one numbered step only after explicit maintainer approval. +3. Before each patch, re-run full `AGENTS.md` pre-patch gate against live source and + current diff. +4. After each patch, run post-patch gate and record explicit Rules check in local + plan. +5. Keep `compiler-maintainability.localplan.md` complete. Never commit it. +6. Do not commit, push, create/update PR, merge, or change GitHub tracking without + explicit maintainer authorization. +7. Preserve unrelated changes. Stop if unexpected work overlaps planned files. +8. Use `rg`/`rg --files` first. Use `apply_patch` for hand edits. +9. Verify behavior from current source and tests, not only docs or donor commits. +10. Do not proceed to next step when current step is merely compiling. Meet its + validation and review stop condition. + +## Branch policy + +Plan was produced on `docs/compiler-framework`. That branch contains useful work +mixed with broad experimental migration. It is read-only donor, not implementation +trunk. + +Before Step 1 implementation: + +- run `git status --short --branch`; +- record current HEAD and `origin/main` SHA; +- create `feature/compiler-maintainability` from verified `origin/main`; +- preserve untracked `task.md` and ignored + `compiler-maintainability.localplan.md`; +- inspect donor commits with `git show`; +- reproduce only approved behavior; +- do not merge or wholesale cherry-pick `docs/compiler-framework`; +- do not fetch, rebase, reset, stash, clean, delete, or rewrite history without + permission. + +## Architectural contract + +### Each entity owns one job + +| Entity | Owns | Must not own | +| --- | --- | --- | +| AST node | Parsed shape, location, canonical children | Type/ownership/backend policy | +| Module identity | Stable source/import identity | Phase-local semantic facts | +| NodeID | Syntax occurrence identity | Symbol/storage meaning | +| SymbolID | Declaration/binding identity | Flow state | +| Place/origin | Storage root and projections | Typechecking or cleanup policy | +| Type model | Source type structure and nominal identity | Per-use flow state | +| Type capability | Canonical derived copy/move/drop/etc. answer | Physical backend emission | +| Typechecker result | Resolved semantic decisions needing type knowledge | CFG topology | +| CFG | Blocks, sites, edges, reachability | Type or ownership re-derivation | +| Semantic operation | Ordered read/write/move/borrow/define/discard meaning | Analysis-specific state | +| Definite initialization | Initialized-state dataflow and diagnostics | Ownership/drop policy | +| Ownership | Move/loan/liveness state and cleanup decisions | Syntax parsing, physical layout | +| Usage | Used/unused accounting and warnings | Ownership state | +| Cleanup plan | Exact source-level destruction sites | Backend layout traversal | +| HIR/MIR | Lowering established evidence | Rediscovering source semantics | +| Backend layout | Physical representation | Source semantic acceptance | +| Artifact validator | Shape and cross-reference invariants | Duplicate analysis algorithm | + +### Desired flow + +```text +source + -> AST + -> binding/resolution artifacts + -> typechecker artifact + published semantic meaning + -> CFG artifact + -> flow-refined artifact + -> definite initialization / ownership / usage over CFG + semantic meaning + -> cleanup plan + -> HIR + -> MIR + -> backend +``` + +No lower phase reaches backward to recompute an upstream decision when explicit +evidence can cross boundary. + +### Realistic feature-extension target + +For future syntax node that evaluates value then stores it in symbol: + +```text +required feature-specific work: + parser shape + resolver scope/binding + typechecker rule and semantic publication + CFG only if control flow differs + HIR only if existing lowering shapes cannot represent it + +automatic common work: + symbol reads and writes + definite initialization + copy/move selection already published by typechecker + borrow conflicts + used/unused accounting + cleanup planning + MIR/backend when existing operations suffice +``` + +If future construct maps to existing semantic operations but still needs custom +ownership, definite-init, usage, or backend cases, migration is incomplete. + +## Hard design constraints + +- No pass-through wrappers or old names forwarding to new names. +- No ignored parameters retained to avoid caller updates. +- No duplicate maps during migration after one slice completes. +- No `map[type]any`, `Publish[T]`, service locator, global registry, reflection, or + generic phase engine. +- No visitor method on every AST node for each compiler phase. +- No phase behavior stored on syntax nodes. +- No new helper/package/file unless it removes current duplication, protects + invariant, or represents real phase/lifetime boundary. +- No one-field organizational struct. +- No backend source-policy decision. +- No validator that reimplements ownership or dataflow. +- No refactor mixed with newly discovered behavior change. Report bug and request + separate approval. +- No Go 1.27 upgrade in this task. Existing Go generics may be used only when same + implementation truly works across current types and improves clarity. +- No language syntax or semantics copied from external languages. + +## Donor map + +Inspect these commits only when their step is active: + +| Commit | Useful evidence | Known caution | +| --- | --- | --- | +| `0d1f4ee` | explicit binding/typechecking result ownership | broad 48-file migration; split smaller | +| `03b4004` | canonical module identity and constant result | do not combine with Step 1 | +| `de8185a` | AST dispatch contract | test-tier, not compile-tier | +| `18b4800` | child traversal enforcement | structural check can be fooled; mutation-test it | +| `26600b0` | attribute/substructure traversal hardening | keep recovery semantics | +| `8ad69ae` | ownership capability vocabulary | final code still composes three walkers | +| `c8eca73` | published value-use kinds | incomplete coverage; validator only checks some uses | +| `c687b50` | cleanup-plan consolidation | verify every removed channel behavior | +| `52ff007` | ownership artifact validator | does not prove exactly-once drop paths | +| `7ec06e9` | removal of unused result channel | confirm no consumer before deletion | +| `7b11a33` | CFG topology validation | validator distinct from CFG analysis | +| `6fa713a` | closed iteration evidence | useful valid-state model; avoid ceremonial interfaces | +| `2a5fcf0` | sealed MIR node families | does not itself enforce backend dispatch completeness | + +## Validation baseline + +Use isolated cache because normal ccache/cache paths can be read-only: + +```bash +GOCACHE=/tmp/peeper-maintainability-go-cache CCACHE_DISABLE=1 go test -count=1 ./... +GOCACHE=/tmp/peeper-maintainability-go-cache CCACHE_DISABLE=1 go vet ./... +GOCACHE=/tmp/peeper-maintainability-go-cache CCACHE_DISABLE=1 go build ./... +git diff --check +``` + +When packaging or source behavior can be affected: + +```bash +GOCACHE=/tmp/peeper-maintainability-go-cache CCACHE_DISABLE=1 go run ./scripts/bundle.go +PEEPER_BIN="$PWD/build/bin/peeper" GOCACHE=/tmp/peeper-maintainability-go-cache CCACHE_DISABLE=1 go test -count=1 ./x_test +``` + +Run focused packages first, then full suite. Record command, exit code, and meaningful +output. Environment failures stay separate from source failures. + +## Step 1 - Extract typechecker-owned result from mixed semantic storage + +### Goal + +Create one explicit typechecker generation artifact on fresh branch from +`origin/main`. This is first narrow slice of phase-result migration, not full donor +commit replay. + +### Why first + +Current `origin/main` `project.SemanticInfo` mixes binding, resolution, +typechecking, constant, method-index, and operation-catalog state. Common semantic +publication cannot be reliable until its owner and reset lifetime are clear. + +### Pre-change inspection + +Answer in local plan before editing: + +1. Every `SemanticInfo` field and all writers/consumers. +2. Exact phase that first makes each field valid. +3. Whether field is base typechecking, flow-refined, binding/resolution, + constant-evaluation, or shared catalog state. +4. Which fields donor `typecheckresult.Result` moved. +5. Which donor moves were later corrected; inspect subsequent commits touching + `typecheckresult`, `bindingresult`, and `project.Module`. +6. Reset behavior in `Module.resetToPhase`, `ResetSemanticData`, and + `CompilerContext.ResetModule`. +7. LSP/invalid-source paths that read partial typechecking evidence. +8. Eager const-evaluation paths that run before typechecking completes. + +### Approved Step 1 implementation boundary + +Move only facts produced as base typechecker decisions and consumed downstream. +Expected candidates, subject to live proof: + +```text +ExprTypes +CaseTests +Matches +ImplicitConversions +ImplicitCallArguments +CompilerCalls +StringConcatenations +VariantConstructions +ForIterations +InterfaceImplementations +``` + +Do not move merely because donor moved it. Keep outside this slice unless live +writer/consumer/lifecycle evidence proves typechecker ownership: + +```text +BlockScopes +ResolvedSymbols +ExpandedDefaultBindings +ConstValues +MethodSets +MethodSymbol +OperationFunctions +``` + +### Required implementation behavior + +- Add dedicated typechecker result only if it owns several coherent facts. +- Constructor initializes all required maps and protects valid empty state. +- Typechecker owns creation/population. +- Consumers read new owner directly; no old-field fallback. +- Remove migrated fields from old mixed structure in same slice. +- Move associated data models with their owner; no type aliases. +- Keep base type evidence distinct from flow-refined evidence. +- Preserve nil/partial behavior before typecheck and during invalid source. +- Preserve default-argument expansion NodeID provenance. +- Preserve eager consteval behavior when typechecker result is absent/incomplete. +- Preserve semantic export fingerprint and LSP hover/completion behavior. +- Update reset lifecycle atomically: retain through its producing phase, clear on + reset before that phase. +- Add lifecycle tests for nil, initialized-empty, populated, retained, and cleared + states. +- Do not introduce generic result container. +- Do not move binding/resolution fields as incidental cleanup. + +### Required validation + +At minimum: + +```bash +gofmt -w +GOCACHE=/tmp/peeper-maintainability-go-cache CCACHE_DISABLE=1 go test -count=1 ./internal/semantics/typechecker ./internal/semantics/consteval ./internal/project ./internal/pipeline ./internal/lsp +GOCACHE=/tmp/peeper-maintainability-go-cache CCACHE_DISABLE=1 go test -race -count=1 ./internal/project ./internal/pipeline ./internal/lsp +GOCACHE=/tmp/peeper-maintainability-go-cache CCACHE_DISABLE=1 go test -count=1 ./... +GOCACHE=/tmp/peeper-maintainability-go-cache CCACHE_DISABLE=1 go run ./scripts/bundle.go +PEEPER_BIN="$PWD/build/bin/peeper" GOCACHE=/tmp/peeper-maintainability-go-cache CCACHE_DISABLE=1 go test -count=1 ./x_test +git diff --check +git status --short --branch +``` + +### Step 1 stop condition + +Stop for Codex review. Report: + +- branch and base SHA; +- field producer/consumer/lifecycle table; +- exact fields moved and explicitly deferred; +- all old reads/writes removed; +- reset and partial-source behavior; +- diff stat; +- exact validation results; +- mandatory Rules check. + +Do not begin Step 2. Do not commit. + +## Step 2 - Finish explicit phase artifacts and canonical identity + +### Goal + +Give remaining semantic facts one honest owner and stable lifetime, then replace +duplicated module identity with canonical identity. + +### Work slices + +This step is too broad for one patch. Claude must propose ordered sub-slices from +live inventory and wait for approval before each: + +1. binding/collection/resolution generation artifact; +2. constant result versus query/cache state; +3. remaining shared catalogs whose multi-writer lifecycle is genuine; +4. canonical module identity and indexes; +5. reset/invalidation convergence. + +### Requirements + +- One fact stored once. +- Producer, consumers, valid-from phase, reset-before phase documented. +- Reuse `NodeID`, `SymbolID`, module identity, and existing binding indexes. +- Keep generated/default provenance paired with binding identity. +- No name/path/pointer reconstruction when stable ID exists. +- No compatibility field, shadow map, or stale alias. +- No false phase split: if collector/binder/resolver truly build one staged symbol + graph with same lifetime, keep coherent result and document multi-writer contract. +- Directly update all consumers. +- Preserve diagnostics discard alongside artifact reset. +- Preserve incremental module reuse and dependency invalidation. + +### Stop condition + +After each approved sub-slice, show artifact ownership matrix and reset tests. Do not +continue automatically. + +## Step 3 - Canonical traversal and exhaustive phase decisions + +### Goal + +Adding AST statement/expression/type kind or child must create immediate named +failures at every phase requiring a decision. + +### Requirements + +- Reuse existing `ast.Inspect`, `ir.InspectExpr`, `ir.InspectPlace`, and + `hir.InspectStmt`; do not add parallel visitors. +- AST node owns canonical children only. +- Do not add resolver/typechecker/ownership methods to AST nodes. +- Add or transplant structural child-field tests only after understanding recovery + nodes, substructures, attributes, and generated/default AST. +- Add phase-dispatch contracts for real switch sites. +- Missing node must be handled or explicitly classified: + +```text +traverse +ignore +reject +contextual +``` + +- Every omission classification needs concrete reason. +- Test must fail when stale classification remains after real handler added. +- Mutation-prove each contract: remove one real case/child locally, capture expected + failure, restore probe before stop. +- Do not claim compile-time exhaustiveness from source-inspection tests. +- Evaluate compile-tier sealed interfaces only if boilerplate and phase-state costs + are lower than current checked-table approach. Do not implement visitor spike by + default. + +### Stop condition + +Produce matrix of node families versus phase sites, with guard strength: +automatic, visible test, loud runtime validation, or manual gap. + +## Step 4 - Canonical type capabilities + +### Goal + +Types receive one canonical derived answer for each shared semantic capability. +Consumers stop independently walking type structure. + +### Inventory first + +Audit current predicates and every caller, including: + +```text +copy / explicit-copy / no-copy +needs-drop +sized / lowerable +equatable / orderable +contains-reference / contains-stored-reference +pointer/reference target +collection shape +target-width representability +backend structural drop/layout traversal +``` + +### Requirements + +- Centralize only semantic questions shared by multiple callers. +- One recursive walker may return several tightly coupled ownership properties when + they share traversal and invariants. +- Do not create giant capability object for unrelated properties. +- Preserve aliases, nominal types, generic instantiation, recursive types, cycle + guards, aggregates, enums/optionals, interfaces, functions, arrays/slices, + ownership carriers, and invalid types. +- Update consumers directly and delete replaced walkers. +- Do not leave `IsX` wrappers around canonical function solely for old callers. +- Backend layout traversal may remain when it answers physical emission rather than + source policy. Rename/comment/test boundary if unclear. +- Type capability does not own per-expression use decision. +- Add exhaustive current-type table tests and recursion tests. +- If consolidation reveals inconsistent existing behavior, report it. Do not choose + new language semantics inside refactor. + +### Stop condition + +Show old/new behavior table, removed duplicate walkers, consumers, and remaining +backend-only traversal with justification. + +## Step 5 - Publish semantic operations once + +### Goal + +Represent common effect of existing syntax using small stable vocabulary so later +analyses do not each inspect AST shape. + +### Inventory + +Trace existing behavior for: + +- declarations with and without initializers; +- assignment and replacement; +- return values; +- expression statements/discarded values; +- conditions; +- calls, implicit receivers, piped calls, default arguments, intrinsics; +- address/shared/mutable borrow; +- selector/index/deref places; +- variant construction, case tests, match payload bindings; +- loops, break, continue, scope exits; +- explicit free/drop operations; +- string/array/collection operations. + +Record exact evaluation order and which phase currently decides read/copy/move. + +### Vocabulary constraints + +Candidate concepts—not mandated names: + +```text +UseValue(Read | Copy | Move) +DefinePlace +ReadPlace +WritePlace +BorrowPlace(shared | mutable) +UnpackPayload +DiscardValue +``` + +- Add operation only when current behavior needs it. +- Operation stores stable `NodeID`/`SymbolID`/place identity and diagnostic location, + not raw syntax pointer when avoidable. +- Typechecker publishes type-dependent decisions. +- Purely structural decisions may be normalized after CFG if that avoids redundant + evidence without losing completeness. +- Preserve ordered evaluation. +- Result owner and reset phase explicit. +- Missing required operation for valid accepted source fails validator. +- Invalid source uses deliberate recovery, not false compiler bug. +- Avoid one struct with unrelated optional fields; use valid closed shapes where + they clarify real alternatives. +- Do not create a framework package until boundary has multiple real producers or + consumers. + +### Pilot requirement + +Choose one existing construct whose symbol effects are currently repeated across at +least two analyses. Migrate producer plus one consumer only. Prove parity, stop, and +request review before expanding. + +### Stop condition + +Show old repeated decisions, new canonical publication, operation ordering tests, +validator behavior, and exact remaining consumers. + +## Step 6 - Convert dataflow consumers + +### Goal + +Definite initialization, ownership, and usage become separate state machines over +shared CFG sites and semantic operations. + +### Requirements + +- Migrate one construct family and one consumer per approved slice. +- Delete old AST switch/re-derivation only after parity tests pass. +- Definite initialization owns initialized-state lattice and diagnostics. +- Ownership owns liveness, moves, loans, conflicts, and cleanup decisions. +- Usage owns used/unused accounting and warnings. +- Share operation stream, not analysis state or diagnostics. +- Preserve CFG reachability, joins, loops/fixed points, and branch-local state. +- Preserve stable-place overlap and alias invalidation. +- Preserve unreachable-source semantics when a warning is syntax/typecheck-owned. +- Preserve exact source locations and diagnostic codes/messages unless separately + approved. +- New existing operation kind must force every consumer to handle, reject, or + explicitly ignore it. +- No default switch branch that silently does nothing. + +### Completion test + +Use an existing symbol-bearing construct as extension simulation. Modify only its +upstream normalization to emit already-known operations; confirm migrated consumers +need no construct-specific code. + +Do not add source syntax. + +## Step 7 - Cleanup and lowering convergence + +### Goal + +One cleanup plan owns source destruction decisions. HIR, MIR, and backend consume +published facts without rebuilding source semantics. + +### Requirements + +- Inventory every drop channel, flag, temporary cleanup path, and explicit free. +- Distinguish programmer-requested free, source cleanup plan, and MIR temporary + destruction. +- Remove write-only/dead/duplicate cleanup channels after consumer audit. +- Preserve evaluation-before-unwind and reverse lexical cleanup order. +- Preserve return, assignment replacement, discarded value, projection base, + match/payload, loop exit, and scope exit behavior. +- Ownership publishes decisions at stable NodeID/SiteID. +- HIR/MIR lower exact evidence. +- Backend only emits physical destruction for supplied MIR/layout. +- Backend may recursively traverse layout to emit nested destruction; it cannot + decide source liveness or invent missing cleanup. +- Avoid moving HIR earlier in pipeline if ownership still needs typed AST/CFG and + established phase order. +- Add exactly-once runtime regressions for owned nested values and every changed + cleanup path. + +### Stop condition + +Provide decision/emission ownership map and prove no duplicate policy remains in +touched paths. + +## Step 8 - Artifact validators and omission proof + +### Goal + +Normal `go test ./...` and pipeline execution catch missing contributor work. + +### Requirements + +- Each phase artifact documents producer, consumers, valid-from phase, reset rule, + and validator. +- Validate keys reference current AST nodes, symbols, CFG sites, or types. +- Validate legal operation/use/capability combinations. +- Validate CFG topology separately from user-facing CFG analysis. +- Validate cleanup keys and result generation; do not duplicate ownership dataflow + to “prove” exactly-once behavior. +- Seal IR/MIR node families where useful. +- Add dispatch contracts where Go cannot provide exhaustiveness. +- Add result reset/invalidation tests. +- Mutation-prove guards with temporary probes, then remove probes. +- Create no test-only production API. +- Simulate addition of a node, type, and semantic operation in tests/build probes. + Record which failure guides contributor at each missing step. +- Explicitly list any remaining unguarded work. + +### Required output + +```text +change kind | required decision | owner | guard | failure message | manual risk +``` + +## Step 9 - Final maintainability convergence + +### Goal + +Prove architecture is easier to understand and requires fewer repeated edits. + +### Audit + +- Every touched function still matches name, parameters, return, and behavior. +- No pass-through wrappers, stale aliases, ignored params, duplicate maps, shadow + registries, or temporary bridges. +- No files/modules split only for appearance. +- No unrelated refactor. +- No source behavior change hidden inside architecture work. +- AST traversal is canonical. +- Semantic facts published once. +- Identity stable across phases. +- Analyses consume operations, not source syntax, when semantics are common. +- Cleanup has one source decision owner. +- HIR/MIR/backend do not re-derive upstream facts. +- Invalid source/LSP recovery remains safe. +- Docs match live source and name honest enforcement gaps. + +### Comparison report + +Choose at least three existing change shapes: + +1. symbol-bearing statement/expression; +2. composite/owned type; +3. control-flow construct. + +For each, show before/after required files and decisions. Do not claim fewer touches +when same work merely moved behind wrappers or generated tables. + +### Full validation + +```bash +gofmt -w +GOCACHE=/tmp/peeper-maintainability-go-cache CCACHE_DISABLE=1 go test -count=1 ./... +GOCACHE=/tmp/peeper-maintainability-go-cache CCACHE_DISABLE=1 go test -race -count=1 ./internal/project ./internal/pipeline ./internal/lsp ./internal/semantics/... +GOCACHE=/tmp/peeper-maintainability-go-cache CCACHE_DISABLE=1 go vet ./... +GOCACHE=/tmp/peeper-maintainability-go-cache CCACHE_DISABLE=1 go build ./... +GOCACHE=/tmp/peeper-maintainability-go-cache CCACHE_DISABLE=1 go run ./scripts/bundle.go +PEEPER_BIN="$PWD/build/bin/peeper" GOCACHE=/tmp/peeper-maintainability-go-cache CCACHE_DISABLE=1 go test -count=1 ./x_test +git diff --check +git status --short --branch +``` + +### Final stop condition + +Return phase-owner map, omission matrix, before/after change paths, validation +evidence, Rules check, and honest remaining risks. Wait for explicit commit and +publication approval. + +## Codex review contract + +Codex will review Claude output after every approved step against: + +- live base SHA and exact diff; +- approved step boundary; +- producer/consumer/reset ownership; +- all touched functions, fields, aliases, parameters, and behavior; +- wrapper/duplication/helper rules; +- diagnostics and invalid-source/LSP recovery; +- stable identity and absence of re-derivation; +- evaluation order, CFG topology, flow joins, moves, loans, and cleanup; +- HIR/MIR/backend phase discipline; +- focused/full/race/vet/build/bundle/source-fixture results; +- unrelated work and Git publication state. + +## Initial authorization + +Only Step 1 is authorized. Complete Step 1, update +`compiler-maintainability.localplan.md`, and stop for Codex review. Do not start +Step 2. Do not commit. diff --git a/x_test/fixtures_test.go b/x_test/fixtures_test.go index ca67546d..4538cf1b 100644 --- a/x_test/fixtures_test.go +++ b/x_test/fixtures_test.go @@ -152,7 +152,8 @@ func executeFixtureProcess(t *testing.T, ctx context.Context, command string, ar if err == nil { return stdout.String(), stderr.String(), 0 } - if exitErr, ok := errors.AsType[*exec.ExitError](err); ok { + var exitErr *exec.ExitError + if errors.As(err, &exitErr) { return stdout.String(), stderr.String(), exitErr.ExitCode() } t.Fatalf("execute %s: %v", command, err) diff --git a/x_test/import_default_parameters/src/external.peep b/x_test/import_default_parameters/src/external.peep index e26c4dfc..23572fc6 100644 --- a/x_test/import_default_parameters/src/external.peep +++ b/x_test/import_default_parameters/src/external.peep @@ -37,3 +37,11 @@ fn StateCode(status: State = State::Ready with .{ value = 42 }) -> i32 fn IsPending(pending: bool = State::Pending is State::Pending) -> bool { return pending; } + +fn Base(value: i32 = 3) -> i32 { + return value; +} + +fn Nested(value: i32 = Base()) -> i32 { + return value; +} diff --git a/x_test/import_default_parameters/src/main.peep b/x_test/import_default_parameters/src/main.peep index c2be7743..82ce0c5f 100644 --- a/x_test/import_default_parameters/src/main.peep +++ b/x_test/import_default_parameters/src/main.peep @@ -9,5 +9,5 @@ fn main() -> i32 { if !(external::IsPending()) { return 1; } - return external::Add(4) - 10 + external::StateCode() - 42; + return external::Add(4) - 10 + external::StateCode() - 42 + external::Nested() - 3; } diff --git a/x_test/import_prelude_identity/peeper.toml b/x_test/import_prelude_identity/peeper.toml new file mode 100644 index 00000000..485438eb --- /dev/null +++ b/x_test/import_prelude_identity/peeper.toml @@ -0,0 +1,7 @@ +name = "import_prelude_identity" +build = "program" + +[test] +mode = "check" +outcome = "success" +stderr_contains = ["S0004"] diff --git a/x_test/import_prelude_identity/src/main.peep b/x_test/import_prelude_identity/src/main.peep new file mode 100644 index 00000000..07ccbbaf --- /dev/null +++ b/x_test/import_prelude_identity/src/main.peep @@ -0,0 +1,7 @@ +// The auto-loaded prelude must register under the same identity an explicit +// import resolves to, so importing it directly stays a normal compile. +import "core:global"; + +fn main() -> i32 { + return 0; +} diff --git a/x_test/negative_default_parameter_arity/peeper.toml b/x_test/negative_default_parameter_arity/peeper.toml index 95355756..df4e1b4d 100644 --- a/x_test/negative_default_parameter_arity/peeper.toml +++ b/x_test/negative_default_parameter_arity/peeper.toml @@ -5,3 +5,4 @@ build = "program" mode = "check" outcome = "failure" stderr_contains = ["Compilation failed"] +stderr_excludes = ["published semantic effects are malformed", "control-flow topology is malformed", "panic:"] diff --git a/x_test/negative_global_qualifier/peeper.toml b/x_test/negative_global_qualifier/peeper.toml new file mode 100644 index 00000000..b34420f6 --- /dev/null +++ b/x_test/negative_global_qualifier/peeper.toml @@ -0,0 +1,7 @@ +name = "negative_global_qualifier" +build = "program" + +[test] +mode = "check" +outcome = "failure" +stderr_contains = ["M0004", "S0005"] diff --git a/x_test/negative_global_qualifier/src/main.peep b/x_test/negative_global_qualifier/src/main.peep new file mode 100644 index 00000000..790a7cdb --- /dev/null +++ b/x_test/negative_global_qualifier/src/main.peep @@ -0,0 +1,8 @@ +// Global symbols are already in scope, so qualifying one is redundant. This +// symbol is also private, so the qualified form additionally fails to resolve. +import "core:global"; + +fn main() -> i32 { + global::exit(0); + return 0; +} diff --git a/x_test/negative_projection_borrow_conflict/peeper.toml b/x_test/negative_projection_borrow_conflict/peeper.toml new file mode 100644 index 00000000..b571814f --- /dev/null +++ b/x_test/negative_projection_borrow_conflict/peeper.toml @@ -0,0 +1,8 @@ +name = "negative_projection_borrow_conflict" +build = "lib" + +[test] +mode = "check" +outcome = "failure" +stderr_contains = ["T0037", "cannot activate mutable borrow while storage is borrowed"] +stderr_excludes = ["T0039"] diff --git a/x_test/negative_projection_borrow_conflict/src/main.peep b/x_test/negative_projection_borrow_conflict/src/main.peep new file mode 100644 index 00000000..06b06687 --- /dev/null +++ b/x_test/negative_projection_borrow_conflict/src/main.peep @@ -0,0 +1,9 @@ +fn Write(_: &mut i32) -> i32 { return 0; } +fn Read(_: &i32) {} + +fn Conflict(matrix: [2][2]i32, mut value: i32) -> i32 { + let reference = &value; + let selected = matrix[Write(&mut value)][0]; + Read(reference); + return selected; +} diff --git a/x_test/negative_projection_uninitialized/peeper.toml b/x_test/negative_projection_uninitialized/peeper.toml new file mode 100644 index 00000000..4e271319 --- /dev/null +++ b/x_test/negative_projection_uninitialized/peeper.toml @@ -0,0 +1,7 @@ +name = "negative_projection_uninitialized" +build = "lib" + +[test] +mode = "check" +outcome = "failure" +stderr_contains = ["T0039", "symbol `read_index` used before it's initialized", "symbol `write_index` used before it's initialized", "symbol `borrow_index` used before it's initialized", "symbol `field_index` used before it's initialized", "symbol `slice_index` used before it's initialized"] diff --git a/x_test/negative_projection_uninitialized/src/main.peep b/x_test/negative_projection_uninitialized/src/main.peep new file mode 100644 index 00000000..1d5e9d44 --- /dev/null +++ b/x_test/negative_projection_uninitialized/src/main.peep @@ -0,0 +1,26 @@ +struct Cell { value: i32 } + +fn Read(matrix: [2][2]i32) -> i32 { + let mut read_index: i32; + return matrix[read_index][0]; +} + +fn Write(mut matrix: [2][2]i32) { + let mut write_index: i32; + matrix[write_index][0] = 1; +} + +fn Borrow(matrix: [2][2]i32) { + let mut borrow_index: i32; + let view = &matrix[borrow_index][0]; +} + +fn Field(cells: [2]Cell) -> i32 { + let mut field_index: i32; + return cells[field_index].value; +} + +fn Slice(matrix: [2][2][2]i32) { + let mut slice_index: i32; + let view = matrix[slice_index][0][..]; +} diff --git a/x_test/negative_variant_reference_rebind/peeper.toml b/x_test/negative_variant_reference_rebind/peeper.toml new file mode 100644 index 00000000..bf8a5553 --- /dev/null +++ b/x_test/negative_variant_reference_rebind/peeper.toml @@ -0,0 +1,8 @@ +name = "negative_variant_reference_rebind" +build = "lib" + +[test] +mode = "check" +outcome = "failure" +stderr_contains = ["T0037", "cannot mutate storage while it is borrowed"] +stderr_excludes = ["T0010", "T0007", "panic", "llvm invariant"] diff --git a/x_test/negative_variant_reference_rebind/src/main.peep b/x_test/negative_variant_reference_rebind/src/main.peep new file mode 100644 index 00000000..6b3b137c --- /dev/null +++ b/x_test/negative_variant_reference_rebind/src/main.peep @@ -0,0 +1,58 @@ +enum Resource { Borrowed: { value: &i32, sibling: &i32 }, Empty } +enum Optional { Borrowed: { value: ?&i32 }, Empty } +enum Mutable { Borrowed: { value: &mut i32 }, Empty } +fn Read(_: &i32) {} +fn ReadOptional(_: ?&i32) {} +fn Write(_: &mut i32) {} + +fn NewLoan(mut first: i32, mut second: i32) { + let mut resource = Resource::Borrowed with .{ value = &first, sibling = &first }; + if resource is Resource::Borrowed { + resource.value = &second; + second = 3; + Read(resource.value); + } +} +fn SiblingLoan(mut first: i32, mut second: i32) { + let reference = &first; + let mut resource = Resource::Borrowed with .{ value = reference, sibling = reference }; + if resource is Resource::Borrowed { + resource.value = &second; + first = 3; + Read(resource.sibling); + } +} +fn CopiedLoan(mut first: i32, mut second: i32) { + let mut resource = Resource::Borrowed with .{ value = &first, sibling = &first }; + if resource is Resource::Borrowed { + let copy = resource.value; + resource.value = &second; + resource.sibling = &second; + first = 3; + Read(copy); + } +} +fn OptionalLoan(mut first: i32, mut second: i32) { + let mut resource = Optional::Borrowed with .{ value = &first }; + if resource is Optional::Borrowed { + resource.value = &second; + second = 3; + ReadOptional(resource.value); + } +} +fn MutableLoan(mut first: i32, mut second: i32) { + let mut resource = Mutable::Borrowed with .{ value = &mut first }; + if resource is Mutable::Borrowed { + resource.value = &mut second; + second = 3; + Write(resource.value); + } +} +fn LoopLoan(mut first: i32, mut second: i32, flag: bool) { + let mut resource = Resource::Borrowed with .{ value = &first, sibling = &first }; + if resource is Resource::Borrowed { + for flag { resource.value = &second; } + second = 3; + Read(resource.value); + } +} diff --git a/x_test/runtime_cleanup_paths/peeper.toml b/x_test/runtime_cleanup_paths/peeper.toml new file mode 100644 index 00000000..5e335def --- /dev/null +++ b/x_test/runtime_cleanup_paths/peeper.toml @@ -0,0 +1,13 @@ +name = "runtime_cleanup_paths" +build = "program" +entry = "src/main.peep" + +# type_automatic_cleanup_plan covers the same paths in check mode, which proves +# the plan is accepted but never executes it. This runs them: a drop emitted +# twice aborts in the allocator, and a drop emitted for storage that was moved +# away corrupts the heap, so reaching the final marker is the exactly-once +# evidence. +[test] +mode = "run" +outcome = "success" +stdout_contains = ["scope", "replace", "discard", "projection", "nested", "match", "done"] diff --git a/x_test/runtime_cleanup_paths/src/main.peep b/x_test/runtime_cleanup_paths/src/main.peep new file mode 100644 index 00000000..b6cf9593 --- /dev/null +++ b/x_test/runtime_cleanup_paths/src/main.peep @@ -0,0 +1,69 @@ +struct Box { + value: i32, + ptr: *i32 +} + +enum Carrier { + Owned: { held: *i32 }, + Empty +} + +fn MakeBox() -> Box { + return .Box{ value = 7, ptr = alloc(0) }; +} + +// A projection out of a temporary: the base is owned by nobody, so its cleanup +// belongs to the site that produced it. +fn ReadTemporaryField() -> i32 { + return MakeBox().value; +} + +// Replacing a live owned value drops the old one before the new one lands. +fn ReplaceOwned() { + let mut held = alloc(0); + let next = alloc(0); + held = next; + println("replace"); +} + +// A value produced and thrown away dies where it is produced. +fn DiscardTemporary() { + alloc(0); + .Box{ value = 0, ptr = alloc(0) }; + println("discard"); +} + +// An owned value inside an aggregate is destroyed with it. +fn NestedOwned() { + let box = .Box{ value = 1, ptr = alloc(0) }; + println("nested"); +} + +// A payload the arm does not bind is dropped on that arm's edge. +fn MatchCarrier(carrier: Carrier) { + match carrier { + Carrier::Owned with { held = _ } => { + println("match"); + } + Carrier::Empty => { + println("match"); + } + } +} + +fn main() -> i32 { + { + let scoped = alloc(0); + println("scope"); + } + ReplaceOwned(); + DiscardTemporary(); + let _ = ReadTemporaryField(); + println("projection"); + NestedOwned(); + MatchCarrier(Carrier::Owned with .{ held = alloc(0) }); + let early = alloc(0); + free(early); + println("done"); + return 0; +} diff --git a/x_test/runtime_projection_operand_order/peeper.toml b/x_test/runtime_projection_operand_order/peeper.toml new file mode 100644 index 00000000..df88fc20 --- /dev/null +++ b/x_test/runtime_projection_operand_order/peeper.toml @@ -0,0 +1,6 @@ +name = "runtime_projection_operand_order" +build = "program" + +[test] +mode = "run" +outcome = "success" diff --git a/x_test/runtime_projection_operand_order/src/main.peep b/x_test/runtime_projection_operand_order/src/main.peep new file mode 100644 index 00000000..c325ba01 --- /dev/null +++ b/x_test/runtime_projection_operand_order/src/main.peep @@ -0,0 +1,47 @@ +struct Trace { order: i32 } +struct Cell { value: i32 } + +fn mark(trace: &mut Trace, digit: i32) -> i32 { + trace.order = trace.order * 10 + digit; + return 0; +} + +fn make(trace: &mut Trace) -> [2]i32 { + trace.order = trace.order * 10 + 1; + return [2]i32{7, 8}; +} + +fn (cell: &Cell) read() -> i32 { + return cell.value; +} + +fn main() -> i32 { + let mut trace = .Trace{order = 0}; + let mut matrix = [2][2]i32{[2]i32{7, 8}, [2]i32{9, 10}}; + let value = matrix[mark(&mut trace, 1)][mark(&mut trace, 2)]; + if value != 7 || trace.order != 12 { return 1; } + + trace.order = 0; + matrix[mark(&mut trace, 2)][mark(&mut trace, 3)] = mark(&mut trace, 1); + if matrix[0][0] != 0 || trace.order != 123 { return 2; } + + trace.order = 0; + let cells = [2][2]Cell{[2]Cell{.{value = 3}, .{value = 4}}, [2]Cell{.{value = 5}, .{value = 6}}}; + let selected = cells[mark(&mut trace, 1)][mark(&mut trace, 2)].read(); + if selected != 3 || trace.order != 12 { return 3; } + + trace.order = 0; + { + let view = matrix[mark(&mut trace, 1)][mark(&mut trace, 2)..mark(&mut trace, 3) + 1]; + if view[0] != 0 || trace.order != 123 { return 4; } + } + + trace.order = 0; + let temporary = make(&mut trace)[mark(&mut trace, 2)]; + if temporary != 7 || trace.order != 12 { return 5; } + + trace.order = 0; + let pointer: rawptr = @matrix[mark(&mut trace, 1)][mark(&mut trace, 2)]; + if pointer != @matrix[0][0] || trace.order != 12 { return 6; } + return 0; +} diff --git a/x_test/runtime_variant_reference_rebind/peeper.toml b/x_test/runtime_variant_reference_rebind/peeper.toml new file mode 100644 index 00000000..965ef462 --- /dev/null +++ b/x_test/runtime_variant_reference_rebind/peeper.toml @@ -0,0 +1,6 @@ +name = "runtime_variant_reference_rebind" +build = "program" + +[test] +mode = "run" +outcome = "success" diff --git a/x_test/runtime_variant_reference_rebind/src/main.peep b/x_test/runtime_variant_reference_rebind/src/main.peep new file mode 100644 index 00000000..d9f5e017 --- /dev/null +++ b/x_test/runtime_variant_reference_rebind/src/main.peep @@ -0,0 +1,54 @@ +struct Box { value: i32 } +enum Resource { Borrowed: { value: &Box, sibling: &Box }, Empty } +enum Optional { Borrowed: { value: ?&Box }, Empty } +enum Mutable { Borrowed: { value: &mut Box }, Empty } + +fn Read(reference: &Box) -> i32 { return reference.value; } +fn Write(reference: &mut Box) { reference.value = 30; } + +fn main() -> i32 { + let mut first: Box = .{ value = 10 }; + let mut second: Box = .{ value = 20 }; + let reference = &first; + let mut resource = Resource::Borrowed with .{ value = reference, sibling = reference }; + if resource is Resource::Borrowed { + let duplicate = resource; + resource.value = resource.value; + resource.value = &second; + if Read(resource.value) != 20 || Read(resource.sibling) != 10 { return 1; } + match duplicate { + Resource::Borrowed with { value = old } => { if Read(old) != 10 { return 2; } } + Resource::Empty => { return 3; } + } + resource.sibling = &second; + first.value = 11; + for index in 0..2 { + if index == 0 { resource.value = &first; } else { resource.value = &second; } + } + resource.value = &second; + first.value = 12; + match resource { + Resource::Borrowed with { value = current, sibling = sibling } => { + if Read(current) != 20 || Read(sibling) != 20 { return 4; } + } + Resource::Empty => { return 5; } + } + } + let mut optional = Optional::Borrowed with .{ value = &first }; + if optional is Optional::Borrowed { + optional.value = none; + first.value = 13; + if optional.value != none { return 6; } + } + let mut mutable = Mutable::Borrowed with .{ value = &mut first }; + if mutable is Mutable::Borrowed { + mutable.value = &mut second; + first.value = 14; + Write(mutable.value); + } + if first.value != 14 || second.value != 30 { return 7; } + let mut values = [2]i32{1, 2}; + values[0] = 7; + if values[0] != 7 { return 8; } + return 0; +}