diff --git a/.agents/skills/peeper-code-review/SKILL.md b/.agents/skills/peeper-code-review/SKILL.md new file mode 100644 index 00000000..fff45cb0 --- /dev/null +++ b/.agents/skills/peeper-code-review/SKILL.md @@ -0,0 +1,83 @@ +--- +name: peeper-code-review +description: Review Peeper compiler changes for correctness and simple, maintainable code. Use for this repository's uncommitted changes, branches or PRs, especially requests to avoid overengineering, simplify code, reduce redundant variables/helpers/state, or find clearer equivalent solutions. Report concrete findings and smallest safe remedies; review does not imply permission to apply fixes. +--- + +# Peeper code review + +Optimize for fewer concepts to maintain and code a new contributor can read cold. +Deletion and direct reuse beat new layers. Clear, equivalent code beats clever brevity. + +## Pin scope + +- Read repo `RULES.md`, `AGENTS.md`, `go-style.md` and relevant active local plan. + Consult specs/architecture for changed behavior, then verify claims against source. + Reference existing rules instead of creating another policy or precedence hierarchy. +- Inspect status and resolve requested baseline. For **current uncommitted changes**, + default to `git diff HEAD -- ` for final worktree state; inspect staged diff + separately when index differs from worktree. Read untracked source/tests/fixtures. + An empty committed range is not an empty worktree. For branch/PR review, use requested + base/merge-base and included commits. +- Identify task work and pre-existing user edits. Cover requested source/documentation; + do not treat unrelated edits as scope creep or overwrite them. Exclude generated + artifacts and operational journals/local plans from implementation findings. +- Review source read-only unless user requested fixes. Follow existing agent edit gates + and delivery authorization when implementation is requested. + +## Standards and simplicity + +Trace changed symbols to real callers and existing canonical owners before suggesting +abstraction, extraction or replacement. Review tests and docs as well as production. + +For each changed concept, ask: + +1. Does it need to exist? Recommend deletion of speculative state and stale layers. +2. Can stdlib, existing dependency or current owner do it directly? Prefer reuse. +3. Can deletion, inlining or a plain guard/loop/switch make the same behavior clearer? +4. Does a variable name a useful domain fact, prevent repeated/effectful evaluation or + clarify an invariant? Keep it. If it only renames a clear expression, suggest removal. +5. Is a helper/type/cache/boundary justified under `RULES.md`? Keep real reuse and + ownership/invariant boundaries; eliminate pass-through or single-use simple layers. +6. Is identical logic repeated? Use its canonical owner. Similar-looking operations + with different semantic or validation responsibilities should remain distinct. + +Recommend the smallest equivalent change. State exactly what disappears and why the +replacement is easier to understand. Fewer lines alone do not justify nested expressions, +bit tricks, generic frameworks, hidden mutation or erasing validation. Do not manufacture +cleanup findings merely to hit a quota, rename good domain variables or chase unrelated code. + +## Spec and correctness + +- Trace changed producers and consumers through actual compiler phases. Check source + acceptance, lowering/materialization, backend output and runtime meaning together. +- Before deleting behavior, inventory validation/diagnostics and locations, mutation, + caching, normalization/fallbacks, locking, ownership/lifetimes and phase invariants. +- Numeric simplification must retain intermediate truncation, signed extension and + float rounding. Bounds must preserve exact values and trap-before-access ordering. +- Verify semantic/nominal identity, defining-owner publication and incomplete-type + construction where affected. Do not repeat decisions already owned by checked evidence. +- Check new symbols/fields have current production consumers. Tests should prove + behavior or invariants, not require production APIs/state only for observation. + +## Evidence and validation + +Use the smallest focused source/test probe needed to confirm a suspected problem. +Inspect baseline behavior when distinguishing a regression from an existing gap. +Keep temporary probes outside tracked source; add no production instrumentation. +Respect active plan's validation environment. Do not rerun unchanged expensive suites +without new edits, failures or unresolved evidence. Clearly attribute inherited results. +For language behavior, require appropriate `x_test/` coverage and bundled compiler; +for ABI/backend changes, distinguish target object validation from native execution. +Verify docs/skill edits with links, discovery/ignore rules and diff checks as relevant. + +## Report + +Findings first, under **Standards** and **Spec**. For each finding give: + +- severity and whether correctness defect, documented breach or optional cleanup; +- file and precise lines, actual consequence and source/rule/test evidence; +- smallest safe remedy, what it removes, and behavior that must be preserved. + +Distinguish introduced problems from existing gaps. Report zero findings when no +worthwhile improvement is justified. End with validation actually run, remaining +limitations and short Rules check. Keep review concise; no generic refactor wishlist. diff --git a/.gitignore b/.gitignore index b17a56f0..a6a5f84f 100644 --- a/.gitignore +++ b/.gitignore @@ -26,6 +26,13 @@ graphify-out *.patch clean.md .*/ +!.agents/ +.agents/* +!.agents/skills/ +.agents/skills/* +!.agents/skills/peeper-code-review/ +.agents/skills/peeper-code-review/* +!.agents/skills/peeper-code-review/SKILL.md !.gitignore !.github/ !.github/workflows/ diff --git a/AGENTS.md b/AGENTS.md index 9ab51927..ab6eb94a 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -6,6 +6,10 @@ This file defines how agents must work in this repository. When documents conflict, do not let a file's self-declared precedence settle technical correctness. Identify the conflict, inspect evidence, and ask for a decision when required. Human-facing engineering rules belong in `RULES.md`; agent-only gates, local plans, GitHub automation, and response style belong here. +For code-review requests, read [Peeper code review](.agents/skills/peeper-code-review/SKILL.md). +It captures the review workflow and user's simplicity preferences; use `RULES.md` +for durable engineering requirements. + --- ## 1) Required pre-change check diff --git a/Code-tour.md b/Code-tour.md index a7e808dc..6c01dec5 100644 --- a/Code-tour.md +++ b/Code-tour.md @@ -42,10 +42,11 @@ invoked as an external tool. Peeper does not link anything by hand. | `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/module` | `Module` and its phase artifacts | +| `internal/project` | `CompilerContext`, module registry, imports, and fingerprints | | `internal/frontend` | `token`, `lexer`, `parser`, `ast` | | `internal/semantics` | Collector, binder, resolver, const eval, typechecker, flow facts, effects, definite init, ownership, usage, plus the artifact packages | -| `internal/ir` | `cfg`, `hir`, `mir`, and the shared `ir` node/type model | +| `internal/ir` | `thir`, `cfg`, `exprlower`, `mir`, and the shared `ir` node/type model | | `internal/backend/llvm` | MIR → LLVM IR text | | `internal/toolchain` | Finds `clang` and the sysroot; builds its command lines | | `internal/diagnostics` | Errors, warnings, source rendering, phase attribution | @@ -86,7 +87,7 @@ 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 { +func (l *moduleLoader) Load(entry *module.Module) error { l.enqueue(entry) for len(l.queue) > 0 { module := l.pop() @@ -129,14 +130,14 @@ This is the spine of the compiler. Each module carries a `Phase`, and ```mermaid flowchart TD Setup --> Load --> Parsed --> Collected --> Bound --> Resolved - Resolved --> Typechecked --> CFG --> FlowTyped - FlowTyped --> Effects --> DefiniteInit --> Ownership --> Usage + Resolved --> Typechecked --> CFG --> Analyzed + Analyzed --> Usage Usage --> MIR --> Backend --> Finalize ``` ```go // internal/pipeline/pipeline.go (heavily simplified) -func advanceModulePhase(ctx *project.CompilerContext, module *project.Module) bool { +func advanceModulePhase(ctx *project.CompilerContext, module *module.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 } @@ -156,32 +157,34 @@ scheduler enforces that by advancing everyone one rung at a time. | `Collected` | top-level symbols, method sets | `SymbolIndex`, `ModuleScope` | | `Bound` | operator/interface bindings | `SymbolIndex` | | `Resolved` | every identifier → symbol | `SymbolIndex` occurrence lookup | -| `Typechecked` | types, typing decisions, finalized constants, typed source IR | `Typechecking`, `Constants`, `THIR` | +| `Typechecked` | types, typing decisions, finalized constants, typed source IR | `SymbolIndex`, `THIR`, `SemanticExportFingerprint` | | `CFG` | blocks, sites, edges | `CFG` | -| `FlowTyped` | path-sensitive narrowing | `Analysis` | -| `Effects` | ordered evaluation/storage events | transient during `Analysis.Run` | -| `DefiniteInit` | *diagnostics only* | — | -| `Ownership` | drop plan | `Ownership` | -| `Usage` | *warnings only* | — | +| `Analyzed` | flow, effect, definite-init, and ownership evidence | `Analysis` | +| `Usage` | usage diagnostics at the project barrier | diagnostics; no durable module artifact | | `MIR` | flat, block-structured IR | `MIR` | | `Backend` | LLVM IR text | `LLVMIR` | +| `Finalize` | cross-module runtime-symbol validation | project phase | -**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. +**Phase artifacts are the central design idea.** Each durable artifact is produced by one +owning phase and read by later phases. Flow, effects, definite initialization, and +ownership evidence are published together in `Analysis`; `Usage` is a project barrier +for diagnostics rather than another durable module artifact. -`Module.resetToPhase` clears artifacts in phase order, so incremental rebuilds cannot -leave stale evidence behind: +`Module.ResetToPhase` clears downstream 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 } - // ... +// internal/module/module.go (simplified) +func (m *Module) ResetToPhase(retained phase.Phase) { + m.Phase = retained + if retained <= phase.Parsed { m.ModuleScope = nil; m.SymbolIndex = nil } + if retained < phase.Collected { m.typeDeclarations = nil } + if retained < phase.Typechecked { m.THIR = nil; m.SemanticExportFingerprint = "" } + if retained < phase.CFG { m.CFG = nil } + if retained < phase.Analyzed { m.Analysis = nil } + if retained < phase.MIR { m.MIR = nil } + if retained < phase.Backend { m.LLVMIR = "" } } ``` @@ -253,15 +256,13 @@ module.SymbolIndex.SetScope(block, scope) depend on, so nothing has to re-derive them: ```go -// internal/semantics/typecheckresult/result.go (excerpt) -result.RecordExprType(expr.ID(), typ) -result.RecordImplicitConversion(expr.ID(), conversion) -result.RecordForIteration(loop.ID(), iteration) - -// Later phases ask semantic questions; the backing indexes stay private. -typ := result.ExprType(expr.ID()) -conversion, ok := result.ImplicitConversion(expr.ID()) -iteration, ok := result.ForIteration(loop.ID()) +// internal/semantics/typechecker/evidence.go (private during Check) +c.evidence.RecordExprType(expr.ID(), typ) +c.evidence.RecordImplicitConversion(expr.ID(), conversion) +c.evidence.RecordForIteration(loop.ID(), iteration) + +// internal/semantics/typechecker/thir_build.go materializes those facts on THIR; +// later phases query THIR instead of re-deriving source meaning. ``` --- @@ -406,15 +407,14 @@ They currently share **evidence, not solver machinery**. Each has distinct latti 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 +// internal/semantics/analysis/definite_init.go (simplified) +func transfer(ops []effectOp, in initState) initState { + out := copyInitState(in) + visitor := &initializationVisitor{current: out, shouldApplyState: true} + for _, op := range ops { + visitEffect(op, visitor) } + return out } ``` @@ -423,22 +423,12 @@ It contains **no AST switch at all**; source identity comes from `source.NodeID` **Ownership** tracks moves, loans and liveness, then writes the drop plan: ```go -// internal/semantics/analysis/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 +// internal/semantics/analysis/ownership_effects.go (simplified) +func (a *analyzer) applyEffects(node *site, st ownershipState, loans *loanContext) { + ops := a.effects[node.cfgSite.ID] + visitor := &ownershipEffectVisitor{a: a, node: node, st: st, loans: loans} + for _, op := range ops { + visitEffect(op, visitor) } } ``` @@ -447,12 +437,12 @@ The result is the `CleanupPlan` — the **single source of drop obligations** ov values: ```go -// internal/semantics/ownershipresult/result.go -type CleanupPlan struct { - AfterScope map[cfg.SiteID][]symbols.SymbolID // scope exit - BeforeReturn map[source.NodeID][]symbols.SymbolID // after the value is computed - BeforeAssign map[source.NodeID]struct{} // replacing a value drops the old - DiscardedValue map[source.NodeID]struct{} // a temporary nobody owns +// internal/semantics/analysis/cleanup.go +type cleanupPlan struct { + AfterScope map[cfg.SiteID][]symbols.SymbolID + BeforeReturn map[source.NodeID][]symbols.SymbolID + BeforeAssign map[source.NodeID]struct{} + DiscardedValue map[source.NodeID]struct{} // … } ``` @@ -543,18 +533,18 @@ The compiler is built so that *forgetting* something fails loudly. | Guard | Where | Catches | | --- | --- | --- | -| AST traversal tests | `frontend/ast` tests + source fixtures | broken child traversal behavior | +| AST traversal tests | `internal/frontend/ast` tests + source fixtures | broken child traversal behavior | | Sealed semantic type contract | Go type system | missing child/ownership behavior on a new semantic type | | THIR validation + required operation methods | `internal/ir/thir` | malformed evidence or missing consumer support | -| MIR/backend rejecting dispatch | `internal/ir/mir`, `backend/llvm` | unsupported lowered nodes fail loudly | +| MIR/backend rejecting dispatch | `internal/ir/mir`, `internal/backend/llvm` | unsupported lowered nodes fail loudly | | `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 | +| `Analysis` validation | `internal/semantics/analysis` | malformed effects or cleanup evidence | A contract failure reads like this: ``` -publishStmt makes no decision about ast.YieldStmt; add a case or declare why the kind is inert +THIR operation contract has no implementation for a new node kind; add its case or +declare why the kind is inert ``` At true closed extension points, every omission must be handled or deliberately @@ -573,7 +563,8 @@ For a new syntax construct, in order: 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. +7. **analysis** — implement required `AnalyzeFlow` and `BuildEffects` methods when + the construct changes flow or storage behavior. 8. **THIR/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. @@ -607,7 +598,7 @@ at each true extension point. | --- | --- | | 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 typechecker publish? | `internal/semantics/typechecker/evidence.go`, then `internal/ir/thir` | +| Why is a value moved/dropped here? | `internal/semantics/analysis`, then `internal/ir/mir` | | 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/docs/architecture/ir.md b/docs/architecture/ir.md index 55872136..4c5ddd90 100644 --- a/docs/architecture/ir.md +++ b/docs/architecture/ir.md @@ -130,7 +130,21 @@ rebuilt: - expression trees lower through `exprlower`, then existing shared-IR-to-MIR logic. External functions and body-backed functions share THIR signature lowering. -Published module constants become MIR static data before function lowering. +Published module constants become MIR static data before function lowering, using +the declared semantic type's runtime representation. Lowering checks published +value ABI identity against that type, including nominal enum identity. Missing +values produce a compile-time evaluation diagnostic; mismatched published types +produce an invalid-evidence diagnostic. Neither failure emits partial MIR. +Discard (`_`) module bindings remain source-checked but emit no addressable static. + +LLVM fixed-array index lowering retains unsigned 64-bit lengths and compares +constant indexes exactly; backend bounds diagnostics retain index source location. +After exact logical bounds validation, physical offsets use the target pointer-index +width for GEP; narrow unsigned values must not become negative through LLVM's +implicit sign extension or wider-index truncation. Checked constant-index +types/values remain intact in THIR/MIR. +Runtime indexes continue through the shared bounds guard before element access; +signed indexes also reject negative values independently of unsigned length. ## Validation boundaries diff --git a/docs/architecture/semantics-bindings.md b/docs/architecture/semantics-bindings.md index bc13e4f3..dbe71679 100644 --- a/docs/architecture/semantics-bindings.md +++ b/docs/architecture/semantics-bindings.md @@ -448,11 +448,24 @@ are `types.go`, `syntax.go`, `relations.go`, `compatibility.go`, `lookup.go`, - Evaluator cache and in-progress state are keyed by `symbols.SymbolID` and discarded after checking. - A module constant read from another module uses `ctx.PublishedConstant`. - Local module queries use the operation-local cache; authoritative values become visible through `SymbolIndex.ConstantValue` only during finalization. -- Expected numeric types influence literal construction and identifier adaptation. +- Expected numeric types influence literal construction and bound-symbol adaptation. +- Fixed-array bounds queries use the checked index type after literal inference; + constant-index evidence preserves that value and type for THIR/MIR lowering. +- Constant bounds compare exact integers, without host-sized length/index parsing. +- Checked numeric conversions run after folding at the source expression's width; + binary operands use the checked operation type, or the unconverted operand's + type for comparisons. Untyped lazy queries retain common-type fallback. +- Explicit numeric casts preserve source-width intermediates before converting. + Float narrowing rounds before any later widening; nonfinite or out-of-range + float-to-integer values are not published as wrapping integers. +- Integer-to-float casts round the exact integer directly to the destination + width, preserving IEEE infinity when the conversion overflows. - Numeric literals use default or explicit numeric types and target-aware parsing. - Boolean and string literals produce typed constant values. -- Constant identifiers consume their resolver-bound `SymbolIndex` entry and must - denote `SymbolConst`; evaluation never repeats lexical name lookup. +- Identifiers and qualified constant paths consume their resolver-bound `SymbolIndex` + entry and must denote `SymbolConst`; evaluation never repeats name/import lookup. + Foreign values come only from the defining module's finalized publication, not + consumer caches or a fresh evaluation of the foreign declaration. - Unary and binary operations delegate folding to `constvalue`. - Variant constructions use private typechecker evidence before THIR publication. - Non-copyable variant types are not evaluated as constants. diff --git a/internal/backend/llvm/emitter_test.go b/internal/backend/llvm/emitter_test.go index c33b1e91..14334c14 100644 --- a/internal/backend/llvm/emitter_test.go +++ b/internal/backend/llvm/emitter_test.go @@ -7,6 +7,7 @@ import ( "os/exec" "path" "path/filepath" + "runtime" "strings" "testing" "time" @@ -2914,6 +2915,57 @@ func TestGenerateLLVMIRLowersIndexPlaceForArrayRead(t *testing.T) { } } +func TestGenerateLLVMIRNormalizesConstantArrayIndexes(t *testing.T) { + u1 := llvmTypes.table.Intern(ir.Type{Kind: ir.TypeInteger, Bits: 1}) + for _, compilerTarget := range []target.Info{testLinux386, testLinuxAMD64} { + for _, test := range []struct { + name string + indexType ir.TypeID + value, length, conversion string + }{ + {"u1_zero", u1, "0", "2", "zext i1 0 to i64"}, + {"u1_one", u1, "1", "2", "zext i1 1 to i64"}, + {"u8_top_bit", llvmTypes.u8, "128", "129", "zext i8 128 to i64"}, + {"u24_top_bit", llvmTypes.table.Intern(ir.Type{Kind: ir.TypeInteger, Bits: 24}), "8388608", "8388609", "zext i24 8388608 to i64"}, + {"byte_top_bit", llvmTypes.table.Intern(ir.Type{Kind: ir.TypeByte}), "128", "129", "zext i8 128 to i64"}, + {"signed_i8", llvmTypes.i8, "1", "2", "sext i8 1 to i64"}, + {"u64", llvmTypes.usize, "1", "2", ""}, + {"i128", llvmTypes.table.Intern(ir.Type{Kind: ir.TypeInteger, IsSigned: true, Bits: 128}), "1", "2", "trunc i128 1 to i64"}, + {"u128", llvmTypes.u128, "1", "2", "trunc i128 1 to i64"}, + } { + for _, operation := range []struct { + name string + build func(ir.TypeID, mir.ValueRef) *mir.Module + }{{"read", indexReadMIRModule}, {"store", indexStoreMIRModule}} { + t.Run(compilerTarget.Arch+"/"+test.name+"/"+operation.name, func(t *testing.T) { + array := llvmTypes.table.Intern(ir.Type{Kind: ir.TypeArray, Elem: llvmTypes.i32, Length: test.length}) + base := llvmTypes.table.Intern(ir.Type{Kind: ir.TypeReference, Elem: array, IsMutable: true}) + diag := diagnostics.NewDiagnosticBag() + text := GenerateLLVMIR(operation.build(base, &mir.RefConst{Value: test.value, Type: test.indexType}), diag, compilerTarget, false) + if diag.HasErrors() || text == "" { + t.Fatalf("valid constant index failed:\n%s", diag.EmitAllToString()) + } + if test.conversion != "" && !strings.Contains(text, test.conversion) { + t.Fatalf("missing value-preserving conversion %q:\n%s", test.conversion, text) + } + projections := 0 + for _, line := range strings.Split(text, "\n") { + if strings.Contains(line, "getelementptr inbounds ["+test.length+" x i32]") { + projections++ + if !strings.Contains(line, ", i32 0, i64 ") { + t.Fatalf("constant element GEP did not use normalized index:\n%s", text) + } + } + } + if projections != 1 || strings.Contains(text, "call void @llvm.trap()") { + t.Fatalf("constant index must have one GEP and no runtime bounds trap:\n%s", text) + } + }) + } + } + } +} + func TestGenerateLLVMIRBoundsChecksRuntimeFixedArrayIndex(t *testing.T) { const targetTriple = "x86_64-unknown-linux-gnu" mod := indexReadMIRModule(llvmTypes.fixed4I32, &mir.RefName{Name: "index", Type: llvmTypes.i32}) @@ -3017,6 +3069,98 @@ func TestGenerateLLVMIRRejectsInvalidConstantArrayIndexes(t *testing.T) { } } +func TestGenerateLLVMIRChecksUnsignedArrayBounds(t *testing.T) { + large := llvmTypes.table.Intern(ir.Type{Kind: ir.TypeArray, Elem: llvmTypes.i32, Length: "9223372036854775808"}) + for _, index := range []struct { + value string + rejected bool + }{ + {"0", false}, {"9223372036854775807", false}, + {"-1", true}, {"9223372036854775808", true}, {"18446744073709551616", true}, {"bad", true}, + } { + t.Run(index.value, func(t *testing.T) { + location := source.NewLocation(unixTestPath, source.Position{Line: 2, Column: 4}, source.Position{Line: 2, Column: 8}) + diag := diagnostics.NewDiagnosticBag() + text := GenerateLLVMIR(indexReadMIRModule(large, &mir.RefConst{ + Value: index.value, Type: llvmTypes.u128, Location: location, + }), diag, testLinuxAMD64, false) + if !index.rejected { + if diag.HasErrors() || text == "" { + t.Fatalf("valid unsigned bounds rejected:\n%s", diag.EmitAllToString()) + } + return + } + items := diag.Diagnostics() + if text != "" || len(items) != 1 || items[0].Code != diagnostics.ErrArrayOutOfBounds || + len(items[0].Labels) != 1 || items[0].Labels[0].Location != location { + t.Fatalf("expected located backend bounds rejection:\n%s", diag.EmitAllToString()) + } + }) + } +} + +func TestBoundsCheckedIndexHandlesUnsignedLengths(t *testing.T) { + if runtime.GOOS != "linux" || runtime.GOARCH != "amd64" { + t.Skip("native bounds execution requires Linux/amd64") + } + clang, err := exec.LookPath("clang") + if err != nil { + t.Skip("clang unavailable for native bounds validation") + } + types := newLLVMTypeFixture(target.Bits64) + i64 := types.table.Intern(ir.Type{Kind: ir.TypeInteger, IsSigned: true, Bits: 64}) + i128 := types.table.Intern(ir.Type{Kind: ir.TypeInteger, IsSigned: true, Bits: 128}) + for _, test := range []struct { + name string + indexType ir.TypeID + value string + trap bool + }{ + {"negative_i8", types.i8, "-2", true}, + {"negative_i64", i64, "-9223372036854775808", true}, + {"negative_i128", i128, "-2", true}, + {"positive_i64", i64, "9223372036854775807", false}, + {"unsigned_high", types.usize, "9223372036854775808", false}, + {"last_unsigned", types.usize, "18446744073709551614", false}, + {"equal_unsigned", types.usize, "18446744073709551615", true}, + {"wide_unsigned", types.u128, "18446744073709551616", true}, + } { + t.Run(test.name, func(t *testing.T) { + var body strings.Builder + emitter := &llvmEmitter{mod: &mir.Module{Types: types.table}, target: testLinuxAMD64} + builder := newLLVMBuilder(&body, emitter, -1) + indexLayout := emitter.layout(test.indexType) + builder.locals["index"] = builder.value("%index", indexLayout) + builder.namedLabel("entry") + if _, ok := emitBoundsCheckedIndex(builder, &mir.RefName{Name: "index", Type: test.indexType}, + builder.value("18446744073709551615", llvmScalarLayout("i64"))); !ok || emitter.isInvalid { + t.Fatal("bounds lowering failed") + } + builder.line("ret i32 0") + text := fmt.Sprintf("declare void @llvm.trap()\ndefine i32 @check(%s %%index) {\n%s}\n"+ + "define i32 @main() {\n %%result = call i32 @check(%s %s)\n ret i32 %%result\n}\n", + indexLayout.Text, body.String(), indexLayout.Text, test.value) + dir := t.TempDir() + binary := filepath.Join(dir, "bounds") + cmd := exec.Command(clang, "-target", testLinuxAMD64.LLVMTriple, "-x", "ir", "-o", binary, "-") + cmd.Stdin = strings.NewReader(text) + if output, err := cmd.CombinedOutput(); err != nil { + t.Fatalf("bounds LLVM is invalid: %v\n%s\n%s", err, output, text) + } + run := exec.Command(binary) + run.Dir = dir + output, err := run.CombinedOutput() + if test.trap { + if run.ProcessState == nil || run.ProcessState.ExitCode() != -1 { + t.Fatalf("expected bounds trap, got %v\n%s\n%s", err, output, text) + } + } else if err != nil { + t.Fatalf("valid index trapped: %v\n%s\n%s", err, output, text) + } + }) + } +} + func TestGenerateLLVMIRLowersDynamicArrayIndexRead(t *testing.T) { const targetTriple = "x86_64-unknown-linux-gnu" irText := GenerateLLVMIR(indexReadMIRModule(llvmTypes.dynamicI32, &mir.RefName{Name: "i", Type: llvmTypes.i32}), diagnostics.NewDiagnosticBag(), testLinuxAMD64, false) diff --git a/internal/backend/llvm/instruction_emit.go b/internal/backend/llvm/instruction_emit.go index fb893e86..1d0978d7 100644 --- a/internal/backend/llvm/instruction_emit.go +++ b/internal/backend/llvm/instruction_emit.go @@ -107,8 +107,6 @@ func emitPrint(b *llvmBuilder, printInstr *mir.Print) { } } -// emitTargetIndexAsI64 widens a target-sized length before it reaches lowering -// paths whose arithmetic and comparisons are intentionally i64. func emitIndexPtr(b *llvmBuilder, base llvmValue, baseType ir.TypeID, addressed bool, indexRef mir.ValueRef) (llvmPlace, bool) { if b == nil || base.Layout == nil || baseType == ir.InvalidType || indexRef == nil { return llvmPlace{}, false @@ -143,18 +141,20 @@ func emitIndexPtr(b *llvmBuilder, base llvmValue, baseType ir.TypeID, addressed } return b.gep(b.pointerPlace(data), index, false), true } - length, lengthErr := strconv.Atoi(target.Length) + length, lengthErr := strconv.ParseUint(target.Length, 10, 64) var index llvmValue if indexConst, isConstant := indexRef.(*mir.RefConst); isConstant { - parsedIndex, indexErr := strconv.Atoi(indexConst.Value) - if lengthErr != nil || indexErr != nil || parsedIndex < 0 || parsedIndex >= length { + parsedIndex, indexOK := new(big.Int).SetString(indexConst.Value, 10) + if lengthErr != nil || !indexOK || parsedIndex.Sign() < 0 || parsedIndex.Cmp(new(big.Int).SetUint64(length)) >= 0 { b.emitter.isInvalid = true if b.emitter.diag != nil { - b.emitter.diag.Add(problems.ArrayIndexOutOfBounds(indexConst.Value, target.Length, nil)) + b.emitter.diag.Add(problems.ArrayIndexOutOfBounds(indexConst.Value, target.Length, indexConst.SourceLocation())) } return llvmPlace{}, false } - index = emitRef(b, indexRef) + // GEP (getelementptr) sign-extends narrow indexes even when the source integer is unsigned. + u64 := b.emitter.mod.Types.Intern(ir.Type{Kind: ir.TypeInteger, Bits: 64}) + index = emitCast(b, &mir.Cast{Arg: indexRef, Type: u64}) } else { if lengthErr != nil { return llvmPlace{}, false @@ -169,7 +169,14 @@ func emitIndexPtr(b *llvmBuilder, base llvmValue, baseType ir.TypeID, addressed return llvmPlace{}, false } arrayPlace := b.pointerPlace(base) - return b.arrayElement(arrayPlace, index, true), true + physicalIndex := index + physicalLayout := b.emitter.layout(b.emitter.mod.Types.IndexType()) + if physicalIndex.Layout.Text != physicalLayout.Text { + // Bounds use i64 so wide values stay exact; GEP must receive the target + // pointer-index width so inbounds does not implicitly truncate it. + physicalIndex = b.cast("trunc", physicalIndex, physicalLayout) + } + return b.arrayElement(arrayPlace, physicalIndex, true), true } // Directly addressed roots need entry-block storage so one pointer dominates every place use. diff --git a/internal/backend/llvm/string_slice_emit.go b/internal/backend/llvm/string_slice_emit.go index 4b2e0a57..44a895e5 100644 --- a/internal/backend/llvm/string_slice_emit.go +++ b/internal/backend/llvm/string_slice_emit.go @@ -48,8 +48,12 @@ func emitBoundsCheckedIndex(b *llvmBuilder, indexRef mir.ValueRef, length llvmVa if !ok { return llvmValue{}, false } - // Unsigned comparison also rejects negative signed indexes after sign extension. outOfBounds := b.compare("icmp", "uge", compareIndex, compareLength) + // Unsigned lengths can exceed the signed index range. + if !isUnsignedTypeID(b.emitter.mod.Types, indexRef.TypeID()) { + negative := b.compare("icmp", "slt", compareIndex, b.value("0", compareIndex.Layout)) + outOfBounds = b.arithmetic("or", outOfBounds, negative) + } boundsID := b.nextID b.nextID++ failLabel := fmt.Sprintf("bounds_fail_%d", boundsID) diff --git a/internal/ir/mir/module_lower.go b/internal/ir/mir/module_lower.go index fe6086a5..78d4bc0a 100644 --- a/internal/ir/mir/module_lower.go +++ b/internal/ir/mir/module_lower.go @@ -64,28 +64,46 @@ func GenerateMIR(input LoweringInput) *Module { Funcs: make([]*Function, 0, len(input.Source.Functions)), } - // All semantic constant types must be interned before ABI-key lookup below. if input.Scope != nil { - for _, symbol := range input.Scope.Symbols() { - if symbol != nil && symbol.Kind == symbols.SymbolConst { - semanticType, _ := symbols.GetSymbolType(symbol) - if typelower.Type(input.Types, input.Diagnostics, semanticType) == ir.InvalidType { - return nil - } - } - } for _, symbol := range input.Scope.Symbols() { if symbol == nil || symbol.Kind != symbols.SymbolConst { continue } + // Discard bindings are checked, but have no addressable static value. + if symbol.Name == "_" { + continue + } + semanticType, _ := symbols.GetSymbolType(symbol) + typ := typelower.Type(input.Types, input.Diagnostics, semanticType) + if typ == ir.InvalidType { + return nil + } var value constvalue.Value if input.SymbolIndex != nil { value = input.SymbolIndex.ConstantValue(symbol.ID) } - internConstantStrings(out, value) - if entry, ok := staticEntryForConst(input.Types, symbol, value); ok { - out.StaticData = append(out.StaticData, entry) + if value == nil { + if input.Diagnostics != nil { + input.Diagnostics.AddError(diagnostics.ErrInvalidOperation, + "module constant `"+symbol.Name+"` cannot be evaluated at compile time", symbol.Location, "") + } + return nil + } + abiKey := value.TypeText() + if variant, ok := value.(*constvalue.VariantConst); ok && variant != nil && variant.NominalIdentity() != "" { + abiKey = "variant:" + variant.NominalIdentity() } + if abiKey != input.Types.ABIKey(typ) { + if input.Diagnostics != nil { + input.Diagnostics.AddError(diagnostics.ErrInvalidEvidence, + fmt.Sprintf("module constant `%s` has value type %s, want %s", symbol.Name, value.TypeText(), typeinfo.TypeText(semanticType)), symbol.Location, "") + } + return nil + } + internConstantStrings(out, value) + out.StaticData = append(out.StaticData, &StaticEntry{ + Name: fmt.Sprintf("@%s$%s", symbol.Name, symbol.ID.String()), Type: typ, Constant: value, + }) } } @@ -428,22 +446,6 @@ func variantPayloadPlace(subject ValueRef, caseIndex int, payloadType ir.TypeID, } } -func staticEntryForConst(types *ir.TypeTable, symbol *symbols.Symbol, value constvalue.Value) (*StaticEntry, bool) { - if types == nil || symbol == nil || value == nil { - return nil, false - } - typeText := value.TypeText() - abiKey := typeText - if variant, ok := value.(*constvalue.VariantConst); ok && variant != nil && variant.NominalIdentity() != "" { - abiKey = "variant:" + variant.NominalIdentity() - } - typ, ok := types.LookupABIKey(abiKey) - if !ok { - return nil, false - } - return &StaticEntry{Name: fmt.Sprintf("@%s$%s", symbol.Name, symbol.ID.String()), Type: typ, Constant: value}, true -} - func internConstantStrings(module *Module, value constvalue.Value) { if module == nil || value == nil { return diff --git a/internal/ir/mir/module_lower_test.go b/internal/ir/mir/module_lower_test.go index 59ad0688..8bbc5254 100644 --- a/internal/ir/mir/module_lower_test.go +++ b/internal/ir/mir/module_lower_test.go @@ -1,17 +1,19 @@ package mir import ( - "compiler/internal/constvalue" - "compiler/internal/ir/cfg" "testing" + "compiler/internal/constvalue" + "compiler/internal/diagnostics" "compiler/internal/frontend/ast" "compiler/internal/ir" + "compiler/internal/ir/cfg" "compiler/internal/ir/exprlower" "compiler/internal/ir/thir" "compiler/internal/moduleid" "compiler/internal/semantics/symbols" "compiler/internal/semantics/typeinfo" + "compiler/internal/source" ) func TestExternalFunctionSignatureUsesPublishedLinkName(t *testing.T) { @@ -88,3 +90,48 @@ func TestGenerateMIREmitsStaticConstantFromSymbolIndex(t *testing.T) { t.Fatalf("static constant = %#v, want %#v", out.StaticData[0].Constant, value) } } + +func TestGenerateMIRRejectsUnmaterializableModuleConstants(t *testing.T) { + for _, test := range []struct { + name, valueType, code string + internValueType bool + }{ + {name: "missing value", code: diagnostics.ErrInvalidOperation}, + {name: "mismatched value", valueType: "i8", code: diagnostics.ErrInvalidEvidence}, + {name: "mismatched interned value", valueType: "i8", code: diagnostics.ErrInvalidEvidence, internValueType: true}, + } { + t.Run(test.name, func(t *testing.T) { + symbol := symbols.New(symbols.ProjectedSymbolID(symbols.SymbolConst, "Value"), "Value", symbols.SymbolConst, nil, nil) + symbol.BindType(&typeinfo.IntegerType{IsSigned: true, Bits: 64}) + symbol.Location = source.NewLocation("test.peep", source.Position{Line: 1, Column: 1}, source.Position{Line: 1, Column: 6}) + scope := symbols.NewScope(nil) + if err := scope.Declare(symbol); err != nil { + t.Fatal(err) + } + index := symbols.NewIndex() + if test.valueType != "" { + value, ok := constvalue.NewIntText("2", test.valueType) + if !ok { + t.Fatal("cannot construct test constant") + } + index.PublishConstant(symbol.ID, value) + } + types := ir.NewTypeTable() + if test.internValueType { + types.Intern(ir.Type{Kind: ir.TypeInteger, IsSigned: true, Bits: 8}) + } + diag := diagnostics.NewDiagnosticBag() + out := GenerateMIR(LoweringInput{ + Types: types, Diagnostics: diag, Source: thir.NewModule("test", "test.peep", nil), + CFG: &cfg.Module{}, Scope: scope, SymbolIndex: index, + }) + if out != nil || !diag.HasErrors() { + t.Fatalf("MIR = %#v, diagnostics = %s; want materialization rejection", out, diag.EmitAllToString()) + } + items := diag.Diagnostics() + if len(items) != 1 || items[0].Code != test.code || len(items[0].Labels) != 1 || items[0].Labels[0].Location != symbol.Location { + t.Fatalf("diagnostics = %#v, want %s at constant declaration", items, test.code) + } + }) + } +} diff --git a/internal/ir/types.go b/internal/ir/types.go index 8b9c4239..60a1f6a9 100644 --- a/internal/ir/types.go +++ b/internal/ir/types.go @@ -128,7 +128,6 @@ type TypeTable struct { mu sync.RWMutex types []Type ids map[string]TypeID - abiKeys map[string]TypeID isComplete []bool indexType TypeID } @@ -137,7 +136,6 @@ func NewTypeTable() *TypeTable { return &TypeTable{ types: []Type{{Name: ""}}, ids: make(map[string]TypeID), - abiKeys: make(map[string]TypeID), isComplete: []bool{false}, } } @@ -156,7 +154,6 @@ func (t *TypeTable) Intern(typ Type) TypeID { t.types = append(t.types, cloneType(typ)) t.isComplete = append(t.isComplete, true) t.ids[key] = id - t.abiKeys[t.abiKeyLocked(id)] = id return id } @@ -210,23 +207,9 @@ func (t *TypeTable) CompleteNamed(id TypeID, typ Type) error { } t.types[id] = cloneType(typ) t.isComplete[id] = true - t.abiKeys[t.abiKeyLocked(id)] = id return nil } -// LookupABIKey bridges finalized semantic identity into an already-interned IR -// type. It never parses text or creates a type; typelower remains the only -// semantic-to-runtime-IR type construction boundary. -func (t *TypeTable) LookupABIKey(key string) (TypeID, bool) { - if t == nil { - return InvalidType, false - } - t.mu.RLock() - defer t.mu.RUnlock() - id, ok := t.abiKeys[key] - return id, ok -} - func (t *TypeTable) SetIndexType(id TypeID) { if t == nil { panic("setting invalid IR index type") @@ -428,12 +411,7 @@ func (t *TypeTable) ABIKey(id TypeID) string { if id == InvalidType || int(id) >= len(t.types) { return "" } - return t.abiKeyLocked(id) -} - -func (t *TypeTable) abiKeyLocked(id TypeID) string { - typ := t.types[id] - if key, ok := identifiedTypeKey(typ); ok { + if key, ok := identifiedTypeKey(t.types[id]); ok { return key } return t.textLocked(id) diff --git a/internal/ir/types_test.go b/internal/ir/types_test.go index ddd01885..cce52ac8 100644 --- a/internal/ir/types_test.go +++ b/internal/ir/types_test.go @@ -32,9 +32,8 @@ func TestTypeTableConcurrentInterningAndReads(t *testing.T) { results <- result{err: fmt.Sprintf("Text(%d) = %q, want %q", unique, got, uniqueName)} return } - abiKey := types.ABIKey(unique) - if got, ok := types.LookupABIKey(abiKey); !ok || got != unique { - results <- result{err: fmt.Sprintf("LookupABIKey(%q) = (%d, %t), want (%d, true)", abiKey, got, ok, unique)} + if got := types.ABIKey(unique); got != "struct:test::"+uniqueName { + results <- result{err: fmt.Sprintf("ABIKey(%d) = %q, want %q", unique, got, "struct:test::"+uniqueName)} return } if typ, ok := types.Type(shared); !ok || typ.Kind != TypeStruct { @@ -147,6 +146,9 @@ func TestTypeTableReservesAndCompletesRecursiveNamedComposite(t *testing.T) { if _, complete := types.Type(nodeID); complete { t.Fatal("reserved named type was visible before completion") } + if got := types.ABIKey(nodeID); got != "struct:test::Node" { + t.Fatalf("reserved recursive ABI key = %q, want struct:test::Node", got) + } if repeated, err := types.ReserveNamed(nodeShell); err != nil || repeated != nodeID { t.Fatalf("repeated reservation = (%d, %v), want (%d, nil)", repeated, err, nodeID) } diff --git a/internal/pipeline/pipeline_test.go b/internal/pipeline/pipeline_test.go index 32e397b4..3d811e56 100644 --- a/internal/pipeline/pipeline_test.go +++ b/internal/pipeline/pipeline_test.go @@ -16,6 +16,7 @@ import ( "compiler/internal/graph" "compiler/internal/ir/cfg" "compiler/internal/ir/mir" + "compiler/internal/ir/thir" "compiler/internal/module" "compiler/internal/moduleid" "compiler/internal/phase" @@ -23,6 +24,7 @@ import ( "compiler/internal/project" "compiler/internal/semantics/intrinsics" "compiler/internal/semantics/symbols" + "compiler/internal/semantics/typeinfo" "compiler/internal/target" "compiler/pkg/manifest" "compiler/pkg/peeper" @@ -100,36 +102,108 @@ func buildPipelineTestWithConfig(t *testing.T, cfg project.Config, preludeSrc, e return diag } +func assertLLVMCompiles(t *testing.T, compilerTarget target.Info, text string) { + t.Helper() + clang, err := exec.LookPath("clang") + if err != nil { + t.Skip("clang unavailable for LLVM IR validation") + } + cmd := exec.Command(clang, "-target", compilerTarget.LLVMTriple, "-x", "ir", "-c", "-o", filepath.Join(t.TempDir(), "module.o"), "-") + cmd.Stdin = strings.NewReader(text) + if output, err := cmd.CombinedOutput(); err != nil { + t.Fatalf("%s LLVM is invalid: %v\n%s\n%s", compilerTarget.LLVMTriple, err, output, text) + } +} + func runImportedRuntimeSymbolPipeline(t *testing.T, entrySrc, runtimeSrc string) *diagnostics.DiagnosticBag { + t.Helper() + ctx, entry := importedPipelineContext(t, "", map[string]string{ + peeper.MainFileName: entrySrc, "runtime" + peeper.SourceExt: runtimeSrc, + }) + if err := Run(ctx, entry); err != nil { + t.Fatalf("pipeline.Run returned error: %v", err) + } + return ctx.Diagnostics +} + +func importedPipelineContext(t *testing.T, arch string, sources map[string]string) (*project.CompilerContext, *module.Module) { t.Helper() root := t.TempDir() srcDir := filepath.Join(root, peeper.SourceDirName) - entryPath := filepath.Join(srcDir, peeper.MainFileName) - runtimePath := filepath.Join(srcDir, "runtime"+peeper.SourceExt) if err := os.MkdirAll(srcDir, 0o755); err != nil { t.Fatalf("mkdir source dir: %v", err) } - if err := os.WriteFile(entryPath, []byte(entrySrc), 0o644); err != nil { - t.Fatalf("write entry: %v", err) - } - if err := os.WriteFile(runtimePath, []byte(runtimeSrc), 0o644); err != nil { - t.Fatalf("write runtime module: %v", err) + for name, src := range sources { + if err := os.WriteFile(filepath.Join(srcDir, name), []byte(src), 0o644); err != nil { + t.Fatalf("write %s: %v", name, err) + } } diag := diagnostics.NewDiagnosticBag() - ctx := project.NewWithConfig(project.Config{ + cfg := project.Config{ RootDir: root, ProjectName: "app", Extension: peeper.SourceExt, - }, diag) + TargetArch: arch, + } + if arch != "" { + cfg.TargetOS = "linux" + } + ctx := project.NewWithConfig(cfg, diag) entry := &module.Module{ ID: moduleid.ID{Origin: string(project.ModuleOriginLocal), ImportPath: "app/main"}, - FilePath: entryPath, + FilePath: filepath.Join(srcDir, peeper.MainFileName), } - if err := Run(ctx, entry); err != nil { - t.Fatalf("pipeline.Run returned error: %v", err) + return ctx, entry +} + +func assertCheckedConstantIndex(t *testing.T, entry *module.Module, wantValue, wantType string) { + t.Helper() + if entry.THIR == nil || entry.MIR == nil || entry.LLVMIR == "" { + t.Fatal("constant index did not reach THIR, MIR and backend") + } + accesses := 0 + for _, stmt := range entry.AST.Stmts { + ast.Inspect(stmt, func(node ast.Node) bool { + if index, ok := node.(*ast.IndexExpr); ok { + accesses++ + checked := entry.THIR.Node(index.ID()).(*thir.Index) + if checked.Constant == nil || checked.Constant.Text != wantValue || + !typeinfo.IsSameType(checked.Constant.Type, checked.Index.ExprType()) || typeinfo.TypeText(typeinfo.Underlying(checked.Constant.Type)) != wantType { + t.Fatalf("THIR index = %#v, want %s %s", checked.Constant, wantType, wantValue) + } + } + return true + }) + } + projections := 0 + for _, function := range entry.MIR.Funcs { + for _, block := range function.Blocks { + for _, instruction := range block.Instrs { + assign, ok := instruction.(*mir.Assign) + if !ok { + continue + } + load, ok := assign.Value.(*mir.Load) + if !ok || load.Place == nil { + continue + } + for _, projection := range load.Place.Projections { + if projection.Kind != mir.PlaceProjectionIndex { + continue + } + projections++ + index, ok := projection.Index.(*mir.RefConst) + if !ok || index.Value != wantValue || entry.MIR.Types.Text(index.TypeID()) != wantType { + t.Fatalf("MIR index = %#v, want %s %s", projection.Index, wantType, wantValue) + } + } + } + } + } + if accesses != 1 || projections != 1 { + t.Fatalf("index counts: AST %d, MIR %d, want one each", accesses, projections) } - return diag } // TestPipelinePreludeSymbolsVisibleInEntry verifies that prelude-defined symbols @@ -244,18 +318,11 @@ fn main() { if !foundCompare || !foundIndexMove || !foundProjection { t.Fatalf("MIR target-width evidence missing: compare=%v index=%v projection=%v\n%s", foundCompare, foundIndexMove, foundProjection, entry.MIR.Text()) } - if !strings.Contains(entry.LLVMIR, "icmp ult "+test.llvmType) || strings.Contains(entry.LLVMIR, "trunc i64") { + physicalTrunc := strings.Contains(entry.LLVMIR, "trunc i64") + if !strings.Contains(entry.LLVMIR, "icmp ult "+test.llvmType) || physicalTrunc != (test.arch == "386") { t.Fatalf("LLVM index width invalid for %s:\n%s", test.arch, entry.LLVMIR) } - clang, err := exec.LookPath("clang") - if err != nil { - t.Skip("clang unavailable for LLVM IR validation") - } - cmd := exec.Command(clang, "-target", targetInfo.LLVMTriple, "-x", "ir", "-c", "-o", filepath.Join(t.TempDir(), "for-loop.o"), "-") - cmd.Stdin = strings.NewReader(entry.LLVMIR) - if output, err := cmd.CombinedOutput(); err != nil { - t.Fatalf("%s for-loop LLVM is invalid: %v\n%s\n%s", test.arch, err, output, entry.LLVMIR) - } + assertLLVMCompiles(t, targetInfo, entry.LLVMIR) }) if diag.HasErrors() { t.Fatalf("unexpected diagnostics:\n%s", diag.EmitAllToString()) @@ -1038,6 +1105,79 @@ fn main() -> i32 { return Value; } } } +func TestPipelineMaterializesNumericConstantsAcrossTargets(t *testing.T) { + for _, arch := range []string{"386", "amd64"} { + for _, test := range []struct{ name, source, typ, value string }{ + {"mixed widths", "const Value: i64 = 1i32 + 2i64;", "i64", "3"}, + {"narrow initializer", "const Value: i64 = 2i8;", "i64", "2"}, + {"narrow type interned", "const Narrow: i8 = 1i8; const Value: i64 = 2i8;", "i64", "2"}, + {"float narrowing", "const Value: f32 = 1.25f64 as f32;", "f32", "1.25"}, + {"source overflow", "const Value: i64 = 127i8 + 1i8;", "i64", "-128"}, + {"integer float rounding", "const Value: f32 = 9223372586610589697u64 as f32;", "f32", "9.223373e+18"}, + {"integer float infinity", "const Value: f64 = (1u2048 << 1024u2048) as f64;", "f64", "+Inf"}, + } { + t.Run(arch+"/"+test.name, func(t *testing.T) { + source := test.source + "\nfn Read(value: " + test.typ + ") -> " + test.typ + " { return value; }\nfn main() { let value = Read(Value); }" + diag := buildPipelineTestWithConfig(t, project.Config{ + RootDir: ".", Extension: peeper.SourceExt, TargetOS: "linux", TargetArch: arch, + }, "", source, func(entry *module.Module) { + if entry.MIR == nil || entry.LLVMIR == "" { + t.Fatal("constant did not reach backend") + } + symbol, found := entry.ModuleScope.LookupLocal("Value") + if !found || symbol == nil { + t.Fatal("missing constant Value") + } + name := "@Value$" + symbol.ID.String() + var static *mir.StaticEntry + for _, candidate := range entry.MIR.StaticData { + if candidate.Name == name { + static = candidate + } + } + if static == nil || entry.MIR.Types.Text(static.Type) != test.typ || static.Constant != entry.SymbolIndex.ConstantValue(symbol.ID) { + t.Fatalf("static = %#v, want authoritative %s constant", static, test.typ) + } + text, ok := static.Constant.(interface{ Text() string }) + if !ok || text.Text() != test.value || static.Constant.TypeText() != test.typ { + t.Fatalf("static value = %#v, want %s %s", static.Constant, test.value, test.typ) + } + if !strings.Contains(entry.LLVMIR, name+" = constant ") { + t.Fatal("LLVM lacks required constant definition") + } + targetInfo, err := target.New("linux", arch) + if err != nil { + t.Fatal(err) + } + assertLLVMCompiles(t, targetInfo, entry.LLVMIR) + }) + if diag.HasErrors() { + t.Fatalf("unexpected diagnostics:\n%s", diag.EmitAllToString()) + } + }) + } + } +} + +func TestPipelineOmitsDiscardConstantStaticsAcrossTargets(t *testing.T) { + for _, arch := range []string{"386", "amd64"} { + t.Run(arch, func(t *testing.T) { + diag := buildPipelineTestWithConfig(t, project.Config{ + RootDir: ".", Extension: peeper.SourceExt, TargetOS: "linux", TargetArch: arch, + }, "", `const _: i32 = 1i32; +const _: f64 = 1.25f32; +fn main() -> i32 { return 0; }`, func(entry *module.Module) { + if entry.MIR == nil || entry.LLVMIR == "" || len(entry.MIR.StaticData) != 0 { + t.Fatalf("discard bindings require no static data: MIR=%#v LLVM=%v", entry.MIR, entry.LLVMIR != "") + } + }) + if diag.HasErrors() { + t.Fatalf("unexpected diagnostics:\n%s", diag.EmitAllToString()) + } + }) + } +} + func TestTypecheckedPhaseFinalizesNamedVariantConstants(t *testing.T) { diag := diagnostics.NewDiagnosticBag() const entryPath = "entry" + peeper.SourceExt @@ -1891,15 +2031,7 @@ fn main() -> i32 { t.Fatalf("reference-recursive enum stopped before backend: phase=%v\n%s", entry.Phase, diag.EmitAllToString()) } - clang, err := exec.LookPath("clang") - if err != nil { - t.Skip("clang unavailable for LLVM IR validation") - } - cmd := exec.Command(clang, "-target", ctx.Target.LLVMTriple, "-x", "ir", "-c", "-o", filepath.Join(t.TempDir(), "recursive-enum.o"), "-") - cmd.Stdin = strings.NewReader(entry.LLVMIR) - if output, err := cmd.CombinedOutput(); err != nil { - t.Fatalf("%s reference-recursive enum LLVM is invalid: %v\n%s\n%s", arch, err, output, entry.LLVMIR) - } + assertLLVMCompiles(t, ctx.Target, entry.LLVMIR) }) } } @@ -2419,6 +2551,435 @@ fn first() -> i32 { } } +func TestPipelineRejectsWideConstantIndexesAcrossTargets(t *testing.T) { + for _, arch := range []string{"386", "amd64"} { + for _, test := range []struct{ name, declaration, index, value string }{ + {"float cast", "", "4294967296f64 as u64", "4294967296"}, + {"negative float cast", "", "(-4294967296f64) as i64", "-4294967296"}, + {"local constant", "const Offset: i64 = 4294967296i64;", "Offset", "4294967296"}, + {"negative local constant", "const Offset: i64 = -4294967296i64;", "Offset", "-4294967296"}, + } { + t.Run(arch+"/"+test.name, func(t *testing.T) { + var entry *module.Module + diag := buildPipelineTestWithConfig(t, project.Config{ + RootDir: ".", Extension: peeper.SourceExt, TargetOS: "linux", TargetArch: arch, + }, "", "fn main() -> i32 { "+test.declaration+" let values = [1]i32{7}; return values["+test.index+"]; }", func(mod *module.Module) { + entry = mod + }) + items := diag.Diagnostics() + if len(items) != 1 || items[0].Code != diagnostics.ErrArrayOutOfBounds || + items[0].Message != "array index out of bounds: index "+test.value+" for length 1" || + len(items[0].Labels) != 1 || items[0].Labels[0].Location == nil { + t.Fatalf("expected located bounds diagnostic, got:\n%s", diag.EmitAllToString()) + } + if entry.MIR != nil || entry.LLVMIR != "" { + t.Fatal("invalid constant index reached backend") + } + }) + } + } +} + +func TestPipelineRejectsHostSizedConstantBounds(t *testing.T) { + for _, index := range []struct{ source, value string }{ + {"-1i64", "-1"}, + {"9223372036854775808u64", "9223372036854775808"}, + {"18446744073709551616u128", "18446744073709551616"}, + } { + t.Run(index.source, func(t *testing.T) { + var entry *module.Module + diag := buildPipelineTestWithConfig(t, project.Config{ + RootDir: ".", Extension: peeper.SourceExt, TargetOS: "linux", TargetArch: "amd64", + }, "", "fn read(xs: [9223372036854775808u64]u8) -> u8 { return xs["+index.source+"]; }", func(mod *module.Module) { + entry = mod + }) + fn := entry.AST.Stmts[0].(*ast.FnDecl) + access := fn.Body.Stmts[0].(*ast.ReturnStmt).Value.(*ast.IndexExpr) + items := diag.Diagnostics() + if len(items) != 1 || items[0].Code != diagnostics.ErrArrayOutOfBounds || + items[0].Message != "array index out of bounds: index "+index.value+" for length 9223372036854775808" || + len(items[0].Labels) != 1 || items[0].Labels[0].Location != ast.LocOf(access.Index) { + t.Fatalf("expected source-located exact bounds diagnostic:\n%s", diag.EmitAllToString()) + } + if entry.MIR != nil || entry.LLVMIR != "" { + t.Fatal("invalid large-array index reached backend") + } + }) + } +} + +func TestPipelineLowersLargeArrayBoundsAcrossTargets(t *testing.T) { + for _, compilerTarget := range []struct{ arch, length string }{ + {"386", "2147483648"}, {"amd64", "9223372036854775808"}, {"amd64", "9223372036854775809"}, + } { + for _, index := range []string{"0u64", "index"} { + t.Run(compilerTarget.arch+"/"+compilerTarget.length+"/"+index, func(t *testing.T) { + params := "xs: &Large" + if index == "index" { + params += ", index: i64" + } + src := "struct Large { values: [" + compilerTarget.length + "u64]u8, flag: bool }\n" + + "fn read(" + params + ") -> u8 { return xs.values[" + index + "]; }" + var entry *module.Module + diag := buildPipelineTestWithConfig(t, project.Config{ + RootDir: ".", Extension: peeper.SourceExt, TargetOS: "linux", TargetArch: compilerTarget.arch, + }, "", src, func(mod *module.Module) { entry = mod }) + if diag.HasErrors() || entry.MIR == nil || entry.LLVMIR == "" { + t.Fatalf("large array access failed:\n%s", diag.EmitAllToString()) + } + if index == "index" && (!strings.Contains(entry.LLVMIR, "icmp uge i64") || !strings.Contains(entry.LLVMIR, "icmp slt i64") || + !strings.Contains(entry.LLVMIR, compilerTarget.length) || !strings.Contains(entry.LLVMIR, "call void @llvm.trap()")) { + t.Fatalf("large array runtime guard missing:\n%s", entry.LLVMIR) + } + targetInfo, err := target.New("linux", compilerTarget.arch) + if err != nil { + t.Fatal(err) + } + assertLLVMCompiles(t, targetInfo, entry.LLVMIR) + }) + } + } +} + +func TestPipelineLowersHighBitZeroSizedArrayIndexesAtPhysicalWidth(t *testing.T) { + for _, test := range []struct { + arch, length, lowIndex, highIndex, physicalIndex string + }{ + {"386", "2147483649", "2147483647", "2147483648", "i32"}, + {"amd64", "9223372036854775809", "9223372036854775807", "9223372036854775808", "i64"}, + } { + t.Run(test.arch, func(t *testing.T) { + var entry *module.Module + src := "struct Empty {}\n" + + "struct Large { values: [" + test.length + "u64]Empty }\n" + + "fn constant_low(value: &Large) -> bool { return @value.values[" + test.lowIndex + "u64] == @value.values[0u64]; }\n" + + "fn constant_high(value: &Large) -> bool { return @value.values[" + test.highIndex + "u64] == @value.values[0u64]; }\n" + + "fn runtime_high(value: &Large, index: usize) -> bool { return @value.values[index] == @value.values[0u64]; }" + diag := buildPipelineTestWithConfig(t, project.Config{ + RootDir: ".", Extension: peeper.SourceExt, TargetOS: "linux", TargetArch: test.arch, + }, "", src, func(mod *module.Module) { entry = mod }) + if diag.HasErrors() || entry.MIR == nil || entry.LLVMIR == "" { + t.Fatalf("high-bit zero-sized array access failed:\n%s", diag.EmitAllToString()) + } + lines := strings.Split(entry.LLVMIR, "\n") + arrayGEPs := 0 + for _, line := range lines { + if !strings.Contains(line, "getelementptr inbounds ["+test.length+" x") { + continue + } + arrayGEPs++ + if !strings.Contains(line, ", i32 0, "+test.physicalIndex+" ") { + t.Fatalf("array GEP used wrong physical index width:\n%s", entry.LLVMIR) + } + } + for _, index := range []string{test.lowIndex, test.highIndex} { + found := false + for lineIndex, line := range lines { + if test.physicalIndex == "i32" { + if strings.Contains(line, "trunc i64 "+index+" to i32") && + lineIndex+1 < len(lines) && + strings.Contains(lines[lineIndex+1], "getelementptr inbounds ["+test.length+" x") { + found = true + break + } + continue + } + if strings.Contains(line, "getelementptr inbounds ["+test.length+" x") && + strings.Contains(line, ", i32 0, i64 "+index) { + found = true + break + } + } + if !found { + t.Fatalf("boundary index %s did not reach final array GEP:\n%s", index, entry.LLVMIR) + } + } + runtimeGuard := false + for _, line := range lines { + if strings.Contains(line, "icmp uge i64") && strings.HasSuffix(strings.TrimSpace(line), ", "+test.length) { + runtimeGuard = true + break + } + } + if !runtimeGuard { + t.Fatalf("runtime index did not reach exact bounds guard:\n%s", entry.LLVMIR) + } + if arrayGEPs < 6 { + t.Fatalf("expected low/high constant and runtime array GEPs, got %d:\n%s", arrayGEPs, entry.LLVMIR) + } + targetInfo, err := target.New("linux", test.arch) + if err != nil { + t.Fatal(err) + } + assertLLVMCompiles(t, targetInfo, entry.LLVMIR) + }) + } +} + +func TestPipelineLowersCheckedConstantIndexesAcrossTargets(t *testing.T) { + for _, arch := range []string{"386", "amd64"} { + for _, test := range []struct{ name, declaration, index, typ, value, conversion, length string }{ + {"float cast", "", "1.75f64 as u64", "u64", "1", "", ""}, + {"source width negation", "const Offset: u8 = 255u8;", "-Offset", "u8", "1", "zext i8 1 to i64", ""}, + {"explicit narrowing", "", "257u16 as u8", "u8", "1", "zext i8 1 to i64", ""}, + {"single bit literal", "", "1u1", "u1", "1", "zext i1 1 to i64", ""}, + {"single bit zero", "", "0u1", "u1", "0", "zext i1 0 to i64", ""}, + {"single bit float cast", "", "1f64 as u1", "u1", "1", "zext i1 1 to i64", ""}, + {"single bit binding", "const Offset: u1 = 1u1;", "Offset", "u1", "1", "zext i1 1 to i64", ""}, + {"signed narrow", "", "1i8", "i8", "1", "sext i8 1 to i64", ""}, + {"wide unsigned", "", "1u128", "u128", "1", "trunc i128 1 to i64", ""}, + {"byte", "", "1u8 as byte", "byte", "1", "zext i8 1 to i64", ""}, + {"u8 top bit", "", "128u8", "u8", "128", "zext i8 128 to i64", "129"}, + {"u24 top bit", "", "8388608u24", "u24", "8388608", "zext i24 8388608 to i64", "8388609"}, + } { + t.Run(arch+"/"+test.name, func(t *testing.T) { + src := "fn main() -> i32 { " + test.declaration + " let values = [2]i32{7, 9}; return values[" + test.index + "]; }" + if test.length != "" { + // High-bit indexes use borrowed storage; no large object is allocated. + src = "struct Values { items: [" + test.length + "]i32 }\n" + + "fn read(values: &Values) -> i32 { " + test.declaration + " return values.items[" + test.index + "]; }" + } + diag := buildPipelineTestWithConfig(t, project.Config{ + RootDir: ".", Extension: peeper.SourceExt, TargetOS: "linux", TargetArch: arch, + }, "", src, func(entry *module.Module) { + assertCheckedConstantIndex(t, entry, test.value, test.typ) + if test.conversion != "" && !strings.Contains(entry.LLVMIR, test.conversion) { + t.Fatalf("checked index value not preserved for GEP; missing %q:\n%s", test.conversion, entry.LLVMIR) + } + targetInfo, err := target.New("linux", arch) + if err != nil { + t.Fatal(err) + } + assertLLVMCompiles(t, targetInfo, entry.LLVMIR) + }) + if diag.HasErrors() { + t.Fatalf("unexpected diagnostics:\n%s", diag.EmitAllToString()) + } + }) + } + } +} + +func TestPipelineEvaluatesQualifiedConstantIndexesAcrossTargets(t *testing.T) { + for _, arch := range []string{"386", "amd64"} { + for _, test := range []struct { + name, prefix, declaration, index, value, typ string + rejected bool + }{ + {"direct wide", "", "", "values::Offset", "18446744073709551616", "", true}, + {"local wide", "", "const Saved: u128 = values::Offset;", "Saved", "18446744073709551616", "", true}, + {"module wide", "const Saved: u128 = values::Offset;", "", "Saved", "18446744073709551616", "", true}, + {"arithmetic wide", "", "", "values::Offset + 0u128", "18446744073709551616", "", true}, + {"cast wide", "", "", "values::Offset as i128", "18446744073709551616", "", true}, + {"transitive wide", "import \"app/bridge\";", "", "bridge::Forward", "18446744073709551616", "", true}, + {"negative", "", "", "values::Negative", "-4294967296", "", true}, + {"equal length", "", "", "values::End", "2", "", true}, + {"zero", "", "", "values::Zero", "0", "u128", false}, + {"unsigned single bit", "", "", "values::Bit", "1", "u1", false}, + {"type alias", "", "", "values::Selected", "1", "u64", false}, + {"source width negation", "", "", "-values::Wrapped", "1", "u8", false}, + {"explicit narrowing", "", "", "values::Wide as u8", "1", "u8", false}, + {"module alias", "const Saved: u128 = values::Zero;", "", "Saved", "0", "u128", false}, + {"transitive valid", "import \"app/bridge\";", "", "bridge::Selected", "1", "u1", false}, + } { + t.Run(arch+"/"+test.name, func(t *testing.T) { + ctx, entry := importedPipelineContext(t, arch, map[string]string{ + peeper.MainFileName: "import \"app/values\";\n" + test.prefix + "\nfn main() -> i32 { " + test.declaration + " let items = [2]i32{7, 9}; return items[" + test.index + "]; }", + "values" + peeper.SourceExt: `type Index = u64; +const Offset: u128 = 1u128 << 64u128; +const Negative: i64 = -4294967296i64; +const End: u8 = 2u8; +const Zero: u128 = 0u128; +const Bit: u1 = 1u1; +const Selected: Index = 1u64; +const Wrapped: u8 = 255u8; +const Wide: u16 = 257u16;`, + "bridge" + peeper.SourceExt: `import "app/values"; +const Forward: u128 = values::Offset; +const Selected: u1 = values::Bit;`, + }) + if err := Run(ctx, entry); err != nil { + t.Fatal(err) + } + diag := ctx.Diagnostics + if test.rejected { + fn := entry.AST.Stmts[len(entry.AST.Stmts)-1].(*ast.FnDecl) + access := fn.Body.Stmts[len(fn.Body.Stmts)-1].(*ast.ReturnStmt).Value.(*ast.IndexExpr) + if diag.ErrorCount() != 1 { + t.Fatalf("want one source bounds error, got:\n%s", diag.EmitAllToString()) + } + for _, item := range diag.Diagnostics() { + if item.Severity != diagnostics.Error { + continue + } + if item.Code != diagnostics.ErrArrayOutOfBounds || item.Message != "array index out of bounds: index "+test.value+" for length 2" || + len(item.Labels) != 1 || item.Labels[0].Location != ast.LocOf(access.Index) { + t.Fatalf("want exact index-located bounds error, got:\n%s", diag.EmitAllToString()) + } + } + if entry.MIR != nil || entry.LLVMIR != "" { + t.Fatal("invalid qualified constant reached backend") + } + return + } + if diag.HasErrors() { + t.Fatalf("valid qualified constant failed:\n%s", diag.EmitAllToString()) + } + assertCheckedConstantIndex(t, entry, test.value, test.typ) + assertLLVMCompiles(t, ctx.Target, entry.LLVMIR) + }) + } + } +} + +func TestPipelineChecksQualifiedConstantShiftCounts(t *testing.T) { + for _, arch := range []string{"386", "amd64"} { + for _, test := range []struct { + name, left, count string + rejected bool + }{ + {"out of width", "1u8", "values::Invalid", true}, + {"negative", "1i8", "values::Negative", true}, + {"direct valid", "1u8", "values::Valid", false}, + {"explicit narrowing", "1u8", "values::Wide as u8", false}, + } { + t.Run(arch+"/"+test.name, func(t *testing.T) { + ctx, entry := importedPipelineContext(t, arch, map[string]string{ + peeper.MainFileName: "import \"app/values\"; fn Read() -> i32 { return (" + test.left + " << (" + test.count + ")) as i32; }", + "values" + peeper.SourceExt: `const Invalid: u8 = 8u8; +const Negative: i8 = -1i8; +const Valid: u8 = 2u8; +const Wide: u16 = 258u16;`, + }) + if err := Run(ctx, entry); err != nil { + t.Fatal(err) + } + diag := ctx.Diagnostics + if test.rejected { + cast := entry.AST.Stmts[0].(*ast.FnDecl).Body.Stmts[0].(*ast.ReturnStmt).Value.(*ast.AsExpr) + shift := cast.Expr.(*ast.BinaryExpr) + items := diag.Diagnostics() + if len(items) != 1 || items[0].Code != diagnostics.ErrInvalidOperation || items[0].Message != "shift count must be between 0 and 7" || + len(items[0].Labels) != 1 || items[0].Labels[0].Location != ast.LocOf(shift.Right) || entry.MIR != nil || entry.LLVMIR != "" { + t.Fatalf("want located constant-shift error before MIR, got:\n%s", diag.EmitAllToString()) + } + return + } + if diag.HasErrors() || entry.MIR == nil || entry.LLVMIR == "" { + t.Fatalf("valid qualified shift failed:\n%s", diag.EmitAllToString()) + } + assertLLVMCompiles(t, ctx.Target, entry.LLVMIR) + }) + } + } +} + +func TestPipelineRecordsQualifiedRangeEntryAcrossTargets(t *testing.T) { + for _, arch := range []string{"386", "amd64"} { + for _, test := range []struct { + name, params, start, end string + guaranteed bool + }{ + {"ascending", "", "values::Zero", "values::One", true}, + {"equal", "", "values::One", "values::One", false}, + {"reversed", "", "values::One", "values::Zero", false}, + {"runtime", "start: i64", "start", "values::One", false}, + } { + t.Run(arch+"/"+test.name, func(t *testing.T) { + ctx, entry := importedPipelineContext(t, arch, map[string]string{ + peeper.MainFileName: "import \"app/values\"; fn Read(" + test.params + ") -> i64 { for value in " + test.start + "..(" + test.end + " as i64) { return value; } }", + "values" + peeper.SourceExt: `const Zero: i64 = 0i64; const One: i64 = 1i64;`, + }) + if err := Run(ctx, entry); err != nil { + t.Fatal(err) + } + loop := entry.AST.Stmts[0].(*ast.FnDecl).Body.Stmts[0].(*ast.ForStmt) + plan, ok := entry.THIR.Node(loop.ID()).(*thir.For).Iteration.(*thir.RangeIteration) + if !ok { + t.Fatalf("missing range plan for %T:\n%s", loop.Iterable, ctx.Diagnostics.EmitAllToString()) + } + if plan.HasGuaranteedEntry != test.guaranteed { + t.Fatalf("range entry = %v, want %v", plan.HasGuaranteedEntry, test.guaranteed) + } + for name, typ := range map[string]typeinfo.Type{"element": plan.ElementType, "cursor": plan.Cursor.Type, "limit": plan.Limit.Type} { + if typeinfo.TypeText(typ) != "i64" { + t.Fatalf("%s type = %s, want i64", name, typeinfo.TypeText(typ)) + } + } + diag := ctx.Diagnostics + if !test.guaranteed { + if diag.ErrorCount() != 1 || !hasDiagnosticCode(diag, diagnostics.ErrMissingReturn) || entry.MIR != nil || entry.LLVMIR != "" { + t.Fatalf("non-guaranteed range must retain missing-return error:\n%s", diag.EmitAllToString()) + } + return + } + if diag.HasErrors() || entry.MIR == nil || entry.LLVMIR == "" { + t.Fatalf("guaranteed imported range failed:\n%s", diag.EmitAllToString()) + } + assertLLVMCompiles(t, ctx.Target, entry.LLVMIR) + }) + } + } +} + +func TestPipelineReportsQualifiedConstantConditionsInCFGPhase(t *testing.T) { + for _, test := range []struct { + condition, warning string + value bool + }{ + {"values::Flag", diagnostics.WarnConstantConditionTrue, true}, + {"values::One == 1i64", diagnostics.WarnConstantConditionTrue, true}, + {"values::One == 0i64", diagnostics.WarnConstantConditionFalse, false}, + } { + t.Run(test.condition, func(t *testing.T) { + ctx, entry := importedPipelineContext(t, "amd64", map[string]string{ + peeper.MainFileName: "import \"app/values\"; fn main() -> i32 { if " + test.condition + " { return 1; } return 0; }", + "values" + peeper.SourceExt: `const Flag: bool = true; const One: i64 = 1i64;`, + }) + loader := &moduleLoader{ctx: ctx, scheduled: make(map[moduleid.ID]string)} + if err := loader.Load(entry); err != nil { + t.Fatal(err) + } + owner, found := ctx.ModuleByID(entry.Imports["values"].ID) + if !found { + t.Fatal("qualified owner was not loaded") + } + diag := ctx.Diagnostics + for _, mod := range []*module.Module{owner, entry} { + for mod.Phase < phase.Typechecked { + if !advanceModulePhase(ctx, mod, diag) { + t.Fatalf("typing stopped at %v:\n%s", mod.Phase, diag.EmitAllToString()) + } + } + } + condition := entry.AST.Stmts[0].(*ast.FnDecl).Body.Stmts[0].(*ast.IfStmt) + checked := entry.THIR.Node(condition.ID()).(*thir.If) + if checked.ConstantCondition == nil || *checked.ConstantCondition != test.value { + t.Fatalf("qualified condition = %v, want %v", checked.ConstantCondition, test.value) + } + if diag.HasErrors() || hasDiagnosticCode(diag, diagnostics.WarnConstantConditionTrue) || hasDiagnosticCode(diag, diagnostics.WarnConstantConditionFalse) { + t.Fatalf("qualified warning/error emitted before CFG:\n%s", diag.EmitAllToString()) + } + entry.AST = nil + if !advanceModulePhase(ctx, entry, diag) || entry.Phase != phase.CFG || entry.MIR != nil || diag.HasErrors() { + t.Fatalf("CFG did not consume qualified evidence without AST:\n%s", diag.EmitAllToString()) + } + warnings := 0 + for _, item := range diag.Diagnostics() { + if item.Code == diagnostics.WarnConstantConditionTrue || item.Code == diagnostics.WarnConstantConditionFalse { + if item.Code != test.warning { + t.Fatalf("wrong qualified warning %s", item.Code) + } + warnings++ + } + } + if warnings != 1 { + t.Fatalf("qualified warnings = %d, want one:\n%s", warnings, diag.EmitAllToString()) + } + }) + } +} + func TestPipelineLowersTopLevelConstArrayIndex(t *testing.T) { preludeSrc := `` entrySrc := `const I: i32 = 1; @@ -2792,15 +3353,7 @@ fn main() -> i32 { } } - clang, err := exec.LookPath("clang") - if err != nil { - t.Skip("clang unavailable for LLVM IR validation") - } - cmd := exec.Command(clang, "-target", ctx.Target.LLVMTriple, "-x", "ir", "-c", "-o", filepath.Join(t.TempDir(), "intrinsic.o"), "-") - cmd.Stdin = strings.NewReader(entry.LLVMIR) - if out, err := cmd.CombinedOutput(); err != nil { - t.Fatalf("%s representative LLVM IR is invalid: %v\n%s\n%s", compilerTarget.name, err, out, entry.LLVMIR) - } + assertLLVMCompiles(t, ctx.Target, entry.LLVMIR) }) } } diff --git a/internal/semantics/typechecker/check_expr.go b/internal/semantics/typechecker/check_expr.go index 8cb943ca..9d801cd8 100644 --- a/internal/semantics/typechecker/check_expr.go +++ b/internal/semantics/typechecker/check_expr.go @@ -653,7 +653,8 @@ func (c *checker) typeIndexExpr(scope *symbols.Scope, node *ast.IndexExpr) typei return elem } array := typeinfo.Underlying(baseType).(*typeinfo.ArrayType) - value, ok := c.evaluateConstant(c.ctx, node.Index, typeinfo.DefaultIntegerType()) + // Literal inference is finished; bounds checking must keep the checked width. + value, ok := c.evaluateConstant(c.ctx, node.Index, indexType) if !ok { return elem } @@ -665,11 +666,10 @@ func (c *checker) typeIndexExpr(scope *symbols.Scope, node *ast.IndexExpr) typei Text: indexConst.Text(), Type: indexType, }) - length, lengthErr := strconv.Atoi(array.Len) - indexText := indexConst.Text() - indexValue, indexErr := strconv.Atoi(indexText) - if lengthErr == nil && (indexErr != nil || indexValue < 0 || indexValue >= length) { - c.ctx.Diagnostics.Add(problems.ArrayIndexOutOfBounds(indexText, array.Len, ast.LocOf(node.Index))) + length, lengthErr := numeric.StringToBigInt(array.Len) + indexValue := indexConst.Int() + if lengthErr == nil && (indexValue == nil || indexValue.Sign() < 0 || indexValue.Cmp(length) >= 0) { + c.ctx.Diagnostics.Add(problems.ArrayIndexOutOfBounds(indexConst.Text(), array.Len, ast.LocOf(node.Index))) } return elem } diff --git a/internal/semantics/typechecker/constant_eval.go b/internal/semantics/typechecker/constant_eval.go index 1b40f8c5..114002fd 100644 --- a/internal/semantics/typechecker/constant_eval.go +++ b/internal/semantics/typechecker/constant_eval.go @@ -1,6 +1,9 @@ package typechecker import ( + "math" + "math/big" + "compiler/internal/constvalue" "compiler/internal/diagnostics" "compiler/internal/frontend/ast" @@ -112,8 +115,24 @@ func (e *constantEvaluator) evalConstSymbol(sym *symbols.Symbol) (constvalue.Val return value, true } -func (e *constantEvaluator) evalExpr(expr ast.Expr, expected typeinfo.Type) (constvalue.Value, bool) { +func (e *constantEvaluator) evalExpr(expr ast.Expr, expected typeinfo.Type) (value constvalue.Value, evaluated bool) { + if expr == nil { + return nil, false + } + var checkedType typeinfo.Type if e.evidence != nil { + checkedType = e.evidence.ExprType(expr.ID()) + if conversion, found := e.evidence.ImplicitConversion(expr.ID()); found && checkedType != nil && + conversion.Kind == typeinfo.ConversionNumeric && conversion.Compatibility == typeinfo.Compatible { + // Fold at the checked source width before converting to the context's + // destination. Widening earlier would change intermediate overflow. + defer func(destination typeinfo.Type) { + if evaluated { + value, evaluated = expectedNumericConstValue(value, destination) + } + }(expected) + expected = checkedType + } if construction, ok := e.evidence.VariantConstruction(expr.ID()); ok { if typeinfo.OwnershipCapabilityOf(construction.EnumType).Copy != typeinfo.CopyImplicit { return nil, false @@ -212,9 +231,9 @@ func (e *constantEvaluator) evalExpr(expr ast.Expr, expected typeinfo.Type) (con return constvalue.NewIntText(value, typText) case *ast.BoolLit: return constvalue.NewBool(node.Value), true - case *ast.Ident: + case *ast.Ident, *ast.ScopeResolution: // Later declarations may shadow the name, but not its resolved binding. - sym := e.module.SymbolIndex.Symbol(node) + sym := e.module.SymbolIndex.Symbol(expr) if sym == nil || sym.Kind != symbols.SymbolConst { return nil, false } @@ -224,28 +243,30 @@ func (e *constantEvaluator) evalExpr(expr ast.Expr, expected typeinfo.Type) (con } return expectedNumericConstValue(value, expected) case *ast.AsExpr: - var targetType, sourceType typeinfo.Type + targetType := checkedType + var sourceType typeinfo.Type if e.evidence != nil { - targetType = e.evidence.ExprType(node.ID()) sourceType = e.evidence.ExprType(node.Expr.ID()) } if targetType == nil { targetType = e.ctx.TypeResolver.Query(e.module, node.TypeExpr, typeresolution.Context{}).Type } - if !typeinfo.IsIntegral(targetType) { + if _, _, numericTarget := typeinfo.NumericInfo(targetType); !numericTarget { return nil, false } // Cast input keeps its own width; destination context must not change // overflow before the explicit finite-width conversion. value, ok := e.evalExpr(node.Expr, sourceType) - integer, isInteger := value.(*constvalue.IntConst) - if !ok || !isInteger || integer == nil { + if !ok { return nil, false } - converted, ok := constvalue.NewInt(integer.Int(), typeinfo.TypeText(typeinfo.Underlying(targetType))) + converted, ok := expectedNumericConstValue(value, targetType) if !ok { return nil, false } + if typeinfo.IsSameType(targetType, expected) { + return converted, true + } return expectedNumericConstValue(converted, expected) case *ast.UnaryExpr: value, ok := e.evalExpr(node.Expr, expected) @@ -254,15 +275,28 @@ func (e *constantEvaluator) evalExpr(expr ast.Expr, expected typeinfo.Type) (con } return constvalue.FoldUnary(node.Op, value) case *ast.BinaryExpr: - left, lok := e.evalExpr(node.Left, expected) - right, rok := e.evalExpr(node.Right, expected) + operandType := expected + if checkedType != nil { + operandType = checkedType + if binaryResultIsBool(node.Op) { + // The result is bool; the unconverted operand owns the checked + // numeric destination for comparisons. + operandType = e.evidence.ExprType(node.Left.ID()) + if conversion, found := e.evidence.ImplicitConversion(node.Left.ID()); found && + conversion.Kind == typeinfo.ConversionNumeric && conversion.Compatibility == typeinfo.Compatible { + operandType = e.evidence.ExprType(node.Right.ID()) + } + } + } + left, lok := e.evalExpr(node.Left, operandType) + right, rok := e.evalExpr(node.Right, operandType) if !lok || !rok { return nil, false } if folded, ok := constvalue.FoldBinary(node.Op, left, right); ok { return folded, true } - if expected != nil { + if checkedType != nil || expected != nil { return nil, false } commonType := typeinfo.CommonNumericType(&typeinfo.NamedType{Name: left.TypeText()}, &typeinfo.NamedType{Name: right.TypeText()}) @@ -284,20 +318,29 @@ func expectedNumericConstValue(value constvalue.Value, expected typeinfo.Type) ( if expected == nil { return value, true } - family, _, ok := typeinfo.NumericInfo(expected) + family, bits, ok := typeinfo.NumericInfo(expected) if !ok { return value, true } typeText := typeinfo.TypeText(typeinfo.Underlying(expected)) switch v := value.(type) { case *constvalue.IntConst: - if v == nil { + integer := v.Int() + if integer == nil { return nil, false } if family == typeinfo.NumericFloat { - return constvalue.NewFloatText(v.Text(), typeText) + // Round the exact integer directly to its destination. Routing f32 + // through f64 can double-round, and text parsing rejects IEEE overflow. + number := new(big.Float).SetInt(integer) + if bits == 32 { + rounded, _ := number.Float32() + return constvalue.NewFloat(float64(rounded), typeText) + } + rounded, _ := number.Float64() + return constvalue.NewFloat(rounded, typeText) } - return constvalue.NewInt(v.Int(), typeText) + return constvalue.NewInt(integer, typeText) case *constvalue.FloatConst: if v == nil { return nil, false @@ -305,7 +348,16 @@ func expectedNumericConstValue(value constvalue.Value, expected typeinfo.Type) ( if family == typeinfo.NumericFloat { return constvalue.NewFloat(v.Float(), typeText) } - return nil, false + if math.IsNaN(v.Float()) || math.IsInf(v.Float(), 0) { + return nil, false + } + integer, _ := big.NewFloat(v.Float()).Int(nil) + // LLVM float-to-integer conversion is undefined outside the destination + // range. Do not turn an unmaterializable cast into a wrapping integer. + if !numeric.FitsIntegerLiteral(integer.String(), bits, family == typeinfo.NumericSigned) { + return nil, false + } + return constvalue.NewInt(integer, typeText) default: return value, true } diff --git a/internal/semantics/typechecker/constant_eval_test.go b/internal/semantics/typechecker/constant_eval_test.go index 5bfa4260..e6b112e6 100644 --- a/internal/semantics/typechecker/constant_eval_test.go +++ b/internal/semantics/typechecker/constant_eval_test.go @@ -2,6 +2,7 @@ package typechecker import ( "fmt" + "math" "testing" "compiler/internal/constvalue" @@ -16,7 +17,6 @@ import ( "compiler/internal/semantics/binder" "compiler/internal/semantics/collector" "compiler/internal/semantics/resolver" - "compiler/internal/semantics/symbols" "compiler/internal/semantics/typeinfo" "compiler/pkg/peeper" ) @@ -126,12 +126,110 @@ func TestEvaluateIntegerCastWithoutTypingEvidence(t *testing.T) { module, diag := constevalModule(t, `type Count = u8; const Converted: Count = 258u16 as Count; const Shifted: u8 = 128u8 >> Converted; +const Inferred = 258u16 as Count; +const Widened: u16 = 258u16 as Count; +const InferredByte = 258u16 as byte; `) if diag.HasErrors() { t.Fatalf("unexpected diagnostics:\n%s", diag.EmitAllToString()) } assertIntConst(t, module, "Shifted", "32", "u8") assertIntConst(t, module, "Converted", "2", "u8") + assertIntConst(t, module, "Inferred", "2", "u8") + assertIntConst(t, module, "Widened", "2", "u16") + assertIntConst(t, module, "InferredByte", "2", "byte") +} + +func TestEvaluateCheckedNumericConversions(t *testing.T) { + for _, test := range []struct{ name, source, value, typ string }{ + {"mixed operands", "const Value: i64 = 1i32 + 2i64;", "3", "i64"}, + {"explicit initializer", "const Value: i64 = 2i8;", "2", "i64"}, + {"signed widening", "const Value: u16 = -1i8;", "65535", "u16"}, + {"source overflow", "const Value: i64 = 127i8 + 1i8;", "-128", "i64"}, + {"nested overflow", "const Value: i64 = (127i8 + 1i8) + 1i64;", "-127", "i64"}, + {"unary overflow", "const Value: i64 = -(-128i8);", "-128", "i64"}, + {"implicit float widening", "const Value: f64 = 1.25f32;", "1.25", "f64"}, + {"mixed floats", "const Value: f64 = 1.25f32 + 2.5f64;", "3.75", "f64"}, + {"explicit float narrowing", "const Value: f32 = 1.25f64 as f32;", "1.25", "f32"}, + {"round then widen", "const Value: f64 = 16777217f64 as f32;", "1.6777216e+07", "f64"}, + {"aliased round then widen", "type Narrow = f32; type Wide = f64; const Value: Wide = 16777217f64 as Narrow;", "1.6777216e+07", "f64"}, + {"negative zero then widen", "const Value: f64 = -0f64 as f32;", "-0", "f64"}, + {"float intermediate width", "const Value: f64 = 16777216f32 + 1f32;", "1.6777216e+07", "f64"}, + {"integer to float", "const Value: f32 = -3i8 as f32;", "-3", "f32"}, + {"float to integer", "const Value: i8 = -3.75f64 as i8;", "-3", "i8"}, + } { + t.Run(test.name, func(t *testing.T) { + module, diag := checkTypeModule(t, test.source) + if diag.HasErrors() { + t.Fatalf("unexpected diagnostics:\n%s", diag.EmitAllToString()) + } + value := evaluatedConst(t, module, "Value") + text, ok := value.(interface{ Text() string }) + if !ok || text.Text() != test.value || value.TypeText() != test.typ { + t.Fatalf("published Value = %#v, want %s %s", value, test.value, test.typ) + } + }) + } + module, diag := checkTypeModule(t, `const Less: bool = 1i8 < 2i64; +const Greater: bool = 2i64 > 1i8;`) + if diag.HasErrors() { + t.Fatalf("unexpected comparison diagnostics:\n%s", diag.EmitAllToString()) + } + assertBoolConst(t, module, "Less", true) + assertBoolConst(t, module, "Greater", true) +} + +func TestEvaluateRejectsUnrepresentableFloatToIntegerConstants(t *testing.T) { + for _, expression := range []string{ + "128f64 as i8", "-129f64 as i8", "-1f64 as u8", "256f64 as u8", + "(1f64 / 0f64) as i32", "(0f64 / 0f64) as i32", + } { + t.Run(expression, func(t *testing.T) { + module, diag := checkTypeModule(t, "const Value = "+expression+";") + if diag.HasErrors() { + t.Fatalf("unexpected typing diagnostics:\n%s", diag.EmitAllToString()) + } + if value := evaluatedConst(t, module, "Value"); value != nil { + t.Fatalf("unrepresentable float cast published %#v", value) + } + }) + } +} + +func TestEvaluateIntegerToFloatRoundsAtDestinationWidth(t *testing.T) { + for _, test := range []struct { + name, expression, typ string + want float64 + }{ + {"midpoint", "9223372586610589696u64 as f32", "f32", 9223372036854775808}, + {"above midpoint", "9223372586610589697u64 as f32", "f32", 9223373136366403584}, + {"negative above midpoint", "-9007199791611905i64 as f32", "f32", -9007200328482816}, + {"f32 infinity", "(1u256 << 128u256) as f32", "f32", math.Inf(1)}, + {"f64 infinity", "(1u2048 << 1024u2048) as f64", "f64", math.Inf(1)}, + {"negative infinity", "-(1i2048 << 1024i2048) as f64", "f64", math.Inf(-1)}, + } { + t.Run(test.name, func(t *testing.T) { + module, diag := checkTypeModule(t, "const Value = "+test.expression+";") + if diag.HasErrors() { + t.Fatalf("unexpected diagnostics:\n%s", diag.EmitAllToString()) + } + published := evaluatedConst(t, module, "Value") + value, ok := published.(*constvalue.FloatConst) + if !ok || value == nil || value.Float() != test.want || value.TypeText() != test.typ { + t.Fatalf("published Value = %#v, want %g %s", published, test.want, test.typ) + } + }) + } +} + +func TestExpectedNumericConstValueRejectsInvalidIntegers(t *testing.T) { + for _, value := range []*constvalue.IntConst{nil, {}} { + for _, typ := range []typeinfo.Type{&typeinfo.FloatType{Bits: 32}, &typeinfo.IntegerType{IsSigned: true, Bits: 64}} { + if converted, ok := expectedNumericConstValue(value, typ); ok || converted != nil { + t.Fatalf("invalid integer converted to %#v", converted) + } + } + } } func TestEvaluateBitwiseConstExpressionsThroughIntegralAlias(t *testing.T) { @@ -319,8 +417,13 @@ func TestEvaluateRejectsUnboundConstantReferences(t *testing.T) { ctx := project.New(".", peeper.SourceExt, diag) ctx.AddModule(module) evaluator := newConstantEvaluator(ctx, module, newEvidence()) - if value, ok := evaluator.evalExpr(&ast.Ident{Name: "Value"}, nil); ok { - t.Fatalf("unbound identifier evaluated to %#v through name lookup", value) + for _, expr := range []ast.Expr{ + &ast.Ident{Name: "Value"}, + &ast.ScopeResolution{Segments: []ast.PathSegment{{Name: &ast.Ident{Name: "owner"}}, {Name: &ast.Ident{Name: "Value"}}}}, + } { + if value, ok := evaluator.evalExpr(expr, nil); ok { + t.Fatalf("unbound %T evaluated to %#v through name lookup", expr, value) + } } } @@ -344,7 +447,9 @@ func TestEvaluateReadsForeignPublishedConstantWithoutConsumerCache(t *testing.T) resolver.Resolve(ctx, module) } - owner := parse("owner"+peeper.SourceExt, "owner", "const Shared: i32 = 7;") + owner := parse("owner"+peeper.SourceExt, "owner", `const Shared: i32 = 7; +type Number = i32; +fn Read() -> i32 { return 7; }`) resolve(owner) finalizeConstantValues(ctx, owner, newEvidence()) shared, found := owner.ModuleScope.LookupLocal("Shared") @@ -355,26 +460,62 @@ func TestEvaluateReadsForeignPublishedConstantWithoutConsumerCache(t *testing.T) t.Fatalf("publish shared constant: %v", err) } - consumer := parse("consumer"+peeper.SourceExt, "consumer", "const Local = Shared;") + consumer := parse("consumer"+peeper.SourceExt, "consumer", `const Local = Shared; +const Qualified = owner::Shared; +fn Probe() { + let Mutable: i32 = 7; + const Variable = Mutable; + const Function = owner::Read; + const Type = owner::Number; +}`) + consumer.Imports["owner"] = module.ResolvedImport{ID: owner.ID, FilePath: owner.FilePath} resolve(consumer) - local, found := consumer.ModuleScope.LookupLocal("Local") - if !found || local == nil { - t.Fatal("missing consumer constant") + if diag.HasErrors() { + t.Fatalf("foreign binding setup failed:\n%s", diag.EmitAllToString()) } + // Foreign reads must use the published value, not re-evaluate changed owner AST. + shared.ASTNode.(*ast.ConstDecl).Value = &ast.NumberLit{Value: "99"} evaluator := newConstantEvaluator(ctx, consumer, newEvidence()) - if _, ok := evaluator.evalConstSymbol(local); !ok { - t.Fatal("failed to lazily evaluate imported constant") - } - cached := evaluator.cache[local.ID] - value, ok := cached.(*constvalue.IntConst) - if !ok || value == nil || value.Text() != "7" { - t.Fatalf("consumer value = %#v, want 7", cached) + for _, name := range []string{"Local", "Qualified"} { + local, found := consumer.ModuleScope.LookupLocal(name) + if !found || local == nil { + t.Fatalf("missing consumer constant %s", name) + } + if _, ok := evaluator.evalConstSymbol(local); !ok { + t.Fatalf("failed to lazily evaluate %s", name) + } + cached := evaluator.cache[local.ID] + value, ok := cached.(*constvalue.IntConst) + if !ok || value == nil || value.Text() != "7" { + t.Fatalf("%s consumer value = %#v, want published 7", name, cached) + } } if _, found := evaluator.cache[shared.ID]; found { t.Fatal("foreign constant duplicated in consumer query cache") } - if owner.SymbolIndex.ConstantValue(shared.ID) == nil { - t.Fatal("owner lost published constant") + if value := owner.SymbolIndex.ConstantValue(shared.ID).(*constvalue.IntConst); value.Text() != "7" { + t.Fatalf("owner published value changed to %s", value.Text()) + } + probe := consumer.AST.Stmts[2].(*ast.FnDecl) + for _, stmt := range probe.Body.Stmts[1:] { + decl := stmt.(*ast.ConstDecl) + expr := decl.Value + bound := consumer.SymbolIndex.Symbol(expr) + if bound == nil { + t.Fatalf("nonconstant %s has no canonical binding", decl.Name.Name) + } + evaluator.cache[bound.ID], _ = constvalue.NewIntText("99", "i32") + if value, ok := evaluator.evalExpr(expr, nil); ok { + t.Fatalf("nonconstant %s evaluated to %#v", decl.Name.Name, value) + } + } + owner.SymbolIndex.ClearConstants() + evaluator.cache[shared.ID], _ = constvalue.NewIntText("99", "i32") + for _, stmt := range consumer.AST.Stmts[:2] { + expr := stmt.(*ast.ConstDecl).Value + if value, ok := evaluator.evalExpr(expr, nil); ok { + t.Fatalf("unpublished foreign %T evaluated to %#v through consumer cache or owner AST", expr, value) + } } if diag.HasErrors() { t.Fatalf("unexpected diagnostics:\n%s", diag.EmitAllToString()) @@ -397,11 +538,7 @@ func TestEvaluateStringConst(t *testing.T) { if diag.HasErrors() { t.Fatalf("unexpected diagnostics:\n%s", diag.EmitAllToString()) } - sym, ok := module.ModuleScope.LookupLocal("Name") - if !ok || sym == nil { - t.Fatalf("missing symbol Name") - } - value := evaluatedConst(module, sym.ID) + value := evaluatedConst(t, module, "Name") got, ok := value.(*constvalue.StringConst) if !ok || got == nil || got.Text() != "puts" || got.TypeText() != "cstr" { t.Fatalf("Name = %#v, want str puts cstr", value) @@ -410,31 +547,28 @@ func TestEvaluateStringConst(t *testing.T) { func assertIntConst(t *testing.T, module *module.Module, name, want, wantType string) { t.Helper() - sym, ok := module.ModuleScope.LookupLocal(name) - if !ok || sym == nil { - t.Fatalf("missing symbol %s", name) - } - value := evaluatedConst(module, sym.ID) + value := evaluatedConst(t, module, name) 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, value, want, wantType) } } -func evaluatedConst(module *module.Module, id symbols.SymbolID) constvalue.Value { - if module == nil || module.SymbolIndex == nil { - return nil +func evaluatedConst(t *testing.T, module *module.Module, name string) constvalue.Value { + t.Helper() + if module == nil || module.ModuleScope == nil || module.SymbolIndex == nil { + t.Fatal("constant publication setup missing") + } + sym, ok := module.ModuleScope.LookupLocal(name) + if !ok || sym == nil { + t.Fatalf("missing constant %s", name) } - return module.SymbolIndex.ConstantValue(id) + return module.SymbolIndex.ConstantValue(sym.ID) } func assertBoolConst(t *testing.T, module *module.Module, name string, want bool) { t.Helper() - sym, ok := module.ModuleScope.LookupLocal(name) - if !ok || sym == nil { - t.Fatalf("missing symbol %s", name) - } - value := evaluatedConst(module, sym.ID) + value := evaluatedConst(t, module, name) got, ok := value.(*constvalue.BoolConst) if !ok || got == nil || got.Bool() != want { t.Fatalf("%s = %#v, want bool %v", name, value, want) diff --git a/internal/semantics/typechecker/typechecker_test.go b/internal/semantics/typechecker/typechecker_test.go index 56b989b1..8642b267 100644 --- a/internal/semantics/typechecker/typechecker_test.go +++ b/internal/semantics/typechecker/typechecker_test.go @@ -1739,6 +1739,52 @@ fn first(xs: [4]i32) -> i32 { } } +func TestArrayConstantIndexPreservesCheckedTypeAndValue(t *testing.T) { + for _, test := range []struct { + name, declarations, index, value, typ string + rejected bool + }{ + {"wide float cast", "", "4294967296f64 as u64", "4294967296", "u64", true}, + {"negative float cast", "", "(-4294967296f64) as i64", "-4294967296", "i64", true}, + {"wide signed constant", "const Offset: i64 = 4294967296i64;", "Offset", "4294967296", "i64", true}, + {"negative signed constant", "const Offset: i64 = -4294967296i64;", "Offset", "-4294967296", "i64", true}, + {"unsigned boundary", "const Offset: u64 = 18446744073709551615u64;", "Offset", "18446744073709551615", "u64", true}, + {"beyond host integer", "const Offset: u128 = 1u128 << 64u128;", "Offset", "18446744073709551616", "u128", true}, + {"wide integer cast", "", "4294967296u64 as i64", "4294967296", "i64", true}, + {"wide literal control", "", "4294967296u64", "4294967296", "u64", true}, + {"default literal", "", "1", "1", "i32", false}, + {"fractional float cast", "", "1.75f64 as u64", "1", "u64", false}, + {"negative fraction", "", "(-0.75f64) as i64", "0", "i64", false}, + {"explicit narrowing", "", "257u16 as u8", "1", "u8", false}, + {"source width negation", "const Offset: u8 = 255u8;", "-Offset", "1", "u8", false}, + {"alias constant", "type Index = u64; const Offset: Index = 1u64;", "Offset", "1", "u64", false}, + } { + t.Run(test.name, func(t *testing.T) { + mod, diag := checkTypeModule(t, test.declarations+"\nfn first(values: [2]i32) -> i32 { return values["+test.index+"]; }") + fn := mod.AST.Stmts[len(mod.AST.Stmts)-1].(*ast.FnDecl) + index := fn.Body.Stmts[0].(*ast.ReturnStmt).Value.(*ast.IndexExpr) + typing := testEvidence(mod) + constant, found := typing.ConstantIndex(index.ID()) + if !found || constant.Text != test.value || typeinfo.TypeText(typeinfo.Underlying(constant.Type)) != test.typ || + !typeinfo.IsSameType(constant.Type, typing.ExprType(index.Index.ID())) { + t.Fatalf("constant index = %#v, want checked %s %s", constant, test.typ, test.value) + } + if !test.rejected { + if diag.HasErrors() { + t.Fatalf("unexpected diagnostics:\n%s", diag.EmitAllToString()) + } + return + } + items := diag.Diagnostics() + if len(items) != 1 || items[0].Code != diagnostics.ErrArrayOutOfBounds || + items[0].Message != "array index out of bounds: index "+test.value+" for length 2" || + len(items[0].Labels) != 1 || items[0].Labels[0].Location != ast.LocOf(index.Index) { + t.Fatalf("expected exact located bounds diagnostic, got:\n%s", diag.EmitAllToString()) + } + }) + } +} + func TestArrayIndexExprAcceptsRuntimeIndex(t *testing.T) { src := `fn first(xs: [4]i32, i: i32) -> i32 { return xs[i]; diff --git a/x_test/negative_host_sized_array_bounds/peeper.toml b/x_test/negative_host_sized_array_bounds/peeper.toml new file mode 100644 index 00000000..b44de95a --- /dev/null +++ b/x_test/negative_host_sized_array_bounds/peeper.toml @@ -0,0 +1,8 @@ +name = "negative_host_sized_array_bounds" +build = "program" + +[test] +mode = "check" +outcome = "failure" +compiler_args = ["-target-os", "linux", "-target-arch", "amd64"] +stderr_contains = ["array index out of bounds: index -1 for length 9223372036854775808", "main.peep:2:"] diff --git a/x_test/negative_host_sized_array_bounds/src/main.peep b/x_test/negative_host_sized_array_bounds/src/main.peep new file mode 100644 index 00000000..de14b653 --- /dev/null +++ b/x_test/negative_host_sized_array_bounds/src/main.peep @@ -0,0 +1,5 @@ +fn read(xs: [9223372036854775808u64]u8) -> u8 { + return xs[-1i64]; +} + +fn main() -> i32 { return 0; } diff --git a/x_test/negative_imported_constant_queries/peeper.toml b/x_test/negative_imported_constant_queries/peeper.toml new file mode 100644 index 00000000..f86ebcb6 --- /dev/null +++ b/x_test/negative_imported_constant_queries/peeper.toml @@ -0,0 +1,7 @@ +name = "negative_imported_constant_queries" +build = "program" + +[test] +mode = "check" +outcome = "failure" +stderr_contains = ["array index out of bounds: index 18446744073709551616 for length 1", "shift count must be between 0 and 7"] diff --git a/x_test/negative_imported_constant_queries/src/main.peep b/x_test/negative_imported_constant_queries/src/main.peep new file mode 100644 index 00000000..84f74a09 --- /dev/null +++ b/x_test/negative_imported_constant_queries/src/main.peep @@ -0,0 +1,23 @@ +import "negative_imported_constant_queries/values"; + +const Saved: u128 = values::Offset; + +fn Direct() -> i32 { + let items = [1]i32{7}; + return items[values::Offset]; +} + +fn Local() -> i32 { + const LocalOffset: u128 = values::Offset; + let items = [1]i32{7}; + return items[LocalOffset]; +} + +fn ModuleAlias() -> i32 { + let items = [1]i32{7}; + return items[Saved]; +} + +fn InvalidShift() -> u8 { return 1u8 << values::Shift; } + +fn main() -> i32 { return Direct() + Local() + ModuleAlias(); } diff --git a/x_test/negative_imported_constant_queries/src/values.peep b/x_test/negative_imported_constant_queries/src/values.peep new file mode 100644 index 00000000..5a5d71a7 --- /dev/null +++ b/x_test/negative_imported_constant_queries/src/values.peep @@ -0,0 +1,2 @@ +const Offset: u128 = 1u128 << 64u128; +const Shift: u8 = 8u8; diff --git a/x_test/negative_module_constant_materialization/peeper.toml b/x_test/negative_module_constant_materialization/peeper.toml new file mode 100644 index 00000000..8d17dc25 --- /dev/null +++ b/x_test/negative_module_constant_materialization/peeper.toml @@ -0,0 +1,7 @@ +name = "negative_module_constant_materialization" +build = "program" + +[test] +mode = "build" +outcome = "failure" +stderr_contains = ["module constant `Value` cannot be evaluated at compile time"] diff --git a/x_test/negative_module_constant_materialization/src/main.peep b/x_test/negative_module_constant_materialization/src/main.peep new file mode 100644 index 00000000..247ef218 --- /dev/null +++ b/x_test/negative_module_constant_materialization/src/main.peep @@ -0,0 +1,5 @@ +const Value: i32 = 1i32 / 0i32; + +fn main() -> i32 { + return Value; +} diff --git a/x_test/negative_wide_constant_indexes/peeper.toml b/x_test/negative_wide_constant_indexes/peeper.toml new file mode 100644 index 00000000..1b0a7ef1 --- /dev/null +++ b/x_test/negative_wide_constant_indexes/peeper.toml @@ -0,0 +1,7 @@ +name = "negative_wide_constant_indexes" +build = "program" + +[test] +mode = "check" +outcome = "failure" +stderr_contains = ["array index out of bounds: index 4294967296 for length 1", "array index out of bounds: index -4294967296 for length 1", "array index out of bounds: index 18446744073709551616 for length 1"] diff --git a/x_test/negative_wide_constant_indexes/src/main.peep b/x_test/negative_wide_constant_indexes/src/main.peep new file mode 100644 index 00000000..d41a260e --- /dev/null +++ b/x_test/negative_wide_constant_indexes/src/main.peep @@ -0,0 +1,10 @@ +const Signed: i64 = -4294967296i64; +const Unsigned: u128 = 1u128 << 64u128; + +fn main() -> i32 { + let values = [1]i32{7}; + let _ = values[4294967296f64 as u64]; + let _ = values[(-4294967296f64) as i64]; + let _ = values[Signed]; + return values[Unsigned]; +} diff --git a/x_test/positive_large_array_bounds/peeper.toml b/x_test/positive_large_array_bounds/peeper.toml new file mode 100644 index 00000000..a3afd607 --- /dev/null +++ b/x_test/positive_large_array_bounds/peeper.toml @@ -0,0 +1,7 @@ +name = "positive_large_array_bounds" +build = "program" + +[test] +mode = "check" +outcome = "success" +compiler_args = ["-target-os", "linux", "-target-arch", "amd64"] diff --git a/x_test/positive_large_array_bounds/src/main.peep b/x_test/positive_large_array_bounds/src/main.peep new file mode 100644 index 00000000..2fc1b0da --- /dev/null +++ b/x_test/positive_large_array_bounds/src/main.peep @@ -0,0 +1,8 @@ +struct Large { + values: [9223372036854775809u64]u8, + flag: bool +} + +fn first(value: &Large) -> u8 { return value.values[0u64]; } +fn read(value: &Large, index: i64) -> u8 { return value.values[index]; } +fn main() -> i32 { return 0; } diff --git a/x_test/runtime_checked_constant_indexes/peeper.toml b/x_test/runtime_checked_constant_indexes/peeper.toml new file mode 100644 index 00000000..4643c89e --- /dev/null +++ b/x_test/runtime_checked_constant_indexes/peeper.toml @@ -0,0 +1,6 @@ +name = "runtime_checked_constant_indexes" +build = "program" + +[test] +mode = "run" +outcome = "success" diff --git a/x_test/runtime_checked_constant_indexes/src/indexes.peep b/x_test/runtime_checked_constant_indexes/src/indexes.peep new file mode 100644 index 00000000..75fbedf9 --- /dev/null +++ b/x_test/runtime_checked_constant_indexes/src/indexes.peep @@ -0,0 +1,4 @@ +type Index = u64; +const Selected: Index = 1u64; +const Wrapped: u8 = 255u8; +const Bit: u1 = 1u1; diff --git a/x_test/runtime_checked_constant_indexes/src/main.peep b/x_test/runtime_checked_constant_indexes/src/main.peep new file mode 100644 index 00000000..e71d7fa6 --- /dev/null +++ b/x_test/runtime_checked_constant_indexes/src/main.peep @@ -0,0 +1,38 @@ +import "runtime_checked_constant_indexes/indexes"; + +type Bit = u1; +struct Cell { value: i32 } +struct Box { guard: i32, values: [2]Cell } + +fn read(cell: &Cell) -> i32 { return cell.value; } +fn write(cell: &mut Cell, value: i32) { cell.value = value; } + +fn main() -> i32 { + const Wrapped: u8 = 255u8; + let values = [2]i32{7, 9}; + if values[0f64 as u64] != 7 { return 1; } + if values[1.75f64 as i64] != 9 { return 2; } + if values[(-0.75f64) as i64] != 7 { return 3; } + if values[indexes::Selected] != 9 { return 4; } + if values[-indexes::Wrapped] != 9 { return 5; } + if values[257u16 as u8] != 9 { return 6; } + if values[-Wrapped] != 9 { return 7; } + + const Selected: Bit = 1u1; + let mut box = Box.{guard = 31, values = [2]Cell{.{value = 7}, .{value = 9}}}; + if box.values[1f64 as u1].value != 9 { return 8; } + if box.values[1u1].value != 9 || box.values[0u1].value != 7 { return 9; } + if read(&box.values[Selected]) != 9 { return 10; } + if @box.values[1f64 as u1].value != @box.values[1u64].value { return 11; } + + box.values[1f64 as u1].value = 13; + if box.guard != 31 || box.values[0u64].value != 7 || box.values[1u64].value != 13 { return 12; } + write(&mut box.values[Selected], 17); + if box.guard != 31 || box.values[0u64].value != 7 || box.values[1u64].value != 17 { return 13; } + let index = 1f64 as u1; + if box.values[index].value != 17 { return 14; } + if read(&box.values[indexes::Bit]) != 17 { return 15; } + write(&mut box.values[indexes::Bit], 19); + if box.guard != 31 || box.values[0u64].value != 7 || box.values[1u64].value != 19 { return 16; } + return 0; +} diff --git a/x_test/runtime_constant_conversions/peeper.toml b/x_test/runtime_constant_conversions/peeper.toml new file mode 100644 index 00000000..25066013 --- /dev/null +++ b/x_test/runtime_constant_conversions/peeper.toml @@ -0,0 +1,6 @@ +name = "runtime_constant_conversions" +build = "program" + +[test] +mode = "run" +outcome = "success" diff --git a/x_test/runtime_constant_conversions/src/main.peep b/x_test/runtime_constant_conversions/src/main.peep new file mode 100644 index 00000000..4833cca2 --- /dev/null +++ b/x_test/runtime_constant_conversions/src/main.peep @@ -0,0 +1,38 @@ +import "runtime_constant_conversions/numbers"; + +const ImportedInteger: i64 = numbers::ConvertedInteger; +const ImportedRounded: f64 = numbers::RoundedInteger; + +fn DefaultWide(value: i64 = numbers::Total) -> i64 { return value; } + +fn ReadWide(value: i64) -> i64 { + return value; +} + +fn ConvertInteger(value: u64) -> f32 { + return value as f32; +} + +fn main() -> i32 { + if ReadWide(numbers::Total) != 3i64 { return 1; } + if numbers::Ratio != 1.25f32 { return 2; } + if ReadWide(numbers::Wide) != 2i64 { return 3; } + if numbers::SourceOverflow != -128i64 { return 4; } + if numbers::NestedOverflow != -127i64 { return 5; } + if numbers::UnaryOverflow != -128i64 { return 6; } + if numbers::RoundedWide != 16777216f64 { return 7; } + if numbers::FloatOverflow != 16777216f64 { return 8; } + if numbers::ConvertedFloat != -3f32 { return 9; } + if numbers::ConvertedInteger != -3i8 { return 10; } + let rounded = ConvertInteger(9223372586610589697u64); + if (rounded as u64) != 9223373136366403584u64 { return 11; } + if numbers::RoundedInteger != rounded { return 12; } + let infinity = (1u2048 << 1024u2048) as f64; + if numbers::IntegerInfinity != infinity { return 13; } + if numbers::IntegerInfinity != (1f64 / 0f64) { return 14; } + if ImportedInteger != -3i64 { return 15; } + if ImportedRounded != 9223373136366403584f64 { return 16; } + const Total: i64 = 99i64; + if DefaultWide() != 3i64 || Total != 99i64 { return 17; } + return 0; +} diff --git a/x_test/runtime_constant_conversions/src/numbers.peep b/x_test/runtime_constant_conversions/src/numbers.peep new file mode 100644 index 00000000..bcdc2d00 --- /dev/null +++ b/x_test/runtime_constant_conversions/src/numbers.peep @@ -0,0 +1,14 @@ +type WideInteger = i64; + +const Total: WideInteger = 1i32 + 2i64; +const Ratio: f32 = 1.25f64 as f32; +const Wide: i64 = 2i8; +const SourceOverflow: i64 = 127i8 + 1i8; +const NestedOverflow: i64 = (127i8 + 1i8) + 1i64; +const UnaryOverflow: i64 = -(-128i8); +const RoundedWide: f64 = 16777217f64 as f32; +const FloatOverflow: f64 = 16777216f32 + 1f32; +const ConvertedFloat: f32 = -3i8 as f32; +const ConvertedInteger: i8 = -3.75f64 as i8; +const RoundedInteger: f32 = 9223372586610589697u64 as f32; +const IntegerInfinity: f64 = (1u2048 << 1024u2048) as f64; diff --git a/x_test/runtime_high_bit_empty_array_index/peeper.toml b/x_test/runtime_high_bit_empty_array_index/peeper.toml new file mode 100644 index 00000000..e269bb3f --- /dev/null +++ b/x_test/runtime_high_bit_empty_array_index/peeper.toml @@ -0,0 +1,7 @@ +name = "runtime_high_bit_empty_array_index" +build = "program" + +[test] +mode = "check" +outcome = "success" +compiler_args = ["-target-os", "linux", "-target-arch", "386"] diff --git a/x_test/runtime_high_bit_empty_array_index/src/main.peep b/x_test/runtime_high_bit_empty_array_index/src/main.peep new file mode 100644 index 00000000..5e0f4417 --- /dev/null +++ b/x_test/runtime_high_bit_empty_array_index/src/main.peep @@ -0,0 +1,16 @@ +struct Empty {} +struct Large { values: [2147483649u64]Empty } + +fn constant_high(value: &Large) -> bool { + return @value.values[2147483648u32] == @value.values[0u32]; +} + +fn constant_low(value: &Large) -> bool { + return @value.values[2147483647u32] == @value.values[0u32]; +} + +fn runtime_high(value: &Large, index: u32) -> bool { + return @value.values[index] == @value.values[0u32]; +} + +fn main() -> i32 { return 0; } diff --git a/x_test/runtime_module_discard_constants/peeper.toml b/x_test/runtime_module_discard_constants/peeper.toml new file mode 100644 index 00000000..2644d3a6 --- /dev/null +++ b/x_test/runtime_module_discard_constants/peeper.toml @@ -0,0 +1,6 @@ +name = "runtime_module_discard_constants" +build = "program" + +[test] +mode = "run" +outcome = "success" diff --git a/x_test/runtime_module_discard_constants/src/main.peep b/x_test/runtime_module_discard_constants/src/main.peep new file mode 100644 index 00000000..cf1fc2ac --- /dev/null +++ b/x_test/runtime_module_discard_constants/src/main.peep @@ -0,0 +1,6 @@ +const _: i32 = 1i32; +const _: f64 = 1.25f32; + +fn main() -> i32 { + return 0; +}