diff --git a/.goreleaser.yaml b/.goreleaser.yaml index 849bae06..205096ea 100644 --- a/.goreleaser.yaml +++ b/.goreleaser.yaml @@ -251,7 +251,7 @@ nfpms: - apk file_name_template: '{{ .ProjectName }}_{{ .Version }}_linux_{{ .Arch }}' homepage: 'https://github.com/{{ .Env.GITHUB_REPOSITORY }}' - maintainer: '{{ or .Env.NFPM_MAINTAINER "Pluto Contributors " }}' + maintainer: '{{ or .Env.NFPM_MAINTAINER "Tejas " }}' description: Pluto programming language compiler (requires LLVM 22 toolchain on target host). license: 'Apache-2.0 WITH LLVM-exception' bindir: /usr/bin diff --git a/README.md b/README.md index c2f6628a..19c4c48c 100644 --- a/README.md +++ b/README.md @@ -44,7 +44,7 @@ Scope-based memory (no nulls, no out-of-bounds, no GC), and concurrency by const - Range literals with auto-vectorized execution - First-class rectangular arrays of any rank, columnar tables, and link semantics - Scope-based memory: no nulls, no out-of-bounds, no garbage collector -- printf-style formatting; arrays/ranges printable +- printf-style formatting; arrays printable and range streams iterable - Cross-platform (Linux/macOS/Windows) ## Command name @@ -143,18 +143,18 @@ b = Square(2.2) # float specialization c = Square(arr) # squares each array element a, b, c -# Range, mask, and filter (non-accumulated) +# Range, mask, and filter (non-collected) d = Square(1:3) # range: final iteration result -e = Square(arr[1:3]) # array-range: final iteration result +e = Square(arr[1:3]) # range-indexed array: final iteration result f = Square(arr > 3) # element-wise mask: each element kept where > 3, else 0 -g = Square(arr[1:3] > 3) # array-range filter: final iteration result +g = Square(arr[1:3] > 3) # range-indexed filter: final iteration result d, e, f, g # Accumulation forms -h = [Square(1:3)] # range accumulation -i = [Square(arr[1:3])] # array-range accumulation -j = [Square((1:3) > 1)] # conditional range accumulation -k = [Square(arr[1:4] > 2)] # conditional array-range accumulation +h = [Square(1:3)] # range collection +i = [Square(arr[1:3])] # range-indexed collection +j = [Square((1:3) > 1)] # conditional range collection +k = [Square(arr[1:4] > 2)] # conditional range-indexed collection h, i, j, k ``` @@ -174,13 +174,34 @@ Generated code is equivalent to handwritten specialized code. There is no runtim ## Ranges as the execution primitive -Ranges are first-class values: +A range literal binds an execution domain: ```python -Square(1:5) +i = 0:5 +copy = i # same bounds, independent named driver +values = [copy] # [0 1 2 3 4] +last = i + 0 # 4 +lastSquare = Square(i) # 16 +i # prints the descriptor: 0:5 ``` -Passing a range to a template executes it across all values. The compiler can map these operations to SIMD instructions — this is range-driven auto-vectorization. +A bare range is a value: assignment copies it and print shows its descriptor. +Using it in an operation creates a ranged computation: the assignment keeps +the final yield, brackets materialize all yields into an array, and passing it +to a template executes the call once for each value. The compiler can map +these operations to SIMD instructions — this is range-driven +auto-vectorization. Each named Range binding is a distinct driver: different +names form a cartesian domain when consumed together, even when their bounds +are equal. + +Range-indexed array access follows the same rule: + +```python +arr = [10 20 30 40] +i = 1:4 +last = arr[i] # 40 +selected = [arr[i]] # [20 30 40] +``` Data-parallel execution without explicit loop syntax. @@ -195,7 +216,11 @@ x = [1 2 3 4 5] y = [1.1 2.2 3.3] ``` -Arrays are safe by construction — out-of-bounds access is not possible. Comparisons like `arr > 2` produce element-wise masks (each element kept where it holds, else 0) that work anywhere an array does. +Arrays are bounds-checked: an invalid index never reads outside array storage. +In a scalar range stream, invalid selection points yield nothing; inside `[]`, +they zero-fill to preserve shape. Comparisons like `arr > 2` produce +element-wise masks (each element kept where it holds, else 0) that work +anywhere an array does. The same bracket syntax infers higher-rank arrays and tables without a type keyword: diff --git a/ast/ast.go b/ast/ast.go index 5afd37c6..cf9f1d7c 100644 --- a/ast/ast.go +++ b/ast/ast.go @@ -97,7 +97,7 @@ type Func struct { // For example: // // p = Person -// :name age +// : name age // "Tejas" 35 type StructStatement struct { Token token.Token // The token.ASSIGN token @@ -360,8 +360,8 @@ func writeArrayRow(out *bytes.Buffer, row []Expression) { // Example: // p = Person // -// :name age -// "Tejas" 35 +// : name age +// "Tejas" 35 type StructLiteral struct { Token token.Token // the type name token (e.g. "Person") Headers []token.Token @@ -375,7 +375,7 @@ func (sl *StructLiteral) String() string { out.WriteString(sl.Token.Literal) if len(sl.Headers) > 0 { - out.WriteString("\n :") + out.WriteString("\n : ") for i, header := range sl.Headers { if i > 0 { out.WriteString(" ") diff --git a/compiler/abi.go b/compiler/abi.go index cc905c5a..b3efd1ec 100644 --- a/compiler/abi.go +++ b/compiler/abi.go @@ -22,16 +22,15 @@ type ABIParam struct { } type ABIReturn struct { - Mode ABIReturnMode - DirectType Type - OutTypes []Type - HasSeedParam bool + Mode ABIReturnMode + DirectType Type + OutTypes []Type } // FuncABI captures the lowered function boundary for one mangled variant. -// Range-bearing variants may need hidden alias/seed state so direct scalar -// params and returns still preserve loop-carried accumulation and empty-range -// no-op semantics inside the callee. +// Direct scalar returns carry a hidden destination seed so a skipped write +// preserves the caller's value. Range-bearing variants may additionally need +// hidden alias state for loop-carried accumulation. type FuncABI struct { Params []ABIParam Return ABIReturn @@ -49,6 +48,18 @@ func isDirectScalarABIType(t Type) bool { } } +// aliasableOutput reports whether an output can back a parameter's alias slot. +// The hidden selector picks an output by position and the callee then reads that +// storage as the parameter's own type, so the two must lower identically. There +// is no numeric conversion anywhere on this path, and a pointer selected across +// mismatched types would be loaded as the wrong type. +func aliasableOutput(paramType, outputType Type) bool { + if ptr, ok := outputType.(Ptr); ok { + outputType = ptr.Elem + } + return TypeEqual(paramType, outputType) +} + func directScalarABIReturnType(outTypes []Type) (Type, bool) { if len(outTypes) != 1 { return nil, false @@ -95,9 +106,11 @@ func classifyFuncABI(paramTypes []Type, outTypes []Type) FuncABI { } if directType, ok := directScalarABIReturnType(outTypes); ok { + // Whether a function body writes its output conditionally is not part + // of the type-based mangle. Keep the native C ABI stable across body + // changes: direct-return mode always implies a destination seed. abi.Return.Mode = ABIReturnDirect abi.Return.DirectType = directType - abi.Return.HasSeedParam = abi.HasRangeParams } return abi @@ -141,7 +154,7 @@ func (abi FuncABI) AliasFunctionParamIndex(paramIndex int) int { } func (abi FuncABI) DirectReturnSeedParamIndex() int { - if abi.Return.Mode != ABIReturnDirect || !abi.Return.HasSeedParam { + if abi.Return.Mode != ABIReturnDirect { return -1 } return abi.AliasParamBaseIndex() + abi.NumAliasSlots() diff --git a/compiler/array.go b/compiler/array.go index bee4c878..702e84d2 100644 --- a/compiler/array.go +++ b/compiler/array.go @@ -439,7 +439,16 @@ func (c *Compiler) compileArrayLiteralCell(cell ast.Expression, elemType Type, c c.withArrayLiteralCellMode(func() { c.compileCondExprValue(cell, llvm.Value{}, func() { + errorsBefore := len(c.Errors) vals := c.compileExpression(cell, nil) + if len(vals) == 0 { + // Only an error from this lowering explains the missing value; + // leave the seed in place so that diagnostic can surface. + if len(c.Errors) > errorsBefore { + return + } + panic("internal: array cell lowering produced no value and recorded no error") + } c.storeArrayCellSlotWhenInBounds(cellSlot, vals, cell) }) }) @@ -953,19 +962,6 @@ func (c *Compiler) arrayFormatArg(s *Symbol, info ArrayInfo, elementFormat strin return c.builder.CreateCall(fnTy, fn, formatArgs, "arr_format") } -func (c *Compiler) arrayRangeStrArgs(s *Symbol) (arrayStr llvm.Value, rangeStr llvm.Value) { - arrRange := s.Type.(ArrayRange) - agg := s.Val - arrPtr := c.builder.CreateExtractValue(agg, 0, "array_range_arr") - arrSym := &Symbol{Val: arrPtr, Type: arrRange.Array} - arrayStr = c.arrayStrArg(arrSym) - - rangeVal := c.builder.CreateExtractValue(agg, 1, "array_range_rng") - rangeSym := &Symbol{Val: rangeVal, Type: arrRange.Range} - rangeStr = c.rangeStrArg(rangeSym) - return -} - func (c *Compiler) compileArrayRangeOperands(expr *ast.ArrayRangeExpression) (*Symbol, *Symbol, Array) { arrayLoadName := "" if arrayIdent, ok := expr.Array.(*ast.Identifier); ok { @@ -982,6 +978,20 @@ func (c *Compiler) compileArrayRangeOperands(expr *ast.ArrayRangeExpression) (*S return arraySym, idxSym, arrType } +// compileArrayRangeCallArg builds the internal descriptor used only at an +// immediate call boundary. Ordinary array indexing still lowers to the final +// selected element/subarray and therefore cannot expose or retain ArrayRange. +func (c *Compiler) compileArrayRangeCallArg(expr *ast.ArrayRangeExpression, typ ArrayRange) *Symbol { + arraySym, rangeSym, _ := c.compileArrayRangeOperands(expr) + + _, arrayIsIdent := expr.Array.(*ast.Identifier) + return &Symbol{ + Val: c.CreateArrayRange(arraySym.Val, rangeSym.Val, typ), + Type: typ, + Borrowed: arrayIsIdent || arraySym.Borrowed, + } +} + func (c *Compiler) normalizeArrayIndex(idxSym *Symbol) llvm.Value { idxVal := idxSym.Val intType, ok := idxSym.Type.(Int) @@ -994,9 +1004,14 @@ func (c *Compiler) normalizeArrayIndex(idxSym *Symbol) llvm.Value { return c.builder.CreateIntCast(idxVal, c.Context.Int64Type(), "arr_idx_cast") } -func (c *Compiler) storeArrayRangeOutput(output *Symbol, value llvm.Value, valueType Type) { +func (c *Compiler) storeRangedOutput(output *Symbol, value llvm.Value, valueType Type) { c.freeSymbolValue(output, "old_output") - c.createStore(value, output.Val, valueType) + c.storeSymbolToSlot( + output, + &Symbol{Val: value, Type: valueType}, + output.Type.(Ptr).Elem, + "range_output_store", + ) } func (c *Compiler) compileArraySubarray(array *Symbol, index llvm.Value) *Symbol { @@ -1049,19 +1064,12 @@ func (c *Compiler) checkedArraySubarray(array *Symbol, index llvm.Value, inBound return c.createLoad(resultSlot, resultType, "array_subarray_checked") } -// compileArrayRangeExpression compiles an array indexing expression. -// If the index is a range (e.g., arr[0:10]), returns an ArrayRange symbol. -// If the index is a scalar (e.g., arr[4]), returns the element. -// We check the actual compiled index type, not cached OutTypes, because -// inside a loop the index may be bound to a scalar even if originally a range. +// compileArrayRangeExpression compiles an array indexing expression. A ranged +// index is always finalized or collected through the surrounding loop; only a +// scalar element/subarray value reaches compileArrayRangeBasic. func (c *Compiler) compileArrayRangeExpression(expr *ast.ArrayRangeExpression, dest []*ast.Identifier) []*Symbol { info := c.ExprCache[key(c.FuncNameMangled, expr)] - pending := c.pendingLoopRanges(info.Ranges) - if len(pending) > 0 { - // Bare range indices (arr[i] where i is a range) remain ArrayRange views. - if len(info.OutTypes) > 0 && info.OutTypes[0].Kind() == ArrayRangeKind { - return c.compileArrayRangeBasic(expr) - } + if len(c.pendingLoopRanges(info.Ranges)) > 0 { return c.compileArrayRangeRanges(info, dest) } return c.compileArrayRangeBasic(expr) @@ -1070,21 +1078,8 @@ func (c *Compiler) compileArrayRangeExpression(expr *ast.ArrayRangeExpression, d func (c *Compiler) compileArrayRangeBasic(expr *ast.ArrayRangeExpression) []*Symbol { arraySym, idxSym, arrType := c.compileArrayRangeOperands(expr) - // Check actual compiled index type to determine ArrayRange vs element access if idxSym.Type.Kind() == RangeKind { - // ArrayRange from an identifier is a borrowed view into existing storage. - _, arrayIsIdent := expr.Array.(*ast.Identifier) - borrowed := arrayIsIdent || arraySym.Borrowed - - arrRange := ArrayRange{ - Array: arrType, - Range: idxSym.Type.(Range), - } - return []*Symbol{{ - Val: c.CreateArrayRange(arraySym.Val, idxSym.Val, arrRange), - Type: arrRange, - Borrowed: borrowed, - }} + panic("internal: ranged array access reached scalar lowering without an iterator loop") } // Scalar element access does not retain the source array pointer. // Release temporary array sources on all scalar return paths; a consumed @@ -1124,7 +1119,8 @@ func (c *Compiler) compileArrayRangeRanges(info *ExprInfo, dest []*ast.Identifie PushScope(&c.Scopes, BlockScope) defer c.popScope() - outputs := c.makeOutputs(dest, info.OutTypes, true) + outputs := c.makeSeededTempOutputs(dest, info.OutTypes) + c.bindRangedTempOutputs(dest, outputs) output := outputs[0] withCollectorPreparedLoopNest(c, info.Rewrite.(*ast.ArrayRangeExpression), info.Ranges, nil, nil, func(rew *ast.ArrayRangeExpression) { @@ -1145,11 +1141,11 @@ func (c *Compiler) compileArrayRangeRanges(info *ExprInfo, dest []*ast.Identifie if c.currentLoopBoundsMode() == loopBoundsModeAffineFast && c.isFastAffineAccess(rew) { if arrType.Rank > 1 { subarray := c.compileArraySubarray(arraySym, idxVal) - c.storeArrayRangeOutput(output, subarray.Val, resultType) + c.storeRangedOutput(output, subarray.Val, resultType) return } elemVal := c.ArrayGet(arraySym, arrElemType, idxVal) - c.storeArrayRangeOutput(output, elemVal, resultType) + c.storeRangedOutput(output, elemVal, resultType) return } @@ -1160,10 +1156,10 @@ func (c *Compiler) compileArrayRangeRanges(info *ExprInfo, dest []*ast.Identifie c.builder.SetInsertPointAtEnd(storeBlock) if arrType.Rank > 1 { subarray := c.compileArraySubarray(arraySym, idxVal) - c.storeArrayRangeOutput(output, subarray.Val, resultType) + c.storeRangedOutput(output, subarray.Val, resultType) } else { elemVal := c.ArrayGet(arraySym, arrElemType, idxVal) - c.storeArrayRangeOutput(output, elemVal, resultType) + c.storeRangedOutput(output, elemVal, resultType) } c.builder.CreateBr(contBlock) @@ -1172,7 +1168,7 @@ func (c *Compiler) compileArrayRangeRanges(info *ExprInfo, dest []*ast.Identifie if arrType.Rank > 1 { zeroVal = c.zeroArraySubarray(arraySym) } - c.storeArrayRangeOutput(output, zeroVal.Val, zeroVal.Type) + c.storeRangedOutput(output, zeroVal.Val, zeroVal.Type) c.builder.CreateBr(contBlock) c.builder.SetInsertPointAtEnd(contBlock) @@ -1184,10 +1180,10 @@ func (c *Compiler) compileArrayRangeRanges(info *ExprInfo, dest []*ast.Identifie c.builder.SetInsertPointAtEnd(storeBlock) if arrType.Rank > 1 { subarray := c.compileArraySubarray(arraySym, idxVal) - c.storeArrayRangeOutput(output, subarray.Val, resultType) + c.storeRangedOutput(output, subarray.Val, resultType) } else { elemVal := c.ArrayGet(arraySym, arrElemType, idxVal) - c.storeArrayRangeOutput(output, elemVal, resultType) + c.storeRangedOutput(output, elemVal, resultType) } c.builder.CreateBr(contBlock) @@ -1195,9 +1191,5 @@ func (c *Compiler) compileArrayRangeRanges(info *ExprInfo, dest []*ast.Identifie }) }) - elemType := output.Type.(Ptr).Elem - return []*Symbol{{ - Val: c.createLoad(output.Val, elemType, "final"), - Type: elemType, - }} + return c.loadOutputValues(outputs, "final") } diff --git a/compiler/cfg.go b/compiler/cfg.go index b322fd18..50868853 100644 --- a/compiler/cfg.go +++ b/compiler/cfg.go @@ -158,71 +158,186 @@ func (cfg *CFG) collectSpecifierReads(value string, tok token.Token, runes []run return evs, spec.end } -func (cfg *CFG) extractStmtEvents(stmt ast.Statement) []VarEvent { - var evs []VarEvent // Holds all events for this statement +// extractStmtEvents records the reads common to every statement and appends +// destination writes when stmt is a LetStatement. Callers supply write kinds +// only for a LetStatement; a PrintStatement has no destinations. +func (cfg *CFG) extractStmtEvents(stmt ast.Statement, kinds []EventType) []VarEvent { + var reads []ast.Expression + var names []*ast.Identifier switch s := stmt.(type) { case *ast.LetStatement: - // A LetStatement always follows the same order: - // 1. Read all variables used in the Condition(s). - for _, expr := range s.Condition { - evs = append(evs, cfg.collectReads(expr)...) - } - // 2. Read all variables used in the Value(s). - for _, expr := range s.Value { - evs = append(evs, cfg.collectReads(expr)...) - } - // 3. Write to the destination variable(s). - // Determine the type of write - writeKind := Write - if len(s.Condition) > 0 || cfg.HasRangeExpr(s.Value) { - writeKind = ConditionalWrite + reads = make([]ast.Expression, 0, len(s.Condition)+len(s.Value)) + reads = append(reads, s.Condition...) + reads = append(reads, s.Value...) + names = s.Name + case *ast.PrintStatement: + reads = s.Expression.Arguments + default: + return nil + } + + var evs []VarEvent + for _, expr := range reads { + evs = append(evs, cfg.collectReads(expr)...) + } + for i, lhs := range names { + // Treat '_' as a discard target: do not record writes or liveness. + if lhs.Value == "_" { + continue } - for _, lhs := range s.Name { - // Treat '_' as a discard target: do not record writes or liveness. - if lhs.Value == "_" { - continue + + ve := VarEvent{Name: lhs.Value, Kind: kinds[i], Token: lhs.Tok()} + Put(cfg.Scopes, lhs.Value, ve) + evs = append(evs, ve) + } + return evs +} + +// destWriteKinds classifies each destination write of a statement. A statement +// condition suspends the whole simultaneous assignment. Every other source of +// a skipped write belongs to one value expression — an empty driver, a callee +// that keeps its output, a value that never yields — and leaves sibling +// expressions' writes untouched, so those mark only the destinations their own +// expression feeds and a dead store behind an unconditional sibling is still +// reported. +func (cfg *CFG) destWriteKinds(s *ast.LetStatement) []EventType { + if len(s.Condition) > 0 { + return makeWriteKinds(len(s.Name), ConditionalWrite) + } + + kinds := makeWriteKinds(len(s.Name), Write) + c := cfg.ScriptCompiler.Compiler + dest := 0 + for _, v := range s.Value { + maySkip := cfg.valueMaySkip(v) + span := c.ExprCache[key(c.FuncNameMangled, v)].ExprLen + for j := 0; j < span; j++ { + if maySkip { + kinds[dest] = ConditionalWrite } + dest++ + } + } + return kinds +} - ve := VarEvent{Name: lhs.Value, Kind: writeKind, Token: lhs.Tok()} - Put(cfg.Scopes, lhs.Value, ve) - evs = append(evs, ve) +// funcDestWriteKinds classifies writes using the syntax available in an +// untyped .pt function template. Values pair one-to-one with destinations when +// their counts match. Otherwise output arity is unknown, so any syntactically +// skippable value protects every destination. Range effects that depend on +// inferred bindings are unavailable in this pass. +func (cfg *CFG) funcDestWriteKinds(s *ast.LetStatement) []EventType { + if len(s.Condition) > 0 { + return makeWriteKinds(len(s.Name), ConditionalWrite) + } + + kinds := makeWriteKinds(len(s.Name), Write) + if len(s.Value) == len(s.Name) { + for i, v := range s.Value { + if cfg.funcValueMaySkip(v) { + kinds[i] = ConditionalWrite + } } + return kinds + } - case *ast.PrintStatement: - for _, expr := range s.Expression.Arguments { - evs = append(evs, cfg.collectReads(expr)...) + for _, v := range s.Value { + if !cfg.funcValueMaySkip(v) { + continue + } + for i := range kinds { + kinds[i] = ConditionalWrite } + break } - return evs + return kinds } -// HasRangeExpr returns true if any RHS expression has a range -// used in an iterated position, mirroring the solver's iterate behavior. -// Examples that return true: -// - y = y + 1:5 -// - y = f(x) + 2:3 -// - y = f(1:5) -// -// Example that returns false: -// - i = 1:5 (non-iterated range literal, just a plain write of a range value) -func (cfg *CFG) HasRangeExpr(values []ast.Expression) bool { - for _, v := range values { - if cfg.hasRangeExpr(v) { - return true - } +func makeWriteKinds(count int, kind EventType) []EventType { + kinds := make([]EventType, count) + for i := range kinds { + kinds[i] = kind + } + return kinds +} + +// valueMaySkip reports whether an RHS expression can leave its destination +// unchanged in a typed script: outside an inline collector, an empty range +// driver can run no iterations; a root call can keep an unwritten output; and +// a failable value can yield nothing. The shared tree traversal finds failures +// below the root, so `y = Square(x < 5) + 5` is recognized even though the call +// feeds an operator. +func (cfg *CFG) valueMaySkip(expr ast.Expression) bool { + return cfg.hasRangeExpr(expr) || + cfg.callRootMaySkip(expr) || + treeCanFail(expr, cfg.nodeMayNotYield) +} + +// funcValueMaySkip applies the syntax-only approximation available in an +// untyped .pt function template. Calls and syntactically failable values are +// visible here; range effects that depend on inferred bindings are not. +func (cfg *CFG) funcValueMaySkip(expr ast.Expression) bool { + return cfg.callRootMaySkip(expr) || + treeCanFail(expr, cfg.funcNodeMayNotYield) +} + +// nodeMayNotYield uses exact solver metadata to classify one node in a typed +// script. It also counts an array read, whose out-of-bounds case preserves the +// destination. That widening belongs only to diagnostics: applying it to the +// solver's validity predicate would incorrectly legalize `arr[9] || -1`. +func (cfg *CFG) nodeMayNotYield(expr ast.Expression) bool { + if _, ok := expr.(*ast.ArrayRangeExpression); ok { + return true + } + c := cfg.ScriptCompiler.Compiler + info := c.ExprCache[key(c.FuncNameMangled, expr)] + return info.HasCondScalar() || info.HasCondAnd() +} + +// funcNodeMayNotYield classifies one node in an untyped .pt function template. +// Without specialization metadata, comparison and logical-AND syntax is the +// conservative signal that a scalar specialization may fail to yield. +func (cfg *CFG) funcNodeMayNotYield(expr ast.Expression) bool { + if _, ok := expr.(*ast.ArrayRangeExpression); ok { + return true + } + if infix, ok := expr.(*ast.InfixExpression); ok { + return infix.Token.IsComparison() || infix.IsLogicalAnd() } return false } -// hasRangeExpr checks if an expression contains ranges by looking at ExprCache -func (cfg *CFG) hasRangeExpr(e ast.Expression) bool { - // Only possible when we have ScriptCompiler with ExprCache - if cfg.ScriptCompiler == nil { +// callRootMaySkip reports whether a value is a bare call to a user-defined +// function. Such a callee may leave an output unwritten, so the caller keeps +// its previous value and that previous write is not dead. Only root position +// qualifies: a call feeding an operator always yields a new value. Proving a +// given callee always writes would need per-specialization range types +// unavailable here, so this stays conservative. +func (cfg *CFG) callRootMaySkip(v ast.Expression) bool { + call, ok := v.(*ast.CallExpression) + if !ok { return false } + _, builtin := Builtins[call.Function.Value] + return !builtin +} + +// hasRangeExpr reports whether an RHS expression uses a range in an iterated +// position, mirroring the solver's behavior. A bare range literal is a +// descriptor value and therefore does not count. +func (cfg *CFG) hasRangeExpr(e ast.Expression) bool { c := cfg.ScriptCompiler.Compiler switch t := e.(type) { + case *ast.Identifier: + // Descriptor-copy assignments clear their cached ranges during typing. + // A remaining range here is a scalar use of a driver already bound by + // the statement, so an empty driver may leave the destination unchanged. + return len(c.ExprCache[key(c.FuncNameMangled, t)].Ranges) > 0 + case *ast.StringLiteral: + // Formatting markers can reference named Range drivers even though the + // dependency is not represented as an AST child. + return len(c.ExprCache[key(c.FuncNameMangled, t)].Ranges) > 0 case *ast.InfixExpression, *ast.PrefixExpression: return len(c.ExprCache[key(c.FuncNameMangled, t)].Ranges) > 0 case *ast.ArrayRangeExpression: @@ -239,13 +354,9 @@ func (cfg *CFG) hasRangeExpr(e ast.Expression) bool { } return false case *ast.ArrayLiteral: - for _, row := range t.Rows { - for _, cell := range row { - if cfg.hasRangeExpr(cell) { - return true - } - } - } + // A collector materializes an array even when its domain is empty, so + // its write is unconditional; cells resolve failures locally. Same + // boundary as treeCanFail. return false case *ast.StructLiteral: for _, cell := range t.Row { @@ -257,7 +368,8 @@ func (cfg *CFG) hasRangeExpr(e ast.Expression) bool { case *ast.DotExpression: return cfg.hasRangeExpr(t.Left) default: - // Identifiers, literals, etc. are not conditional at root level + // Literals and other scalar roots are unconditional. A bare range + // literal is a driver constructor rather than an iterated use. return false } } @@ -364,7 +476,7 @@ func (cfg *CFG) validateFunc(fn *ast.FuncStatement) { Put(cfg.Scopes, param.Value, ve) } - cfg.forwardPass(fn.Body.Statements) + cfg.funcForwardPass(fn.Body.Statements) // Build set of output names outSet := make(map[string]struct{}, len(fn.Outputs)) @@ -392,26 +504,46 @@ func (cfg *CFG) validateFunc(fn *ast.FuncStatement) { cfg.backwardPass(live) } -// forwardPass checks for use-before-definition and simple write-after-write errors. -// This pass iterates forward through the events. +// processForwardEvents applies one statement's events and records them for the +// backward liveness pass. +func (cfg *CFG) processForwardEvents(stmt ast.Statement, evs []VarEvent, lastWrites map[string]VarEvent) { + for _, e := range evs { + switch e.Kind { + case Read: + cfg.checkRead(lastWrites, e) + case Write, ConditionalWrite: + cfg.checkWrite(lastWrites, e) + default: + panic(fmt.Sprintf("unhandled event type: %v", e.Kind)) + } + } + block := cfg.Blocks[len(cfg.Blocks)-1] + block.Stmts = append(block.Stmts, &StmtNode{Stmt: stmt, Events: evs}) +} + +// forwardPass checks a typed script for use-before-definition and simple +// write-after-write errors. func (cfg *CFG) forwardPass(statements []ast.Statement) { - block := cfg.Blocks[len(cfg.Blocks)-1] // Get the last block lastWrites := make(map[string]VarEvent) + for _, stmt := range statements { + var kinds []EventType + if s, ok := stmt.(*ast.LetStatement); ok { + kinds = cfg.destWriteKinds(s) + } + cfg.processForwardEvents(stmt, cfg.extractStmtEvents(stmt, kinds), lastWrites) + } +} +// funcForwardPass performs the same checks on an untyped .pt function +// template, using its syntax-only write classification. +func (cfg *CFG) funcForwardPass(statements []ast.Statement) { + lastWrites := make(map[string]VarEvent) for _, stmt := range statements { - evs := cfg.extractStmtEvents(stmt) - for _, e := range evs { - switch e.Kind { - case Read: - cfg.checkRead(lastWrites, e) - case Write, ConditionalWrite: - cfg.checkWrite(lastWrites, e) - default: - panic(fmt.Sprintf("unhandled event type: %v", e.Kind)) - } + var kinds []EventType + if s, ok := stmt.(*ast.LetStatement); ok { + kinds = cfg.funcDestWriteKinds(s) } - sn := &StmtNode{Stmt: stmt, Events: evs} - block.Stmts = append(block.Stmts, sn) + cfg.processForwardEvents(stmt, cfg.extractStmtEvents(stmt, kinds), lastWrites) } } diff --git a/compiler/cfg_test.go b/compiler/cfg_test.go index 2f21d1f0..464bc0ef 100644 --- a/compiler/cfg_test.go +++ b/compiler/cfg_test.go @@ -90,6 +90,31 @@ func getValidTestCases() []cfgTestCase { name: "Var Not Defined", input: `"Value: -x%s"`, }, + { + // The callee may skip its write, leaving the destination's previous + // value in place, so that previous write is live. + name: "Write then Skippable Call Root", + code: `res = maybeWrite(x) + res = x > 0 42`, + input: "x = 7\nx = maybeWrite(-1)\nx", + }, + { + // A condition below the value root still leaves the whole RHS able + // to yield nothing, so the earlier write stays live. + name: "Nested Condition Below Root", + input: "x = 7\ny = 10\ny = (x < 5) + 5\ny", + }, + { + // An out-of-bounds read fails its lanes and preserves the target. + name: "Out Of Bounds Read Preserves Destination", + input: "arr = [1]\ny = 10\ny = arr[9]\ny", + }, + { + // The failable expression suspends only its own destination, and + // b is fresh, so nothing behind the unconditional sibling is dead. + name: "Failable Value Protects Only Its Own Destination", + input: "x = 7\na = 10\na, b = x < 5, 30\na, b", + }, } } @@ -140,6 +165,36 @@ func getErrorTestCases() []cfgTestCase { input: "a=1\nb=2\nc=3\na, b", errorContains: `value assigned to "c" is never used`, }, + { + // A call feeding an operator always contributes to a new value, so + // the write stays unconditional and the earlier one is still dead. + name: "Call Feeding Operator Stays Unconditional", + code: `res = alwaysWrite(x) + res = x * 2`, + input: "x = 7\nx = alwaysWrite(3) + 1\nx", + errorContains: `unconditional assignment to "x" overwrites a previous value that was never used`, + }, + { + // A || yields whenever its final fallback does, so the resolver + // boundary holds and this write is unconditional. + name: "Logical Or With Unconditional Fallback", + input: "x = 7\ny = 10\ny = (x < 5) || 99\ny", + errorContains: `unconditional assignment to "y" overwrites a previous value that was never used`, + }, + { + // An array literal settles a failed cell locally, so the literal + // always yields and the boundary holds. + name: "Array Literal Cell Stays Unconditional", + input: "x = 7\ny = [1]\ny = [x < 5]\ny", + errorContains: `unconditional assignment to "y" overwrites a previous value that was never used`, + }, + { + // A failable sibling no longer suspends the whole statement, so + // the dead store behind the unconditional literal is reported. + name: "Failable Sibling Does Not Protect Unconditional Write", + input: "x = 7\na = 10\nb = 20\na, b = x < 5, 30\na, b", + errorContains: `unconditional assignment to "b" overwrites a previous value that was never used`, + }, { name: "Print Use Before Def", input: `"x is", x`, @@ -183,7 +238,11 @@ func runCFGTest(t *testing.T, tc cfgTestCase, expectError bool) { cc := NewCodeCompiler(ctx, "TestCFGAnalysis", "", cp.Parse()) cc.Compile() cfg := NewCFG(nil, cc) - cfg.Analyze(prog.Statements) + cfg.PushBlock() + defer cfg.PopBlock() + PushScope(&cfg.Scopes, BlockScope) + cfg.funcForwardPass(prog.Statements) + cfg.backwardPass(make(map[string]struct{})) if expectError { assertHasExpectedError(t, cfg.Errors, tc.errorContains) @@ -200,6 +259,87 @@ func assertHasExpectedError(t *testing.T, errors []*token.CompileError, expected } } +func compileScriptForCFGTest(t *testing.T, name, input string) []*token.CompileError { + t.Helper() + + ctx := llvm.NewContext() + defer ctx.Dispose() + + cc := NewCodeCompiler(ctx, name, "", ast.NewCode()) + program := parseInput(t, name, input) + sc := NewScriptCompiler(ctx, program, cc, make(map[string]*Func), cc.Compiler.ExprCache) + return sc.Compile() +} + +// A collector materializes an array even over an empty domain, so its write is +// unconditional and the store behind it is dead. Range classification needs the +// solver, so this runs the full script pipeline. +func TestCollectorWriteIsUnconditional(t *testing.T) { + errs := compileScriptForCFGTest(t, "collectorWrite", "i = 0:0\nc = [9]\nc = [i + 0]\nc") + require.NotEmpty(t, errs, "the dead store behind the collector must be reported") + assert.Contains(t, errs[0].Msg, `unconditional assignment to "c"`) +} + +// Typed comparison metadata distinguishes a scalar condition, which may not +// yield, from an array mask, which always materializes an array. The untyped +// function-template fallback intentionally cannot make that distinction. +func TestArrayComparisonWriteIsUnconditional(t *testing.T) { + errs := compileScriptForCFGTest(t, "arrayComparisonWrite", "a = [1 2]\nr = [9 9]\nr = a > 0\nr") + require.NotEmpty(t, errs, "the dead store behind the array mask must be reported") + assert.Contains(t, errs[0].Msg, `unconditional assignment to "r"`) +} + +// A ranged gate can admit no iterations, so collector and scalar destinations +// both preserve their prior values and both writes stay conditional. +func TestRangedGateCollectorWriteIsConditional(t *testing.T) { + errs := compileScriptForCFGTest(t, "rangedGateCollector", "i = 0:1\nc = [9]\ns = 42\nc, s = i < 0 [i], i + 7\nc, s") + require.Empty(t, errs) +} + +func TestGateArrayWriteKinds(t *testing.T) { + tests := []struct { + name string + input string + }{ + { + name: "empty ranged gate preserves collector", + input: "c = [9]\nc = 0:0 [1]\nc", + }, + { + name: "ranged block preserves destination", + input: "c = [\n 9\n]\ni = 0:1\nc = i < 0 [\n 1\n]\nc", + }, + { + name: "scalar collector preserves destination", + input: "flag = 0\nc = [9]\nc = flag > 0 [1]\nc", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + errs := compileScriptForCFGTest(t, tt.name, tt.input) + require.Empty(t, errs) + }) + } +} + +// A ranged expression suspends its own destination only: the sibling literal +// writes even when the domain is empty, so the store behind it is dead. Range +// classification needs the solver, so this runs the full script pipeline +// rather than the bare-CFG harness. +func TestEmptyDomainDoesNotProtectSiblingWrite(t *testing.T) { + errs := compileScriptForCFGTest(t, "emptyDomainSibling", "i = 0:0\na = 1\nb = 2\na, b = i + 0, 30\na, b") + require.NotEmpty(t, errs, "the dead store behind the sibling literal must be reported") + + msgs := make([]string, len(errs)) + for i, e := range errs { + msgs[i] = e.Msg + } + joined := strings.Join(msgs, "\n") + assert.Contains(t, joined, `unconditional assignment to "b"`) + assert.NotContains(t, joined, `to "a"`, "the ranged destination must stay protected") +} + func TestValidateFuncOutputsNotDeadStore(t *testing.T) { ctx := llvm.NewContext() defer ctx.Dispose() diff --git a/compiler/cfuncs.go b/compiler/cfuncs.go index 20c19aca..eca0f5d0 100644 --- a/compiler/cfuncs.go +++ b/compiler/cfuncs.go @@ -63,7 +63,7 @@ const ( ) // GetFnType returns the LLVM FunctionType for a Pluto runtime helper -// name, like "printf", "free", or "range_i64_str". +// name, like "printf", "free", or "array_nd_str". func (c *Compiler) GetFnType(name string) llvm.Type { // Short helpers to reduce duplication charPtr := llvm.PointerType(c.Context.Int8Type(), 0) diff --git a/compiler/compiler.go b/compiler/compiler.go index e70690a4..bb41fb51 100644 --- a/compiler/compiler.go +++ b/compiler/compiler.go @@ -12,11 +12,12 @@ import ( ) type Symbol struct { - Val llvm.Value - Type Type - FuncArg bool // Symbol originates from function input/output argument context. - Borrowed bool // Value/storage is borrowed from another owner (scope cleanup must skip). - ReadOnly bool // Input parameter (cannot be written to). + Val llvm.Value + Type Type + FuncArg bool // Symbol originates from function input/output argument context. + Borrowed bool // Value/storage is borrowed from another owner (scope cleanup must skip). + ReadOnly bool // Input parameter (cannot be written to). + WriteFlag llvm.Value // Optional i1* set when this logical output is actually written. } // Borrowed-value ownership model: @@ -39,9 +40,10 @@ type Symbol struct { // Memory management: // // For function calls (x = f(y)): -// - Function produces a new value and writes it to output slot -// - This value is MOVED to the destination (ownership transferred) -// - Old value in destination is NOT freed (see freeExprOldValues) +// - Function writes into an independent, destination-seeded staging slot +// - The staged result is MOVED to the real destination after all RHS values +// have been evaluated +// - The real destination's old value is then freed by freeExprOldValues // - Temps passed as inputs are freed by caller after call returns // - Function cleanup skips borrowed params/slots (caller owns them) // @@ -74,6 +76,13 @@ type callArg struct { Name string Symbol *Symbol Lowered *Symbol + // AliasSelector is the one-based selector for the caller destination this + // argument aliases: 0 means none, N means output N-1. Direct scalar params + // transmit it as a hidden ABI argument; indirect params consume it + // caller-side to pass that output's staged pointer in place of the lowered + // argument. One-based keeps the zero value correct for arguments that + // alias nothing. + AliasSelector int } type callSignature struct { @@ -85,11 +94,10 @@ type callSignature struct { } type preparedCall struct { - Args []callArg - AliasIndices []int - Function llvm.Value - FuncType llvm.Type - RetStruct llvm.Type + Args []callArg + Function llvm.Value + FuncType llvm.Type + RetStruct llvm.Type } // BindingKey identifies a variable binding within a specific function variant. @@ -116,14 +124,9 @@ const ( symbolCode ) -func GetCopy(s *Symbol) (newSym *Symbol) { - newSym = &Symbol{} - newSym.Val = s.Val - newSym.Type = s.Type - newSym.FuncArg = s.FuncArg - newSym.Borrowed = s.Borrowed - newSym.ReadOnly = s.ReadOnly - return newSym +func GetCopy(s *Symbol) *Symbol { + newSym := *s + return &newSym } type Compiler struct { @@ -255,36 +258,25 @@ func (c *Compiler) resolvedDestTypes(dest []*ast.Identifier, outTypes []Type) [] if dest == nil || i >= len(dest) { continue } - resolved[i] = c.bindingSlotType(dest[i].Value, outType) + resolved[i] = c.destSlotType(dest[i].Value, outType) } return resolved } -func outputTypesDiffer(a, b []Type) bool { - if len(a) != len(b) { - return true - } - for i := range a { - if !TypeEqual(a[i], b[i]) { - return true +// destSlotType returns the authoritative element type for one destination slot. +// The solver owns the type of a source binding. Conditional lowering writes +// through synthetic condtmp_* identifiers, which have no solver entry, so fall +// back to the storage it already created for them: that pointer's element is +// the flavor chosen for the real destination. Staging allocates an independent +// slot, so taking the expression's own type here would let an empty range or a +// skipped write reset the destination instead of preserving it. +func (c *Compiler) destSlotType(name string, outType Type) Type { + if sym, exists := Get(c.Scopes, name); exists { + if ptrType, isPtr := sym.Type.(Ptr); isPtr { + outType = ptrType.Elem } } - return false -} - -func (c *Compiler) callNeedsTempOutputs(info *ExprInfo, dest []*ast.Identifier) bool { - if len(info.Ranges) != 0 || dest == nil { - return false - } - return outputTypesDiffer(info.OutTypes, c.resolvedDestTypes(dest, info.OutTypes)) -} - -func (c *Compiler) addCallTypeError(tok token.Token, msg string) bool { - c.Errors = append(c.Errors, &token.CompileError{ - Token: tok, - Msg: msg, - }) - return false + return c.bindingSlotType(name, outType) } // inferCallParamTypes selects the solver-cached call variant to use at the @@ -318,41 +310,46 @@ func (c *Compiler) resolveCallSignature(funcName string, ce *ast.CallExpression, }, true } -// buildCallParamAliasIndices records which direct scalar params alias caller -// destinations for range-bearing variants. The callee uses these indices to -// redirect a by-value param spill to the matching output slot so loop-carried -// accumulation keeps the same semantics as the legacy indirect ABI. -func (c *Compiler) buildCallParamAliasIndices(sig *callSignature, args []callArg, dest []*ast.Identifier) []int { - aliasIndices := make([]int, sig.ABI.NumAliasSlots()) - if dest == nil { - return aliasIndices +// setCallArgAliasSelectors records on each argument which caller destination it +// aliases for range-bearing variants. Direct scalar params encode the selected +// output through a hidden ABI index; indirect params receive that output's +// staged pointer directly. Arguments that alias nothing keep selector 0. +func (c *Compiler) setCallArgAliasSelectors(sig *callSignature, args []callArg, dest []*ast.Identifier) { + if !sig.ABI.HasRangeParams || dest == nil { + return } - for i, arg := range args { - aliasSlot := sig.ABI.Params[i].AliasSlot - if aliasSlot < 0 || arg.Name == "" { + for paramIndex, arg := range args { + if arg.Name == "" { continue } - for j := range sig.FnInfo.OutTypes { - if j >= len(dest) { + for outputIndex, output := range dest { + if outputIndex >= len(sig.ABI.Return.OutTypes) { break } - if dest[j].Value != arg.Name { + if output.Value != arg.Name { continue } - aliasIndices[aliasSlot] = j + 1 + // An indirect parameter and a same-named output can legitimately + // differ in ownership flavor, such as a StrH binding receiving a + // StrG output. Redirecting the input to that output's adapter would + // make a sibling output that reads the input see the adapter's + // value instead. Direct scalars cannot reach this: the solver + // rejects a name that would need two numeric types. + if !aliasableOutput(sig.ParamTypes[paramIndex], sig.ABI.Return.OutTypes[outputIndex]) { + continue + } + args[paramIndex].AliasSelector = outputIndex + 1 break } } - - return aliasIndices } // directReturnSeedForCall captures the caller's current destination value for a -// direct scalar return. Range-bearing variants thread this through a hidden ABI -// param so the callee can preserve empty-range and loop-carried accumulation -// semantics even though the LLVM return itself is by value. +// direct scalar return. The callee receives it through a hidden ABI parameter +// so a skipped output write preserves the destination even though the LLVM +// return itself is by value. func (c *Compiler) directReturnSeedForCall(outType Type, dest *ast.Identifier, output *Symbol) *Symbol { if output != nil { return c.derefIfPointer(output, "call_seed") @@ -366,6 +363,9 @@ func (c *Compiler) directReturnSeedForCall(outType Type, dest *ast.Identifier, o func (c *Compiler) selectAliasedParamPtr(name string, spill llvm.Value, aliasIndex llvm.Value, outputs []*Symbol) llvm.Value { slotPtr := spill for i, output := range outputs { + if output == nil { + continue + } match := c.builder.CreateICmp( llvm.IntEQ, aliasIndex, @@ -406,6 +406,12 @@ func (c *Compiler) directParamValue(name string, sym *Symbol, alias *paramAlias) value := sym.Val for i, outputName := range alias.OutputNames { + // Skip rather than filter: the selector names an output by position, so + // index i must keep meaning the i-th output for the remaining slots. + outputSym, ok := Get(c.Scopes, outputName) + if !ok || !aliasableOutput(sym.Type, outputSym.Type) { + continue + } match := c.builder.CreateICmp( llvm.IntEQ, alias.AliasIndex, @@ -485,9 +491,13 @@ func (c *Compiler) mapToLLVMType(t Type) llvm.Type { ) case ArrayRangeKind: arrRange := t.(ArrayRange) - arrayPtr := llvm.PointerType(c.Context.Int8Type(), 0) - rangeTy := c.mapToLLVMType(arrRange.Range) - return llvm.StructType([]llvm.Type{arrayPtr, rangeTy}, false) + return llvm.StructType( + []llvm.Type{ + c.mapToLLVMType(arrRange.Array), + c.mapToLLVMType(arrRange.Range), + }, + false, + ) case PtrKind: ptrType := t.(Ptr) elemLLVM := c.mapToLLVMType(ptrType.Elem) @@ -817,13 +827,6 @@ func (c *Compiler) makeZeroValue(symType Type) *Symbol { s.Val = c.createTableValue(c.ConstI64(0), columns, tableType) case RangeKind: s.Val = c.CreateRange(c.ConstI64(0), c.ConstI64(0), c.ConstI64(1), symType) - case ArrayRangeKind: - arrRangeType := symType.(ArrayRange) - // Create zero value for the array part - arraySym := c.makeZeroValue(arrRangeType.Array) - // Create zero value for the range part - rangeSym := c.makeZeroValue(arrRangeType.Range) - s.Val = c.CreateArrayRange(arraySym.Val, rangeSym.Val, arrRangeType) case StructKind: structType := symType.(Struct) fieldVals := make([]llvm.Value, len(structType.Fields)) @@ -949,6 +952,19 @@ func (c *Compiler) coerceSymbolForType(sym *Symbol, target Type, loadName string return derefed } +func (c *Compiler) markOutputSlotWritten(dst *Symbol, mergedFrom llvm.Value) { + if dst.WriteFlag.IsNil() { + return + } + // Moving a value produced by a stage that tracks the same logical output + // is a merge, not a new write. The stage already set the flag only on the + // runtime paths that yielded a value. + if !mergedFrom.IsNil() && mergedFrom == dst.WriteFlag { + return + } + c.builder.CreateStore(llvm.ConstInt(c.Context.Int1Type(), 1, false), dst.WriteFlag) +} + func (c *Compiler) storeSymbolToSlot(dst *Symbol, src *Symbol, target Type, loadName string) *Symbol { ptrType, ok := dst.Type.(Ptr) if !ok { @@ -977,6 +993,7 @@ func (c *Compiler) storeSymbolToSlot(dst *Symbol, src *Symbol, target Type, load coerced := c.coerceSymbolForType(source, target, "") c.createStore(coerced.Val, dst.Val, coerced.Type) + c.markOutputSlotWritten(dst, src.WriteFlag) return coerced } @@ -1028,9 +1045,10 @@ func (c *Compiler) freeValue(val llvm.Value, typ Type) { } } case ArrayRange: - // Release the backing array payload. Borrowed views are skipped by callers. - arrVal := c.builder.CreateExtractValue(val, 0, "arr_range_arr") - c.freeValue(arrVal, t.Array) + // Call-only ArrayRange descriptors own a temporary backing array when + // Borrowed is false. The range metadata itself has no heap storage. + arrayVal := c.builder.CreateExtractValue(val, 0, "array_range_arr") + c.freeValue(arrayVal, t.Array) } } @@ -1062,46 +1080,6 @@ func (c *Compiler) freeSymbolValue(sym *Symbol, loadName string) { c.freeValue(derefed.Val, derefed.Type) } -// shouldSkipOldValueFree returns true when an expression delegates destination -// old-value cleanup to inner assignment logic, avoiding caller-side double-free. -// -// Cases: -// -// - CallExpression writing directly through destination pointers: -// The caller passes output pointers to the callee. The callee then applies -// normal assignment cleanup when writing to those output params, so caller -// freeExprOldValues must skip. -// -// - InfixExpression/PrefixExpression/ArrayRangeExpression with pending ranges: -// Range-lowered paths free previous output values per iteration inside the -// loop body before storing the next value. -// -// All other expressions return false so freeExprOldValues handles cleanup with full -// assignment context (moved sources and borrowed/non-owning guards). -func (c *Compiler) shouldSkipOldValueFree(expr ast.Expression, dest []*ast.Identifier) bool { - if ce, isCall := expr.(*ast.CallExpression); isCall { - info := c.ExprCache[key(c.FuncNameMangled, ce)] - if _, ok := directScalarABIReturnType(info.OutTypes); ok { - return false - } - return !c.callNeedsTempOutputs(info, dest) - } - - switch e := expr.(type) { - case *ast.InfixExpression: - info := c.ExprCache[key(c.FuncNameMangled, e)] - return len(c.pendingLoopRanges(info.Ranges)) > 0 - case *ast.PrefixExpression: - info := c.ExprCache[key(c.FuncNameMangled, e)] - return len(c.pendingLoopRanges(info.Ranges)) > 0 - case *ast.ArrayRangeExpression: - info := c.ExprCache[key(c.FuncNameMangled, e)] - return len(c.pendingLoopRanges(info.Ranges)) > 0 - default: - return false - } -} - // slotAssign is one destination slot of an assignment: where the value is // written, which identifier owns move/copy decisions (the real destination, // even when writing through a temp), the compiled value and the RHS variable @@ -1186,6 +1164,7 @@ func (c *Compiler) newExprAssign(expr ast.Expression, bit llvm.Value, res []*Sym owner: owners[j], value: sym, rhsName: rhsName, + needsCopy: sym.Borrowed, oldValue: olds[j], destBacked: c.aliasesDestSlot(dests[j], sym), } @@ -1338,17 +1317,10 @@ func (c *Compiler) commitAssignments(assigns []exprAssign) { } // freeExprOldValues frees the destination values one expression's commit -// replaced, skipping fresh destinations, moved sources, borrowed storage, and -// expressions that manage destination cleanup themselves (calls writing -// through destination pointers; ranged lowerings free per iteration). +// replaced, skipping fresh destinations, moved sources, and borrowed storage. +// Function calls and ranged expressions return independent staged values, so +// their real destination cleanup also happens here. func (c *Compiler) freeExprOldValues(e exprAssign, movedSources map[string]struct{}) { - owners := make([]*ast.Identifier, len(e.slots)) - for j, slot := range e.slots { - owners[j] = slot.owner - } - if c.shouldSkipOldValueFree(e.expr, owners) { - return - } for _, slot := range e.slots { if slot.oldValue == nil || c.skipBorrowedOldValueFree(slot.oldValue) { continue @@ -1454,8 +1426,7 @@ func (c *Compiler) skipBorrowedOldValueFree(sym *Symbol) bool { return true } - // Borrowed array ranges are non-owning views into another array payload. - return sym.Type.Kind() == ArrayRangeKind + return false } // captureOldValues captures the current values of destination variables before RHS compilation. @@ -1513,12 +1484,12 @@ func (c *Compiler) compileExpression(expr ast.Expression, dest []*ast.Identifier s.Val = c.ConstF64(e.Value) res = []*Symbol{s} case *ast.StringLiteral: - res = []*Symbol{c.compileStringLiteral(e.Token)} + res = c.compileStringLiteralExpression(e, dest) case *ast.RangeLiteral: info := c.ExprCache[key(c.FuncNameMangled, e)] - // Root bare range literals can be scalarized by an outer ranged context. - // When all of this literal's ranges are already bound, compile the rewrite - // iterator identifier instead of materializing a Range aggregate again. + // A consuming expression can lower this literal inside the loop that + // already bound its iterator rewrite. Complete descriptor assignments + // clear the rewrite and materialize the Range aggregate below. if rewIdent, ok := info.Rewrite.(*ast.Identifier); ok && len(c.pendingLoopRanges(info.Ranges)) == 0 { return []*Symbol{c.compileIdentifier(rewIdent)} } @@ -1565,10 +1536,7 @@ func setInstAlignment(inst llvm.Value, t Type) { inst.SetAlignment(8) case Range: setInstAlignment(inst, typ.Iter) - case Array, Table: - inst.SetAlignment(8) - case ArrayRange: - // ArrayRange is a struct of { i8*, Range }, so align to the largest member, which is i8* + case Array, Table, ArrayRange: inst.SetAlignment(8) case Struct: // Struct alignment follows max-field ABI alignment; Phase 1 fields are scalar/ptr. @@ -1590,11 +1558,12 @@ func (c *Compiler) makePtr(name string, s *Symbol) (ptr *Symbol, alreadyPtr bool // Create the new symbol that represents the pointer to this memory. ptr = &Symbol{ - Val: alloca, - Type: Ptr{Elem: s.Type}, - FuncArg: s.FuncArg, - Borrowed: s.Borrowed, - ReadOnly: s.ReadOnly, + Val: alloca, + Type: Ptr{Elem: s.Type}, + FuncArg: s.FuncArg, + Borrowed: s.Borrowed, + ReadOnly: s.ReadOnly, + WriteFlag: s.WriteFlag, } return ptr, false @@ -1633,6 +1602,11 @@ func (c *Compiler) promoteAlias(name string, sym *Symbol, alias *paramAlias) *Sy outputPtrs := make([]*Symbol, len(alias.OutputNames)) for i, outputName := range alias.OutputNames { outputSym, _ := Get(c.Scopes, outputName) + // Left nil when the output cannot back this slot, so the selector + // keeps its positional meaning but never picks a mistyped pointer. + if !aliasableOutput(sym.Type, outputSym.Type) { + continue + } if outputSym.Type.Kind() != PtrKind { // Only params carry alias bindings, so promoting an output here // cannot recurse through another param-alias entry. @@ -1644,11 +1618,12 @@ func (c *Compiler) promoteAlias(name string, sym *Symbol, alias *paramAlias) *Sy } ptr := &Symbol{ - Val: slotPtr, - Type: Ptr{Elem: sym.Type}, - FuncArg: sym.FuncArg, - Borrowed: sym.Borrowed, - ReadOnly: sym.ReadOnly, + Val: slotPtr, + Type: Ptr{Elem: sym.Type}, + FuncArg: sym.FuncArg, + Borrowed: sym.Borrowed, + ReadOnly: sym.ReadOnly, + WriteFlag: sym.WriteFlag, } Put(c.Scopes, name, ptr) c.clearParamAlias(name) @@ -1696,6 +1671,8 @@ func (c *Compiler) derefIfPointer(s *Symbol, loadName string) *Symbol { newS := GetCopy(s) newS.Val = loadedVal newS.Type = ptrType.Elem + // A write flag belongs to the storage slot, not to an ordinary value read. + newS.WriteFlag = llvm.Value{} return newS } @@ -1751,6 +1728,28 @@ func (c *Compiler) compileIdentifier(ident *ast.Identifier) *Symbol { return c.derefIfPointer(s, ident.Value+"_load") } +// compileStringLiteralExpression lowers formatting markers that reference +// named Range drivers once per yield and retains the final formatted string. +func (c *Compiler) compileStringLiteralExpression(lit *ast.StringLiteral, dest []*ast.Identifier) []*Symbol { + info := c.ExprCache[key(c.FuncNameMangled, lit)] + if len(c.pendingLoopRanges(info.Ranges)) == 0 { + return []*Symbol{c.compileStringLiteral(lit.Token)} + } + + PushScope(&c.Scopes, BlockScope) + defer c.popScope() + + outputs := c.makeSeededTempOutputs(dest, info.OutTypes) + c.bindRangedTempOutputs(dest, outputs) + output := outputs[0] + c.withLoopNest(info.Ranges, func() { + value := c.compileStringLiteral(lit.Token) + c.storeRangedOutput(output, value.Val, value.Type) + }) + + return c.loadOutputValues(outputs, "format_range_final") +} + func (c *Compiler) compileDotExpression(expr *ast.DotExpression) []*Symbol { leftSym := c.compileExpression(expr.Left, nil)[0] leftSym = c.derefIfPointer(leftSym, "dot_left") @@ -2070,24 +2069,24 @@ func (c *Compiler) CreateRange(start, stop, step llvm.Value, typ Type) llvm.Valu return agg } -func (c *Compiler) CreateArrayRange(arrayVal llvm.Value, rangeVal llvm.Value, arrRange ArrayRange) llvm.Value { - llvmTy := c.mapToLLVMType(arrRange) - agg := llvm.Undef(llvmTy) +func (c *Compiler) CreateArrayRange(arrayVal, rangeVal llvm.Value, typ ArrayRange) llvm.Value { + llvmType := c.mapToLLVMType(typ) + agg := llvm.Undef(llvmType) agg = c.builder.CreateInsertValue(agg, arrayVal, 0, "array_range_arr") agg = c.builder.CreateInsertValue(agg, rangeVal, 1, "array_range_rng") return agg } -// Modified compileInfixRanges - cleaner with destinations +// compileInfixRanges evaluates an infix expression once per driver yield and +// retains one loop-carried result per destination. func (c *Compiler) compileInfixRanges(expr *ast.InfixExpression, info *ExprInfo, dest []*ast.Identifier) (res []*Symbol) { - // Infix expressions never accumulate (Accumulates is always false for infix) - // They loop and store the final value PushScope(&c.Scopes, BlockScope) defer c.popScope() // Setup outputs to store values across iterations. // Mark as borrowed so cleanupScope skips them - values are returned via out. - outputs := c.makeOutputs(dest, c.resolvedDestTypes(dest, info.OutTypes), true) + outputs := c.makeSeededTempOutputs(dest, info.OutTypes) + c.bindRangedTempOutputs(dest, outputs) rew := info.Rewrite.(*ast.InfixExpression) withCollectorPreparedLoopNest(c, rew, info.Ranges, nil, nil, func(prepared *ast.InfixExpression) { @@ -2121,17 +2120,7 @@ func (c *Compiler) compileInfixRanges(expr *ast.InfixExpression, info *ExprInfo, }) }) - // Load final values from outputs - out := make([]*Symbol, len(outputs)) - for i := range outputs { - elemType := outputs[i].Type.(Ptr).Elem - out[i] = &Symbol{ - Val: c.createLoad(outputs[i].Val, elemType, "final"), - Type: elemType, - } - } - - return out + return c.loadOutputValues(outputs, "final") } func (c *Compiler) compileRangeInfixSlot( @@ -2186,8 +2175,15 @@ func (c *Compiler) storeRangeCondScalar(op string, leftSym *Symbol, rightSym *Sy ifBlock, elseBlock, contBlock := c.createIfElseCont(cmpVal, "cond_store", "cond_drop_lhs", "cond_next") c.builder.SetInsertPointAtEnd(ifBlock) + toStore := lSym + if !leftTempsHandledInline { + // Identifier LHS values are borrowed from live bindings. Copy before + // releasing the loop-carried output, especially for self-reference + // where both values can currently point at the same payload. + toStore = c.deepCopyIfNeeded(lSym) + } c.freeSymbolValue(output, "old_output") - c.storeSymbolToSlot(output, lSym, output.Type.(Ptr).Elem, "range_cond_store") + c.storeSymbolToSlot(output, toStore, output.Type.(Ptr).Elem, "range_cond_store") c.builder.CreateBr(contBlock) c.builder.SetInsertPointAtEnd(elseBlock) @@ -2214,74 +2210,6 @@ func (c *Compiler) cleanupRangeInfixTemps( c.freeTemporary(rightExpr, right) } -func (c *Compiler) updateUnresolvedType(name string, sym *Symbol, resolved Type) { - switch t := sym.Type.(type) { - case Array: - if !hasConcreteArrayElemType(t.ElemType) { - sym.Type = resolved - Put(c.Scopes, name, sym) - } - case Table: - if !IsFullyResolvedType(t) { - sym.Type = resolved - Put(c.Scopes, name, sym) - } - case ArrayRange: - if t.Array.ElemType.Kind() == UnresolvedKind { - sym.Type = resolved - Put(c.Scopes, name, sym) - } - case Ptr: - if t.Elem.Kind() == UnresolvedKind { - sym.Type = Ptr{Elem: resolved} - Put(c.Scopes, name, sym) - } - default: - // No action needed for other types - } -} - -func (c *Compiler) makeOutputs(dest []*ast.Identifier, outTypes []Type, borrowed bool) []*Symbol { - outputs := make([]*Symbol, len(outTypes)) - - for i, outType := range outTypes { - // Determine the name for the alloca - var name string - if i < len(dest) { - name = dest[i].Value - } else { - name = fmt.Sprintf("tmp_out_%d", c.tmpCounter) - c.tmpCounter++ - } - - sym, exists := Get(c.Scopes, name) - if exists { - // Existing variable - update type if needed and promote to memory - c.updateUnresolvedType(name, sym, outType) - if sym.Type.Kind() == PtrKind { - // Shadow existing pointer symbols in the current scope so temporary - // ownership flags (e.g. Borrowed during range lowering) do not - // mutate outer-scope symbols. - sym = GetCopy(sym) - Put(c.Scopes, name, sym) - } else { - sym = c.promoteToMemory(name) - } - // Preserve existing borrowed ownership and only add temporary borrowed semantics. - // Example: function output params are already Borrowed=true (caller-owned slots). - // A call path uses borrowed=false, and must not clear that existing ownership. - sym.Borrowed = sym.Borrowed || borrowed - outputs[i] = sym - continue - } - - // New variable or intermediate value - create temp alloca without adding to scope. - // The permanent variable will be created by writeTo in FuncScope. - outputs[i] = c.makeTempOutput(name, outType, borrowed, nil) - } - return outputs -} - func (c *Compiler) makeTempOutput(name string, outType Type, borrowed bool, seed *Symbol) *Symbol { ptr := c.createEntryBlockAlloca(c.mapToLLVMType(outType), name) ptrElem := outType @@ -2301,21 +2229,153 @@ func (c *Compiler) makeTempOutput(name string, outType Type, borrowed bool, seed return output } -func (c *Compiler) makeTempOutputs(outTypes []Type, borrowed bool, seedFor func(int, Type) *Symbol) []*Symbol { - outputs := make([]*Symbol, len(outTypes)) - for i, outType := range outTypes { +// makeSeededTempOutputs creates independent result slots. Existing destination +// values seed output-parameter and empty-range behavior, but heap-backed seeds +// are copied so evaluating one RHS cannot mutate or free a destination before +// sibling RHS expressions have read the statement's original values. +func (c *Compiler) makeSeededTempOutputs(dest []*ast.Identifier, outTypes []Type) []*Symbol { + resolved := c.resolvedDestTypes(dest, outTypes) + outputs := make([]*Symbol, len(resolved)) + for i, outType := range resolved { name := fmt.Sprintf("calltmp_%d", c.tmpCounter) c.tmpCounter++ + var existing *Symbol + var exists bool + if dest != nil && i < len(dest) { + existing, exists = Get(c.Scopes, dest[i].Value) + } + var seed *Symbol - if seedFor != nil { - seed = seedFor(i, outType) + if exists { + seed = c.deepCopyIfNeeded(c.resolveDestSeed(dest[i], outType)) + } else { + seed = c.makeZeroValue(outType) + } + outputs[i] = c.makeTempOutput(name, outType, true, seed) + if exists { + outputs[i].WriteFlag = existing.WriteFlag + } + } + return outputs +} + +type callOutputAdapter struct { + abiOutput *Symbol + bridged bool +} + +// makeCallOutputAdapters adapts destination-typed result slots to an indirect +// callee's declared output types. Matching slots pass through directly. A +// mismatched slot gets an ABI-typed zero seed; the callee's explicit per-output +// write flag decides whether that value commits into the destination-typed +// stage. This prevents one static ownership/shape flavor from masquerading as +// another inside the callee. +func (c *Compiler) makeCallOutputAdapters(running []*Symbol, outTypes []Type) []callOutputAdapter { + adapters := make([]callOutputAdapter, len(running)) + for i, output := range running { + targetType := output.Type.(Ptr).Elem + if TypeEqual(targetType, outTypes[i]) { + adapters[i].abiOutput = output + continue } - outputs[i] = c.makeTempOutput(name, outType, borrowed, seed) + + name := fmt.Sprintf("calladapter_%d", c.tmpCounter) + c.tmpCounter++ + adapters[i].abiOutput = c.makeTempOutput(name, outTypes[i], true, nil) + adapters[i].bridged = true + } + return adapters +} + +func callAdapterOutputs(adapters []callOutputAdapter) []*Symbol { + outputs := make([]*Symbol, len(adapters)) + for i := range adapters { + outputs[i] = adapters[i].abiOutput } return outputs } +func (c *Compiler) propagateOutputWriteFlag(output *Symbol, didWrite llvm.Value, index int) { + if output.WriteFlag.IsNil() { + return + } + previous := c.builder.CreateLoad(c.Context.Int1Type(), output.WriteFlag, fmt.Sprintf("output_written_%d", index)) + merged := c.builder.CreateOr(previous, didWrite, fmt.Sprintf("output_written_merge_%d", index)) + c.builder.CreateStore(merged, output.WriteFlag) +} + +// commitCallOutputAdapters commits ABI-flavor temporaries into the independent +// result slots after (and only after) a call executes. Store/coercion happens +// before old-value cleanup so shape resets and StrG -> StrH copies can inspect +// the previous destination safely. +func (c *Compiler) commitCallOutputAdapters(running []*Symbol, adapters []callOutputAdapter, writeFlags []llvm.Value) { + for i, adapter := range adapters { + didWrite := c.builder.CreateLoad(c.Context.Int1Type(), writeFlags[i], fmt.Sprintf("call_output_written_%d", i)) + if !adapter.bridged { + c.propagateOutputWriteFlag(running[i], didWrite, i) + continue + } + + commit := func() { + targetType := running[i].Type.(Ptr).Elem + oldValue := c.derefIfPointer(running[i], fmt.Sprintf("calladapter_old_%d", i)) + source := c.derefIfPointer(adapter.abiOutput, fmt.Sprintf("calladapter_result_%d", i)) + stored := c.storeSymbolToSlot(running[i], source, targetType, fmt.Sprintf("calladapter_store_%d", i)) + c.freeSymbolValue(oldValue, "") + if stored.Val != source.Val { + c.freeSymbolValue(source, "") + } + } + + c.withCondBranch(didWrite, "calladapter", commit, func() { + c.freeSymbolValue(adapter.abiOutput, fmt.Sprintf("calladapter_unwritten_%d", i)) + }) + } +} + +func (c *Compiler) cleanupSkippedCallOutputAdapters(adapters []callOutputAdapter) { + for i, adapter := range adapters { + if adapter.bridged { + c.freeSymbolValue(adapter.abiOutput, fmt.Sprintf("calladapter_skip_%d", i)) + } + } +} + +// bindRangedTempOutputs makes each destination name resolve to its staged slot +// while that one ranged expression is compiled. Conditional lowering can make +// the real destination and a condtmp_* write name alias the same slot, so bind +// every visible name for that slot as well. This preserves loop-carried +// self-reference (res = res + i) without exposing the staged value to sibling +// right-hand sides in a simultaneous assignment; the caller's BlockScope is +// popped before the next expression is compiled. +func (c *Compiler) bindRangedTempOutputs(dest []*ast.Identifier, outputs []*Symbol) { + for i := 0; i < len(dest) && i < len(outputs); i++ { + names := []string{dest[i].Value} + if current, ok := Get(c.Scopes, dest[i].Value); ok && current.Type.Kind() == PtrKind { + seen := make(map[string]struct{}) + for scopeIdx := len(c.Scopes) - 1; scopeIdx >= 0; scopeIdx-- { + scope := c.Scopes[scopeIdx] + for name, sym := range scope.Elems { + if _, visited := seen[name]; visited { + continue + } + seen[name] = struct{}{} + if sym.Type.Kind() == PtrKind && sym.Val == current.Val { + names = append(names, name) + } + } + if scope.ScopeKind == FuncScope { + break + } + } + } + for _, name := range names { + Put(c.Scopes, name, outputs[i]) + } + } +} + // Destination-aware prefix compilation, // mirroring compileInfixExpression/compileInfixRanges. @@ -2367,7 +2427,8 @@ func (c *Compiler) compilePrefixRanges(expr *ast.PrefixExpression, info *ExprInf // Allocate/seed per-destination temps (seed from existing value or zero). // Mark as borrowed so cleanupScope skips them - the values are returned via out. - outputs := c.makeOutputs(dest, c.resolvedDestTypes(dest, info.OutTypes), true) + outputs := c.makeSeededTempOutputs(dest, info.OutTypes) + c.bindRangedTempOutputs(dest, outputs) withCollectorPreparedLoopNest(c, info.Rewrite.(*ast.PrefixExpression), info.Ranges, nil, nil, func(prepared *ast.PrefixExpression) { rightRew := prepared.Right @@ -2387,17 +2448,7 @@ func (c *Compiler) compilePrefixRanges(expr *ast.PrefixExpression, info *ExprInf }) }) - // Materialize final values - out := make([]*Symbol, len(outputs)) - for i := range outputs { - elemType := outputs[i].Type.(Ptr).Elem - out[i] = &Symbol{ - Val: c.createLoad(outputs[i].Val, elemType, "final"), - Type: elemType, - } - } - - return out + return c.loadOutputValues(outputs, "final") } func (c *Compiler) compileRangePrefixSlot(op string, operand *Symbol, expected Type, output *Symbol) { @@ -2430,9 +2481,10 @@ func (c *Compiler) getReturnStruct(mangled string, outputTypes []Type) llvm.Type } // Otherwise, define it exactly once: st := c.Context.StructCreateNamed(retName) - fields := make([]llvm.Type, len(outputTypes)) + fields := make([]llvm.Type, len(outputTypes)*2) for i, t := range outputTypes { fields[i] = llvm.PointerType(c.mapToLLVMType(t), 0) + fields[len(outputTypes)+i] = llvm.PointerType(c.Context.Int1Type(), 0) } st.StructSetBody(fields, false) return st @@ -2456,7 +2508,7 @@ func (c *Compiler) getFuncType(mangled string, abi FuncABI) (llvm.Type, llvm.Typ for i := 0; i < abi.NumAliasSlots(); i++ { llvmParams = append(llvmParams, c.Context.Int32Type()) } - if abi.Return.HasSeedParam { + if abi.Return.Mode == ABIReturnDirect { llvmParams = append(llvmParams, c.mapToLLVMType(abi.Return.DirectType)) } @@ -2555,6 +2607,9 @@ func (c *Compiler) processIndirectOutputs(fn *ast.FuncStatement, retStruct llvm. Borrowed: true, ReadOnly: false, } + flagField := c.builder.CreateStructGEP(retStruct, sretPtr, len(fn.Outputs)+i, outIdent.Value+"_written_field") + flagPtrType := llvm.PointerType(c.Context.Int1Type(), 0) + retPtrs[i].WriteFlag = c.builder.CreateLoad(flagPtrType, flagField, outIdent.Value+"_written") } return retPtrs } @@ -2621,8 +2676,7 @@ func (c *Compiler) processParams(template *ast.FuncStatement, sig *callSignature } } - kind := elemType.Kind() - if kind == RangeKind || kind == ArrayRangeKind { + if elemType.Kind() == RangeKind || elemType.Kind() == ArrayRangeKind { iterIndices = append(iterIndices, i) continue } @@ -2683,13 +2737,6 @@ func (c *Compiler) iterOverRange(rangeType Range, rangeVal llvm.Value, body func }) } -func (c *Compiler) iterOverArrayRange(arrRangeSym *Symbol, body func(llvm.Value, Type)) { - c.iterOverArrayRangeState(arrRangeSym, nil, func(iter llvm.Value, iterType Type, _ *Symbol) *Symbol { - body(iter, iterType) - return nil - }) -} - func (c *Compiler) iterOverRangeState(rangeType Range, rangeVal llvm.Value, currentOutput *Symbol, body func(llvm.Value, Type, *Symbol) *Symbol) *Symbol { iterType := rangeType.Iter hasState := currentOutput != nil @@ -2723,10 +2770,16 @@ func (c *Compiler) iterOverRangeState(rangeType Range, rangeVal llvm.Value, curr func (c *Compiler) iterOverArrayRangeState(arrRangeSym *Symbol, currentOutput *Symbol, body func(llvm.Value, Type, *Symbol) *Symbol) *Symbol { arrRangeType := arrRangeSym.Type.(ArrayRange) - arrPtr := c.builder.CreateExtractValue(arrRangeSym.Val, 0, "array_range_ptr") + arrayVal := c.builder.CreateExtractValue(arrRangeSym.Val, 0, "array_range_arr") rangeVal := c.builder.CreateExtractValue(arrRangeSym.Val, 1, "array_range_bounds") - arraySym := &Symbol{Val: arrPtr, Type: arrRangeType.Array} - elemType := arrRangeType.Array.ElemType + arraySym := &Symbol{ + Val: arrayVal, + Type: arrRangeType.Array, + Borrowed: true, + ReadOnly: true, + } + resultType := arrayIndexResultType(arrRangeType.Array) + hasState := currentOutput != nil stateType := llvm.Type{} seed := llvm.Value{} @@ -2736,28 +2789,41 @@ func (c *Compiler) iterOverArrayRangeState(arrRangeSym *Symbol, currentOutput *S } finalVal := c.createLoopCore(rangeVal, seed, stateType, hasState, func(iter llvm.Value, current llvm.Value) llvm.Value { - inBounds := c.arrayIndexInBounds(arraySym, elemType, iter) + inBounds := c.arrayIndexInBounds(arraySym, arrRangeType.Array.ElemType, iter) preCheck := c.builder.GetInsertBlock() iterBlock, contBlock := c.createIfCont(inBounds, "arr_iter_in_bounds", "arr_iter_cont") c.builder.SetInsertPointAtEnd(iterBlock) - elemVal := c.ArrayGetBorrowed(arraySym, elemType, iter) + var yielded *Symbol + if arrRangeType.Array.Rank == 1 { + yielded = &Symbol{ + Val: c.ArrayGetBorrowed(arraySym, arrRangeType.Array.ElemType, iter), + Type: resultType, + Borrowed: true, + ReadOnly: true, + } + } else { + yielded = c.compileArraySubarray(arraySym, iter) + } + var state *Symbol if hasState { state = GetCopy(currentOutput) state.Val = current } - next := body(elemVal, elemType, state) + next := body(yielded.Val, yielded.Type, state) + if arrRangeType.Array.Rank > 1 { + c.freeSymbolValue(yielded, "array_range_subarray") + } c.builder.CreateBr(contBlock) - iterEnd := c.builder.GetInsertBlock() + c.builder.SetInsertPointAtEnd(contBlock) if !hasState { return llvm.Value{} } - // Array-range iteration can skip the body when the element is out of - // bounds, so merge the unchanged incoming state with the body-updated - // state before handing it back to createLoopCore. + // Out-of-bounds indices skip the function body, so preserve the + // incoming direct-return state on that edge. merged := c.builder.CreatePHI(stateType, "arr_iter_state") merged.AddIncoming([]llvm.Value{current}, []llvm.BasicBlock{preCheck}) merged.AddIncoming([]llvm.Value{next.Val}, []llvm.BasicBlock{iterEnd}) @@ -2812,7 +2878,7 @@ func (c *Compiler) funcLoopNest(fn *ast.FuncStatement, fa *FuncArgs, level int, rangeVal := c.createLoad(paramPtr, elemType, name+"_range") result = c.iterOverRangeState(rangeType, rangeVal, currentOutput, next) case ArrayRangeKind: - arrRangeVal := c.createLoad(paramPtr, elemType, name+"_arrrange") + arrRangeVal := c.createLoad(paramPtr, elemType, name+"_array_range") arrRangeSym := &Symbol{ Val: arrRangeVal, Type: elemType, @@ -2884,9 +2950,19 @@ func (c *Compiler) createIfCont(cond llvm.Value, ifName, contName string) (llvm. return ifBlock, contBlock } -func (c *Compiler) compileCallArgs(ce *ast.CallExpression) []callArg { - args := []callArg{} +func (c *Compiler) compileCallArgs(sig *callSignature, ce *ast.CallExpression) []callArg { + args := make([]callArg, 0, len(sig.ParamTypes)) for _, callArgExpr := range ce.Arguments { + if arrayRangeExpr, ok := callArgExpr.(*ast.ArrayRangeExpression); ok { + if arrayRangeType, ok := sig.ParamTypes[len(args)].(ArrayRange); ok { + args = append(args, callArg{ + Expr: callArgExpr, + Symbol: c.compileArrayRangeCallArg(arrayRangeExpr, arrayRangeType), + }) + continue + } + } + if ident, ok := callArgExpr.(*ast.Identifier); ok { args = append(args, callArg{ Expr: callArgExpr, @@ -2906,8 +2982,7 @@ func (c *Compiler) compileCallArgs(ce *ast.CallExpression) []callArg { return args } -func (c *Compiler) lowerCallArgs(funcName string, args []callArg, sig *callSignature, dest []*ast.Identifier) []int { - aliasIndices := c.buildCallParamAliasIndices(sig, args, dest) +func (c *Compiler) lowerCallArgs(funcName string, args []callArg, sig *callSignature) { for i, arg := range args { sym := arg.Symbol if sig.ABI.Params[i].Mode != ABIParamIndirect { @@ -2932,7 +3007,6 @@ func (c *Compiler) lowerCallArgs(funcName string, args []callArg, sig *callSigna } args[i].Lowered = sym } - return aliasIndices } func (c *Compiler) freeCallArgTemps(callArgs []callArg) { @@ -2957,15 +3031,15 @@ func (c *Compiler) freeCallArgTemps(callArgs []callArg) { } func (c *Compiler) prepareCall(sig *callSignature, ce *ast.CallExpression, dest []*ast.Identifier) preparedCall { - callArgs := c.compileCallArgs(ce) - aliasIndices := c.lowerCallArgs(sig.FuncName, callArgs, sig, dest) + callArgs := c.compileCallArgs(sig, ce) + c.setCallArgAliasSelectors(sig, callArgs, dest) + c.lowerCallArgs(sig.FuncName, callArgs, sig) fn, funcType, retStruct := c.getOrCompileCallFunction(sig) return preparedCall{ - Args: callArgs, - AliasIndices: aliasIndices, - Function: fn, - FuncType: funcType, - RetStruct: retStruct, + Args: callArgs, + Function: fn, + FuncType: funcType, + RetStruct: retStruct, } } @@ -2976,41 +3050,45 @@ func (c *Compiler) withPreparedCall(sig *callSignature, ce *ast.CallExpression, } func (c *Compiler) runCallWithBounds(run func()) { + c.runCallWithBoundsElse(run, nil) +} + +func (c *Compiler) runCallWithBoundsElse(run func(), onSkip func()) { if !c.withStmtBoundsGuard( "call_bounds_ok", "call_bounds_run", "call_bounds_skip", "call_bounds_cont", run, - nil, + onSkip, ) { run() } } -func (c *Compiler) loadOutputValues(outputs []*Symbol, outTypes []Type, name string) []*Symbol { +func (c *Compiler) loadOutputValues(outputs []*Symbol, name string) []*Symbol { out := make([]*Symbol, len(outputs)) for i := range outputs { - elemType := outTypes[i] + elemType := outputs[i].Type.(Ptr).Elem loadName := name if len(outputs) > 1 { loadName = fmt.Sprintf("%s_%d", name, i) } out[i] = &Symbol{ - Val: c.createLoad(outputs[i].Val, elemType, loadName), - Type: elemType, + Val: c.createLoad(outputs[i].Val, elemType, loadName), + Type: elemType, + WriteFlag: outputs[i].WriteFlag, } } return out } func (c *Compiler) compileDirectCallWithRanges(sig *callSignature, info *ExprInfo, dest []*ast.Identifier) []*Symbol { - outputs := c.makeTempOutputs(info.OutTypes, true, func(i int, outType Type) *Symbol { - if dest != nil && i < len(dest) { - return c.resolveDestSeed(dest[i], outType) - } - return c.makeZeroValue(outType) - }) + PushScope(&c.Scopes, BlockScope) + defer c.popScope() + + outputs := c.makeSeededTempOutputs(dest, info.OutTypes) + c.bindRangedTempOutputs(dest, outputs) withCollectorPreparedLoopNest(c, info.Rewrite.(*ast.CallExpression), info.Ranges, nil, nil, func(rewCall *ast.CallExpression) { c.pushBoundsGuard("call_iter_bounds_guard") c.compileCondExprValue(rewCall, llvm.Value{}, func() { @@ -3019,10 +3097,16 @@ func (c *Compiler) compileDirectCallWithRanges(sig *callSignature, info *ExprInf c.popBoundsGuard() }) - return c.loadOutputValues(outputs, info.OutTypes, "final") + return c.loadOutputValues(outputs, "final") } -func (c *Compiler) compileIndirectCallWithRanges(sig *callSignature, info *ExprInfo, dest []*ast.Identifier, outputs []*Symbol) []*Symbol { +func (c *Compiler) compileIndirectCallWithRanges(sig *callSignature, info *ExprInfo, dest []*ast.Identifier) []*Symbol { + PushScope(&c.Scopes, BlockScope) + defer c.popScope() + + outputs := c.makeSeededTempOutputs(dest, info.OutTypes) + c.bindRangedTempOutputs(dest, outputs) + withCollectorPreparedLoopNest(c, info.Rewrite.(*ast.CallExpression), info.Ranges, nil, nil, func(rewCall *ast.CallExpression) { // Scope bounds checks to this loop iteration: arguments can contain // multiple array reads, and the call should execute only when all are @@ -3031,15 +3115,15 @@ func (c *Compiler) compileIndirectCallWithRanges(sig *callSignature, info *ExprI // Inside loop, ranges are shadowed as scalars. If call arguments contain // conditional expressions, execute the call only when they hold. c.compileCondExprValue(rewCall, llvm.Value{}, func() { - c.compileCallInner(sig, rewCall, dest, outputs) + c.compileIndirectCallIntoStagedOutputs(sig, rewCall, dest, outputs) }) c.popBoundsGuard() }) // Loop path materializes final values from output slots after iteration. - // Slots are seeded by makeOutputs (existing value or zero for new vars), so + // Slots are seeded by makeSeededTempOutputs (existing value or zero for new vars), so // empty ranges naturally preserve no-op semantics for existing destinations. - return c.loadOutputValues(outputs, info.OutTypes, "final") + return c.loadOutputValues(outputs, "final") } func (c *Compiler) compileCallExpression(ce *ast.CallExpression, dest []*ast.Identifier) (res []*Symbol) { @@ -3054,6 +3138,11 @@ func (c *Compiler) compileCallExpression(ce *ast.CallExpression, dest []*ast.Ide if !ok { return nil } + // Mutual-recursion convergence can leave this call site's cache partially + // unresolved even though the selected function variant is now concrete. + // Keep every lowering path aligned with the authoritative ABI types. + info.OutTypes = append([]Type(nil), sig.ABI.Return.OutTypes...) + info.ExprLen = len(info.OutTypes) if sig.ABI.Return.Mode == ABIReturnDirect { if !info.LoopInside && len(info.Ranges) > 0 { @@ -3062,47 +3151,22 @@ func (c *Compiler) compileCallExpression(ce *ast.CallExpression, dest []*ast.Ide return c.compileDirectCallWithRanges(sig, info, dest) } - return c.compileCallInner(sig, ce, dest, nil) + return c.compileCallInner(sig, ce, dest) } - // Direct calls can materialize into temporary outputs first when the - // callee's return flavor differs from the destination slot flavor. Those - // temporaries are returned as normal RHS values so outer assignment/guard - // logic owns the eventual store, restore, and old-value cleanup. - if c.callNeedsTempOutputs(info, dest) { - tempOutputs := c.makeOutputs(nil, info.OutTypes, false) - c.compileCallInner(sig, ce, dest, tempOutputs) - return c.loadOutputValues(tempOutputs, info.OutTypes, "call_tmp") + if len(info.Ranges) > 0 && !info.LoopInside { + return c.compileIndirectCallWithRanges(sig, info, dest) } - outputs := c.makeOutputs(dest, info.OutTypes, false) - - if !info.LoopInside && len(info.Ranges) > 0 { - return c.compileIndirectCallWithRanges(sig, info, dest, outputs) - } - - // LoopInside=true or no ranges: direct call - c.compileCallInner(sig, ce, dest, outputs) - - // Update output types (e.g., Static flag for strings) when there are no ranges. - // Old value freeing is handled by writeTo using captured old values. - if !info.HasRanges { - c.updateOutputTypes(outputs, info.OutTypes, dest) - } - - return outputs -} - -// updateOutputTypes updates the destination symbols in scope to reference the output values. -// With StrG/StrH types, the type is determined at type-solving time and doesn't change. -func (c *Compiler) updateOutputTypes(outputs []*Symbol, outTypes []Type, dest []*ast.Identifier) { - for i, out := range outputs { - if i >= len(outTypes) || dest == nil || i >= len(dest) { - continue - } - // Update the symbol in scope to reference this output - Put(c.Scopes, dest[i].Value, out) - } + // Indirect-return callees write through their output pointers. Always point + // them at independent, destination-seeded slots so a call in one RHS cannot + // mutate a real destination before sibling RHS expressions have read the + // statement-start values. The outer assignment owns the eventual commit and + // cleanup. ABI-flavor adapters handle established slots such as StrH when a + // callee declares StrG. + outputs := c.makeSeededTempOutputs(dest, info.OutTypes) + c.compileIndirectCallIntoStagedOutputs(sig, ce, dest, outputs) + return c.loadOutputValues(outputs, "call_final") } func (c *Compiler) getOrCompileCallFunction(sig *callSignature) (llvm.Value, llvm.Type, llvm.Type) { @@ -3137,39 +3201,92 @@ func (c *Compiler) compileDirectCallIntoOutput(sig *callSignature, ce *ast.CallE }) } -// compileCallInner compiles the actual function call -func (c *Compiler) compileCallInner(sig *callSignature, ce *ast.CallExpression, dest []*ast.Identifier, outputs []*Symbol) []*Symbol { - var results []*Symbol +func (c *Compiler) compileIndirectCallIntoOutputs( + sig *callSignature, + ce *ast.CallExpression, + dest []*ast.Identifier, + outputs []*Symbol, + afterCall func([]llvm.Value), + onSkip func(), +) { c.withPreparedCall(sig, ce, dest, func(call preparedCall) { - if sig.ABI.Return.Mode == ABIReturnDirect { - seed := c.makeZeroValue(sig.ABI.Return.DirectType) - if len(dest) > 0 { - seed = c.directReturnSeedForCall(sig.ABI.Return.DirectType, dest[0], nil) + c.runCallWithBoundsElse(func() { + writeFlags := c.makeCallOutputWriteFlags(len(outputs)) + c.builder.CreateCall( + call.FuncType, + call.Function, + c.callArgs(sig, call, call.RetStruct, outputs, writeFlags, nil), + "", + ) + if afterCall != nil { + afterCall(writeFlags) } - resultPtr := c.createEntryBlockAlloca(c.mapToLLVMType(sig.ABI.Return.DirectType), sig.FuncName+"_call_tmp") - c.createStore(seed.Val, resultPtr, seed.Type) + }, onSkip) + }) +} - c.runCallWithBounds(func() { - callResult := c.callDirect(call.Function, call.FuncType, sig, call, seed) - c.createStore(callResult.Val, resultPtr, callResult.Type) - }) +func (c *Compiler) compileIndirectCallIntoStagedOutputs( + sig *callSignature, + ce *ast.CallExpression, + dest []*ast.Identifier, + staged []*Symbol, +) { + adapters := c.makeCallOutputAdapters(staged, sig.ABI.Return.OutTypes) + callOutputs := callAdapterOutputs(adapters) + c.compileIndirectCallIntoOutputs( + sig, + ce, + dest, + callOutputs, + func(writeFlags []llvm.Value) { c.commitCallOutputAdapters(staged, adapters, writeFlags) }, + func() { c.cleanupSkippedCallOutputAdapters(adapters) }, + ) +} - results = []*Symbol{{ - Val: c.createLoad(resultPtr, sig.ABI.Return.DirectType, sig.FuncName+"_call_ret"), - Type: sig.ABI.Return.DirectType, - }} - return +func (c *Compiler) makeCallOutputWriteFlags(count int) []llvm.Value { + flags := make([]llvm.Value, count) + for i := range flags { + name := fmt.Sprintf("call_output_written_%d", c.tmpCounter) + c.tmpCounter++ + flags[i] = c.createEntryBlockAlloca(c.Context.Int1Type(), name) + c.builder.CreateStore(llvm.ConstInt(c.Context.Int1Type(), 0, false), flags[i]) + } + return flags +} + +// compileCallInner compiles the actual function call +func (c *Compiler) compileCallInner(sig *callSignature, ce *ast.CallExpression, dest []*ast.Identifier) []*Symbol { + var results []*Symbol + c.withPreparedCall(sig, ce, dest, func(call preparedCall) { + seed := c.makeZeroValue(sig.ABI.Return.DirectType) + if len(dest) > 0 { + seed = c.directReturnSeedForCall(sig.ABI.Return.DirectType, dest[0], nil) } + resultPtr := c.createEntryBlockAlloca(c.mapToLLVMType(sig.ABI.Return.DirectType), sig.FuncName+"_call_tmp") + c.createStore(seed.Val, resultPtr, seed.Type) c.runCallWithBounds(func() { - results = c.callFunction(call.Function, call.FuncType, sig, call, call.RetStruct, outputs) + callResult := c.callDirect(call.Function, call.FuncType, sig, call, seed) + c.createStore(callResult.Val, resultPtr, callResult.Type) }) + + results = []*Symbol{{ + Val: c.createLoad(resultPtr, sig.ABI.Return.DirectType, sig.FuncName+"_call_ret"), + Type: sig.ABI.Return.DirectType, + }} }) return results } -func (c *Compiler) callArgs(sig *callSignature, call preparedCall, retStruct llvm.Type, outputs []*Symbol, directSeed *Symbol) []llvm.Value { +func (c *Compiler) callArgs( + sig *callSignature, + call preparedCall, + retStruct llvm.Type, + outputs []*Symbol, + writeFlags []llvm.Value, + directSeed *Symbol, +) []llvm.Value { llvmArgs := []llvm.Value{} if sig.ABI.UsesIndirectReturn() { sretPtr := c.createEntryBlockAlloca(retStruct, "sret_tmp") @@ -3177,15 +3294,31 @@ func (c *Compiler) callArgs(sig *callSignature, call preparedCall, retStruct llv fieldPtr := c.builder.CreateStructGEP(retStruct, sretPtr, i, fmt.Sprintf("sret_field_%d", i)) c.builder.CreateStore(out.Val, fieldPtr) } + for i, flag := range writeFlags { + fieldPtr := c.builder.CreateStructGEP(retStruct, sretPtr, len(outputs)+i, fmt.Sprintf("sret_written_field_%d", i)) + c.builder.CreateStore(flag, fieldPtr) + } llvmArgs = append(llvmArgs, sretPtr) } - for _, arg := range call.Args { - llvmArgs = append(llvmArgs, arg.Lowered.Val) + for i, arg := range call.Args { + argVal := arg.Lowered.Val + if sig.ABI.Params[i].Mode == ABIParamIndirect && arg.AliasSelector > 0 && arg.AliasSelector <= len(outputs) { + argVal = outputs[arg.AliasSelector-1].Val + } + llvmArgs = append(llvmArgs, argVal) + } + aliasIndices := make([]int, sig.ABI.NumAliasSlots()) + for i, arg := range call.Args { + slot := sig.ABI.Params[i].AliasSlot + if slot < 0 { + continue + } + aliasIndices[slot] = arg.AliasSelector } - for _, aliasIndex := range call.AliasIndices { + for _, aliasIndex := range aliasIndices { llvmArgs = append(llvmArgs, llvm.ConstInt(c.Context.Int32Type(), uint64(aliasIndex), false)) } - if sig.ABI.Return.HasSeedParam { + if sig.ABI.Return.Mode == ABIReturnDirect { seed := c.coerceSymbolForType(directSeed, sig.ABI.Return.DirectType, sig.FuncName+"_seed") llvmArgs = append(llvmArgs, seed.Val) } @@ -3193,18 +3326,13 @@ func (c *Compiler) callArgs(sig *callSignature, call preparedCall, retStruct llv } func (c *Compiler) callDirect(fn llvm.Value, funcType llvm.Type, sig *callSignature, call preparedCall, directSeed *Symbol) *Symbol { - callVal := c.builder.CreateCall(funcType, fn, c.callArgs(sig, call, llvm.Type{}, nil, directSeed), sig.FuncName+"_ret") + callVal := c.builder.CreateCall(funcType, fn, c.callArgs(sig, call, llvm.Type{}, nil, nil, directSeed), sig.FuncName+"_ret") return &Symbol{ Val: callVal, Type: sig.ABI.Return.DirectType, } } -func (c *Compiler) callFunction(fn llvm.Value, funcType llvm.Type, sig *callSignature, call preparedCall, retStruct llvm.Type, outputs []*Symbol) []*Symbol { - c.builder.CreateCall(funcType, fn, c.callArgs(sig, call, retStruct, outputs, nil), "") - return outputs -} - // extract numeric fields of a range struct func (c *Compiler) rangeComponents(r llvm.Value) (start, stop, step llvm.Value) { start = c.builder.CreateExtractValue(r, 0, "start") @@ -3213,18 +3341,11 @@ func (c *Compiler) rangeComponents(r llvm.Value) (start, stop, step llvm.Value) return } -func (c *Compiler) rangeStrArg(s *Symbol) (arg llvm.Value) { +// rangeStrArg formats a Range descriptor as "start:stop" or "start:stop:step". +func (c *Compiler) rangeStrArg(s *Symbol) llvm.Value { start, stop, step := c.rangeComponents(s.Val) - - // call range_i64_str fnType, fn := c.GetCFunc(RANGE_I64_STR) - arg = c.builder.CreateCall( - fnType, - fn, - []llvm.Value{start, stop, step}, - RANGE_I64_STR, - ) - return + return c.builder.CreateCall(fnType, fn, []llvm.Value{start, stop, step}, RANGE_I64_STR) } func (c *Compiler) floatStrArg(s *Symbol) llvm.Value { @@ -3477,6 +3598,18 @@ func (c *Compiler) appendPrintExpression(expr ast.Expression, formatStr *string, c.freeTemporary(expr, nonStringTemps) } +// printOwnsHeapString reports whether a printf argument is a temporary this +// print must release. A named binding's payload is not ours to free, whether +// the expression names it directly or a root-position comparison yields its +// left operand's value under a non-Identifier node. +func printOwnsHeapString(s *Symbol, expr ast.Expression) bool { + if !IsStrH(s.Type) || s.Borrowed { + return false + } + _, isIdent := expr.(*ast.Identifier) + return !isIdent +} + // appendPrintSymbol handles printing one symbol based on its type func (c *Compiler) appendPrintSymbol(s *Symbol, expr ast.Expression, formatStr *string, args *[]llvm.Value, toFree *[]llvm.Value) { // Dereference pointers first - treat print args like function args @@ -3495,15 +3628,6 @@ func (c *Compiler) appendPrintSymbol(s *Symbol, expr ast.Expression, formatStr * return } - // ArrayRange needs special handling (two string args) - if s.Type.Kind() == ArrayRangeKind { - arrStr, rngStr := c.arrayRangeStrArgs(s) - *formatStr += "%s[%s] " - *args = append(*args, arrStr, rngStr) - *toFree = append(*toFree, arrStr, rngStr) - return - } - // Get format specifier for this type spec, err := defaultSpecifier(s.Type) if err != nil { @@ -3518,10 +3642,6 @@ func (c *Compiler) appendPrintSymbol(s *Symbol, expr ast.Expression, formatStr * // Handle types that need string conversion switch s.Type.Kind() { - case RangeKind: - strPtr := c.rangeStrArg(s) - *args = append(*args, strPtr) - *toFree = append(*toFree, strPtr) case FloatKind: strPtr := c.floatStrArg(s) *args = append(*args, strPtr) @@ -3542,11 +3662,13 @@ func (c *Compiler) appendPrintSymbol(s *Symbol, expr ast.Expression, formatStr * case StrKind: *args = append(*args, s.Val) // Heap string temporaries must survive until printf executes. - if IsStrH(s.Type) { - if _, isIdent := expr.(*ast.Identifier); !isIdent { - *toFree = append(*toFree, s.Val) - } + if printOwnsHeapString(s, expr) { + *toFree = append(*toFree, s.Val) } + case RangeKind: + strPtr := c.rangeStrArg(s) + *args = append(*args, strPtr) + *toFree = append(*toFree, strPtr) default: *args = append(*args, s.Val) } diff --git a/compiler/compiler_test.go b/compiler/compiler_test.go index 04342293..5e07acd6 100644 --- a/compiler/compiler_test.go +++ b/compiler/compiler_test.go @@ -55,6 +55,16 @@ out` require.Less(t, division, falseLabel, "the second condition must not escape the lazy RHS block") } +func TestDirectReturnSeedIndex(t *testing.T) { + zeroArg := classifyFuncABI(nil, []Type{I64}) + require.Equal(t, ABIReturnDirect, zeroArg.Return.Mode) + require.Equal(t, 0, zeroArg.DirectReturnSeedParamIndex()) + + stringReturn := classifyFuncABI(nil, []Type{StrG{}}) + require.Equal(t, ABIReturnIndirect, stringReturn.Return.Mode) + require.Equal(t, -1, stringReturn.DirectReturnSeedParamIndex()) +} + func TestPhase1ScalarABIDirectI64(t *testing.T) { code := `res = Add(x, y) res = x + y` @@ -65,8 +75,8 @@ res` scriptIR, _ := compileScriptAndCodeIR(t, moduleName, code, script) mangled := Mangle(MangleDirPath(moduleName, ""), "Add", []Type{I64, I64}) - require.Contains(t, scriptIR, "define noundef i64 @"+mangled+"(i64 noundef %0, i64 noundef %1)", "expected direct scalar signature with noundef attrs") - require.Contains(t, scriptIR, "call i64 @"+mangled+"(i64 2, i64 3)", "expected direct scalar call") + require.Contains(t, scriptIR, "define noundef i64 @"+mangled+"(i64 noundef %0, i64 noundef %1, i64 noundef %2)", "expected direct scalar signature with a hidden destination seed") + require.Contains(t, scriptIR, "call i64 @"+mangled+"(i64 2, i64 3, i64 0)", "expected direct scalar call with a fresh-destination seed") require.NotContains(t, scriptIR, mangled+"_ret", "single-scalar return should not use sret struct") } @@ -106,8 +116,8 @@ res` scriptIR, _ := compileScriptAndCodeIR(t, moduleName, code, script) mangled := Mangle(MangleDirPath(moduleName, ""), "AddF", []Type{F64, F64}) - require.Contains(t, scriptIR, "define noundef double @"+mangled+"(double noundef %0, double noundef %1)", "expected direct float signature with noundef attrs") - require.Contains(t, scriptIR, "call double @"+mangled+"(double 2.500000e+00, double 3.500000e+00)", "expected direct float call") + require.Contains(t, scriptIR, "define noundef double @"+mangled+"(double noundef %0, double noundef %1, double noundef %2)", "expected direct float signature with a hidden destination seed") + require.Contains(t, scriptIR, "call double @"+mangled+"(double 2.500000e+00, double 3.500000e+00, double 0.000000e+00)", "expected direct float call with a fresh-destination seed") require.NotContains(t, scriptIR, mangled+"_ret", "single-scalar float return should not use sret struct") } @@ -127,6 +137,141 @@ a, b` require.Contains(t, scriptIR, "call void @"+mangled+"(", "expected indirect multi-return call") } +func TestRangedArrayOutputMarksIndirectWrite(t *testing.T) { + code := `res = LastRow(matrix) + i = 0:2 + res = matrix[i]` + script := `matrix = [ + 1 2 + 3 4 +] +row = LastRow(matrix) +row` + + moduleName := "ranged_array_output_write" + scriptIR, _ := compileScriptAndCodeIR(t, moduleName, code, script) + mangled := Mangle(MangleDirPath(moduleName, ""), "LastRow", []Type{ + Array{ElemType: I64, Rank: 2}, + }) + + require.Contains(t, scriptIR, "define void @"+mangled+"(", "expected an indirect array return") + require.Contains(t, scriptIR, "store i1 true, ptr %res_written", "a yielded ranged selection must mark the output as written") +} + +// verifyCompiledFunctions asserts LLVM accepts every function a source pair +// lowers to. The alias selector picks an output by position, so a mistyped +// output reaching it yields IR that runs wrong or fails object emission. +// Verification is per function because module scope additionally trips on +// format-string globals built in the wrong LLVM context. +func verifyCompiledFunctions(t *testing.T, moduleName, codeSrc, scriptSrc string) { + t.Helper() + + ctx := llvm.NewContext() + defer ctx.Dispose() + + cc := NewCodeCompiler(ctx, moduleName, "", mustParseCode(t, codeSrc)) + sc := NewScriptCompiler(ctx, mustParseScript(t, scriptSrc), cc, make(map[string]*Func), cc.Compiler.ExprCache) + require.Empty(t, sc.Compile()) + + verified := 0 + for fn := sc.Compiler.Module.FirstFunction(); !fn.IsNil(); fn = llvm.NextFunction(fn) { + if fn.IsDeclaration() { + continue + } + require.NoError(t, llvm.VerifyFunction(fn, llvm.ReturnStatusAction), "function %s must verify", fn.Name()) + verified++ + } + require.NotZero(t, verified, "expected at least one defined function to verify") +} + +func TestAliasSelectorTypeGaps(t *testing.T) { + const accFirst = "s = 1\nq, r = Mixed(s, 0:4)\nq, r" + + cases := []struct{ name, code, script string }{ + {"float sibling", "sum, other = Mixed(a, x)\n sum = a + x\n other = x * 0.5", accFirst}, + {"string sibling", "sum, other = Mixed(a, x)\n sum = a + x\n other = \"n\"", accFirst}, + {"array sibling", "sum, other = Mixed(a, x)\n sum = a + x\n other = [x x]", accFirst}, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + verifyCompiledFunctions(t, "alias_mismatch", tc.code, tc.script) + }) + } +} + +// Writing a parameter through %n promotes it to memory, which picks the aliased +// slot by pointer. Opaque pointers make a mistyped pointer select valid IR and +// the selector never matches the skipped index at runtime, so only the emitted +// slot selects distinguish this path. +func TestPromotedAliasTypeGap(t *testing.T) { + code := `half, res = Rev(a, x) + "count-a%n chars" + half = x * 0.5 + res = a + x` + script := `h = 0.0 +r = 10 +h, r = Rev(r, 1:4) +h, r` + + ir, _ := compileScriptAndCodeIR(t, "pointer_promotion_gap", code, script) + + require.Regexp(t, `%a_alias_1 = icmp eq i32 %\d+, 2`, ir, + "the compatible output is the second one, so its ABI selector value must be 2") + require.Contains(t, ir, "%a_slot_1 = select i1 %a_alias_1, ptr %res_dest, ptr %a", + "selector 2 must choose the caller's res destination, falling back to the parameter spill") + require.NotContains(t, ir, "%a_slot_0 = select", + "the mismatched leading output must never be selectable as the parameter's slot") +} + +func TestRangeCollectorScalarVariant(t *testing.T) { + code := `res = Scale(x) + res = x * 3` + script := `arr = [10 20 30] +i = 0:3 +scaled = [Scale(arr[i])] +scaled` + + moduleName := "collector_scalar_variant" + scriptIR, _ := compileScriptAndCodeIR(t, moduleName, code, script) + mangled := Mangle(MangleDirPath(moduleName, ""), "Scale", []Type{I64}) + + require.Contains(t, scriptIR, "define noundef i64 @"+mangled+"(", + "a collector invokes the callee once per scalar yield, so promoting the argument to an internal ArrayRange must still define the scalar variant") +} + +func TestArrayCellLoweringSurfacesNewError(t *testing.T) { + ctx := llvm.NewContext() + defer ctx.Dispose() + + cc := NewCodeCompiler(ctx, "array_cell_empty_vals", "", ast.NewCode()) + c := cc.Compiler + + fn := llvm.AddFunction(c.Module, "probe", llvm.FunctionType(ctx.VoidType(), nil, false)) + c.builder.SetInsertPointAtEnd(c.Context.AddBasicBlock(fn, "entry")) + + cell := &ast.CallExpression{ + Token: token.Token{Type: token.LPAREN, Literal: token.SYM_LPAREN}, + Function: &ast.Identifier{ + Token: token.Token{Type: token.IDENT, Literal: "Missing"}, + Value: "Missing", + }, + } + c.ExprCache[key(c.FuncNameMangled, cell)] = &ExprInfo{ + OutTypes: []Type{I64}, + ExprLen: 1, + } + c.Errors = append(c.Errors, &token.CompileError{Token: cell.Tok(), Msg: "prior"}) + errorsBefore := len(c.Errors) + + c.pushStmtCtx() + defer c.popStmtCtx() + require.NotPanics(t, func() { + c.compileArrayLiteralCell(cell, I64, func(*Symbol) bool { return false }) + }, "a cell whose lowering records an error must leave its seed rather than index an empty result") + require.Len(t, c.Errors, errorsBefore+1) + require.Contains(t, c.Errors[errorsBefore].Msg, "function Missing not found") +} + func TestPhase1ScalarABIRangeVariantUsesDirectScalarBoundary(t *testing.T) { code := `res = Acc(a, x) res = a + x` @@ -144,6 +289,110 @@ res` require.NotContains(t, scriptIR, mangled+"_ret", "single-scalar range variant should not use sret struct") } +func TestArrayRangeCallUsesOneStructuralDirectVariant(t *testing.T) { + code := `out = Square(x) + out = x * x` + script := `i = 0:3 +arr = [2 3 4] +res = Square(arr[i]) +res` + + moduleName := "array_range_direct_variant" + scriptIR, _ := compileScriptAndCodeIR(t, moduleName, code, script) + arrayRange := ArrayRange{ + Array: Array{ElemType: I64, Rank: 1}, + Range: Range{Iter: I64}, + } + mangled := Mangle(MangleDirPath(moduleName, ""), "Square", []Type{arrayRange}) + + require.Contains(t, scriptIR, + "define noundef i64 @"+mangled+"(ptr noundef nonnull \"captures\"=\"none\" %0, i64 noundef %1)", + "the ArrayRange direct variant should receive its descriptor plus the hidden output seed") + require.Equal(t, 1, strings.Count(scriptIR, "call i64 @"+mangled+"("), + "an immediate bare array selection should enter the callee only once") +} + +func TestRankTwoArrayRangeCallUsesOneIndirectVariant(t *testing.T) { + code := `out = Identity(x) + out = x` + script := `rows = 0:2 +matrix = [ + 1 2 + 3 4 +] +row = Identity(matrix[rows]) +row` + + moduleName := "array_range_rank_two_indirect" + scriptIR, _ := compileScriptAndCodeIR(t, moduleName, code, script) + arrayRange := ArrayRange{ + Array: Array{ElemType: I64, Rank: 2}, + Range: Range{Iter: I64}, + } + mangled := Mangle(MangleDirPath(moduleName, ""), "Identity", []Type{arrayRange}) + + require.Contains(t, scriptIR, "define void @"+mangled+"(", + "the rank-reduced row result should use the indirect array-return ABI") + require.Contains(t, scriptIR, "array_subarray_start", + "the callee should materialize each selected rank-reduced row") + require.Contains(t, scriptIR, "call ptr @arr_i64_copy(", + "assigning the callee's row parameter should copy it into the indirect output") + require.Contains(t, scriptIR, mangled+"_ret", + "the indirect variant should expose output and write-flag slots") + require.Equal(t, 1, strings.Count(scriptIR, "call void @"+mangled+"("), + "a rank-two selection should be iterated inside one callee invocation") +} + +// Shared drivers must advance in lockstep. Until callee specializations encode +// driver identity, keep the shared loop at the caller and invoke a scalar call. +func TestSharedDriverUsesScalarCall(t *testing.T) { + code := `out = Add(x, y) + out = x + y` + script := `i = 0:3 +arr = [10 20 30] +res = Add(arr[i], i) +res` + + moduleName := "array_range_shared_driver" + scriptIR, _ := compileScriptAndCodeIR(t, moduleName, code, script) + scalarMangled := Mangle(MangleDirPath(moduleName, ""), "Add", []Type{I64, I64}) + arrayRangeMangled := Mangle(MangleDirPath(moduleName, ""), "Add", []Type{ + ArrayRange{ + Array: Array{ElemType: I64, Rank: 1}, + Range: Range{Iter: I64}, + }, + Range{Iter: I64}, + }) + + require.Contains(t, scriptIR, "define noundef i64 @"+scalarMangled+"(i64 noundef %0, i64 noundef %1, i64 noundef %2)", + "a shared driver must select the ordinary scalar specialization") + require.GreaterOrEqual(t, strings.Count(scriptIR, "call i64 @"+scalarMangled+"("), 1, + "the shared caller-side loop should invoke the scalar specialization") + require.Contains(t, scriptIR, "call i64 @"+scalarMangled+"(i64 %get, i64 %iter, i64 %call_seed)", + "the array access and scalar argument should use the same caller-loop iterator") + require.NotContains(t, scriptIR, arrayRangeMangled, + "arr[i] and i must not become independent callee iterators") +} + +func TestRangeIndirectCallUsesOneCalleeVariant(t *testing.T) { + code := `left, right = Pair(x) + left = x + right = x + 1` + script := `left, right = Pair(0:3) +left, right` + + moduleName := "range_indirect_one_call" + scriptIR, _ := compileScriptAndCodeIR(t, moduleName, code, script) + mangled := Mangle(MangleDirPath(moduleName, ""), "Pair", []Type{Range{Iter: I64}}) + + require.Contains(t, scriptIR, "define void @"+mangled+"(", + "a multi-output Range specialization should keep the indirect-return ABI") + require.Contains(t, scriptIR, mangled+"_ret", + "the indirect Range variant should expose output and write-flag slots") + require.Equal(t, 1, strings.Count(scriptIR, "call void @"+mangled+"("), + "a bare Range should be iterated by one callee invocation") +} + func TestConditionalDirectCallArgsDoNotPromoteDirectParams(t *testing.T) { code := `res = Id(x) res = x @@ -313,7 +562,7 @@ greeting = "hello\n\x41"` func TestStructStringConstantDecodesEscapes(t *testing.T) { code := mustParseCode(t, `p = Person - :name + : name "\x41da\n"`) ctx := llvm.NewContext() @@ -346,10 +595,10 @@ func TestCompilerModuleTargetMetadata(t *testing.T) { func TestStructRepeatedDefs(t *testing.T) { codeA := mustParseCode(t, `p = Person - :name age + : name age "Tejas" 35`) codeB := mustParseCode(t, `q = Person - :name age + : name age "Ada" 28`) merged := ast.NewCode() @@ -366,10 +615,10 @@ func TestStructRepeatedDefs(t *testing.T) { func TestStructAmbiguousFieldOrder(t *testing.T) { codeA := mustParseCode(t, `p = Person - :name age + : name age "Tejas" 35`) codeB := mustParseCode(t, `q = Person - :age name + : age name 28 "Ada"`) merged := ast.NewCode() @@ -395,10 +644,10 @@ func TestStructAmbiguousFieldOrder(t *testing.T) { func TestStructUnknownField(t *testing.T) { codeA := mustParseCode(t, `p = Person - :name age score + : name age score "Tejas" 35 100`) codeB := mustParseCode(t, `q = Person - :name height + : name height "Ada" 170`) merged := ast.NewCode() @@ -424,10 +673,10 @@ func TestStructUnknownField(t *testing.T) { func TestStructFieldTypeMismatch(t *testing.T) { codeA := mustParseCode(t, `p = Person - :name age height + : name age height "Tejas" 35 184.5`) codeB := mustParseCode(t, `q = Person - :name age + : name age "Ada" 28.5`) merged := ast.NewCode() @@ -453,10 +702,10 @@ func TestStructFieldTypeMismatch(t *testing.T) { func TestStructSameArityConflictingTypes(t *testing.T) { codeA := mustParseCode(t, `p = Person - :name age + : name age "Tejas" 35`) codeB := mustParseCode(t, `q = Person - :name age + : name age 35 "Ada"`) merged := ast.NewCode() @@ -482,10 +731,10 @@ func TestStructSameArityConflictingTypes(t *testing.T) { func TestStructSubsetDefs(t *testing.T) { codeA := mustParseCode(t, `p = Person - :name age height + : name age height "Tejas" 35 184.5`) codeB := mustParseCode(t, `q = Person - :age name + : age name 28 "Ada"`) merged := ast.NewCode() @@ -503,10 +752,10 @@ func TestStructSubsetDefs(t *testing.T) { func TestStructMaxHeaderDef(t *testing.T) { // Smaller statement first, larger definition second — larger wins. codeA := mustParseCode(t, `q = Person - :age name + : age name 28 "Ada"`) codeB := mustParseCode(t, `p = Person - :name age height + : name age height "Tejas" 35 184.5`) merged := ast.NewCode() @@ -527,7 +776,7 @@ func TestStructMaxHeaderDef(t *testing.T) { func TestStructEmptyInit(t *testing.T) { code := mustParseCode(t, `p = Person - :name age + : name age "Tejas" 35 q = Person`) @@ -666,10 +915,10 @@ func TestReservedScriptVariableName(t *testing.T) { func TestStructUnknownFieldNoSpuriousError(t *testing.T) { codeA := mustParseCode(t, `p = Person - :name age + : name age "Tejas" 35`) codeB := mustParseCode(t, `q = Person - :height age + : height age 170 28`) merged := ast.NewCode() @@ -694,7 +943,7 @@ func TestStructUnknownFieldNoSpuriousError(t *testing.T) { require.True(t, found, "expected redefined struct error, got: %v", errs) } -func TestSetupRangeOutputsWithPointerSeed(t *testing.T) { +func TestMakeSeededTempOutputsCopiesPointerSeed(t *testing.T) { ctx := llvm.NewContext() defer ctx.Dispose() @@ -720,22 +969,20 @@ func TestSetupRangeOutputsWithPointerSeed(t *testing.T) { ReadOnly: true, }) - // Act: seed a loop temporary for a pointer-valued output. + // Act: seed an independent temporary from a pointer-backed destination. dest := []*ast.Identifier{{Value: "seed"}} outTypes := []Type{I64} - outputs := c.makeOutputs(dest, outTypes, false) + outputs := c.makeSeededTempOutputs(dest, outTypes) require.Len(t, outputs, 1, "expect a single output symbol") - // When seed is already a pointer, makeOutputs reuses it directly - require.Equal(t, global, outputs[0].Val, "expected existing pointer to be reused") + require.NotEqual(t, global.String(), outputs[0].Val.String(), "expected an independent output slot") require.Equal(t, ptrType, outputs[0].Type, "expected pointer type to be preserved") c.builder.CreateRetVoid() ir := c.Module.String() - // No store or load needed - we reuse the existing pointer directly - require.NotContains(t, ir, "store", "pointer seed should be reused without store") - require.NotContains(t, ir, "load i64, ptr @seed_global", "pointer seed should not be dereferenced") + require.Contains(t, ir, "load i64, ptr @seed_global", "expected destination seed to be loaded") + require.Contains(t, ir, "store i64", "expected destination seed to initialize the independent slot") } func TestCompileCondScalarStrHUsesBranch(t *testing.T) { diff --git a/compiler/cond.go b/compiler/cond.go index 332e3a66..2c8ff1b1 100644 --- a/compiler/cond.go +++ b/compiler/cond.go @@ -206,8 +206,19 @@ func (c *Compiler) createConditionalTempOutputsFor(dest []*ast.Identifier, outTy Type: Ptr{Elem: outTypes[i]}, Borrowed: true, } + existing, exists := Get(c.Scopes, ident.Value) seed := c.resolveDestSeed(ident, outTypes[i]) + // The conditional slot owns its seed independently. Otherwise an + // early RHS overwrite can free the real pre-statement destination + // before a later sibling RHS has read it. + // A fresh zero seed is already owned and must not be copied again. + if exists { + seed = c.deepCopyIfNeeded(seed) + } c.storeSymbolToSlot(tempSym, seed, outTypes[i], tempName+"_seed") + if exists { + tempSym.WriteFlag = existing.WriteFlag + } // Temporary conditional outputs are borrowed so scope cleanup does not free // values that are transferred to real destinations in the merge block. @@ -224,8 +235,9 @@ func (c *Compiler) commitConditionalOutputs(slots []OutputSlot) { finalType := s.outType finalVal := c.createLoad(tempSym.Val, finalType, s.dest.Value+"_cond_final") finalSym := &Symbol{ - Val: finalVal, - Type: finalType, + Val: finalVal, + Type: finalType, + WriteFlag: tempSym.WriteFlag, } oldSym, exists := Get(c.Scopes, s.dest.Value) @@ -233,6 +245,7 @@ func (c *Compiler) commitConditionalOutputs(slots []OutputSlot) { Put(c.Scopes, s.dest.Value, finalSym) continue } + oldValue := c.valueSymbol(s.dest.Value, oldSym, s.dest.Value+"_cond_old") if _, ok := oldSym.Type.(Ptr); ok { c.storeSymbolToSlot(oldSym, finalSym, oldSym.Type.(Ptr).Elem, s.dest.Value+"_cond_commit") @@ -244,12 +257,17 @@ func (c *Compiler) commitConditionalOutputs(slots []OutputSlot) { // SetExisting always finds the binding — and it must update it in its own // scope (which may be an outer block), not shadow it via Put in the current. SetExisting(c.Scopes, s.dest.Value, updated) + if !c.skipBorrowedOldValueFree(oldValue) { + c.freeSymbolValue(oldValue, s.dest.Value+"_cond_old") + } continue } - // Non-pointer symbols are replaced directly. Old value ownership is already - // handled in the IF branch assignment into temp slots. + // Non-pointer symbols are replaced directly. Put(c.Scopes, s.dest.Value, finalSym) + if !c.skipBorrowedOldValueFree(oldValue) { + c.freeSymbolValue(oldValue, s.dest.Value+"_cond_old") + } } } @@ -334,6 +352,9 @@ func (c *Compiler) createStageTempOutputsFor(commit []OutputSlot) []OutputSlot { seed := c.resolveDestSeed(cs.temp, outType) seed = c.deepCopyIfNeeded(seed) c.storeSymbolToSlot(stageTempSym, seed, outType, tempName+"_seed") + if commitSym, ok := Get(c.Scopes, cs.temp.Value); ok { + stageTempSym.WriteFlag = commitSym.WriteFlag + } Put(c.Scopes, tempName, stageTempSym) stage[i] = OutputSlot{dest: cs.dest, temp: tempIdent, outType: outType} } @@ -344,6 +365,7 @@ func (c *Compiler) commitStageTempOutputs(commit []OutputSlot, stage []OutputSlo for i := range stage { stageSym, _ := Get(c.Scopes, stage[i].temp.Value) stagedValue := c.valueSymbol(stage[i].temp.Value, stageSym, stage[i].temp.Value+"_stage_final") + stagedValue.WriteFlag = stageSym.WriteFlag c.commitSlotValue(commit[i].temp, stagedValue, false) } } @@ -635,6 +657,15 @@ func (c *Compiler) extractComparisonSlots(infix *ast.InfixExpression, info *Expr lSym, cmpVal := c.compareScalars(infix.Operator, left[i], right[i]) conds[i] = c.andConds(operandCond, cmpVal, fmt.Sprintf("slot_cond_%d", i)) lhsSyms[i] = lSym + if _, isIdent := infix.Left.(*ast.Identifier); isIdent { + // The retained value still belongs to the named binding, so a + // later assignment must copy rather than transfer that payload. + // Borrow a copy: compareScalars returns the scope's own *Symbol + // for a non-pointer binding, and marking that would leave the + // variable borrowed for life, so cleanup would never free it. + lhsSyms[i] = GetCopy(lSym) + lhsSyms[i].Borrowed = true + } } } @@ -872,56 +903,34 @@ func (c *Compiler) branchCond(cond llvm.Value, temps []condTemp, onTrue func(), }) } -func (c *Compiler) isRangeDriverCond(expr ast.Expression) bool { - info := c.ExprCache[key(c.FuncNameMangled, expr)] - if len(info.OutTypes) != 1 { - return false - } - - return isRangeDriverType(info.OutTypes[0]) -} - -func (c *Compiler) collectDriverRanges(expr ast.Expression) []*RangeInfo { - info := c.ExprCache[key(c.FuncNameMangled, expr)] - if len(info.Ranges) > 0 { - return info.Ranges - } - - ident, ok := expr.(*ast.Identifier) - if !ok { - panic(fmt.Sprintf("internal: bare range driver %T missing cached ranges", expr)) - } - return []*RangeInfo{{Name: ident.Value}} -} - -// splitCondRanges collects merged ranges and boolean guard expressions -// from statement conditions. Bare range/array-range drivers contribute only -// ranges; comparisons contribute both ranges and a per-iteration guard. +// splitCondRanges collects merged ranges and boolean guard expressions from +// statement conditions. Once any condition contributes a range, every +// non-driver condition remains a per-iteration guard, including scalar +// conjuncts with no ranges of their own. Bare range/array-selection drivers +// contribute only ranges. // Returns nil, nil if no condition introduces ranges. func (c *Compiler) splitCondRanges(conditions []ast.Expression) ([]*RangeInfo, []ast.Expression) { var ranges []*RangeInfo - var condExprs []ast.Expression for _, expr := range conditions { info := c.ExprCache[key(c.FuncNameMangled, expr)] - if c.isRangeDriverCond(expr) { - ranges = mergeUses(ranges, c.collectDriverRanges(expr)) - continue - } + ranges = mergeUses(ranges, info.Ranges) + } + if len(ranges) == 0 { + return nil, nil + } - if len(info.Ranges) == 0 { + var condExprs []ast.Expression + for _, expr := range conditions { + info := c.ExprCache[key(c.FuncNameMangled, expr)] + if info.RangeDriverCond { continue } - - ranges = mergeUses(ranges, info.Ranges) if info.Rewrite != nil { condExprs = append(condExprs, info.Rewrite) continue } condExprs = append(condExprs, expr) } - if len(ranges) == 0 { - return nil, nil - } return ranges, condExprs } @@ -954,7 +963,6 @@ func (c *Compiler) withCondRangeLoop(allRanges []*RangeInfo, condExprs []ast.Exp type statementArrayCollector struct { literal *ast.ArrayLiteral destination *ast.Identifier - oldValue *Symbol scalar *ArrayAccumulator stacked *stackedArrayAccumulator } @@ -963,7 +971,6 @@ func (c *Compiler) newStatementArrayCollector(lit *ast.ArrayLiteral, destination collector := &statementArrayCollector{ literal: lit, destination: destination, - oldValue: c.captureOldValues([]*ast.Identifier{destination})[0], } if arrayLiteralHasArrayCells(lit, arrayType) { collector.stacked = c.newStackedArrayAccumulator(arrayType) @@ -981,14 +988,31 @@ func (c *Compiler) appendStatementArrayCollector(collector *statementArrayCollec c.appendArrayLiteral(collector.scalar, collector.literal) } -func (c *Compiler) commitStatementArrayCollector(collector *statementArrayCollector) { - var result *Symbol - if collector.stacked != nil { - result = c.stackedArrayAccumulatorResult(collector.stacked) - } else { - result = c.ArrayAccResult(collector.scalar) +// finishStatementArrayCollectors commits the accumulated results only when the +// shared statement gate admitted an iteration; otherwise it keeps the seeded +// destinations and releases the unused accumulators. +func (c *Compiler) finishStatementArrayCollectors(collectors []*statementArrayCollector, didAdmit llvm.Value) { + results := make([]*Symbol, len(collectors)) + for i, collector := range collectors { + if collector.stacked != nil { + results[i] = c.stackedArrayAccumulatorResult(collector.stacked) + } else { + results[i] = c.ArrayAccResult(collector.scalar) + } + c.ensureSeededDest(collector.destination, results[i]) } - c.storeAccumulatedArray(collector.destination, result, collector.oldValue) + + c.withCondBranch(didAdmit, "collector_write", func() { + for i, collector := range collectors { + c.commitSlotValue(collector.destination, results[i], false) + } + }, func() { + // Every accumulator owns a runtime vector even when it collected no + // cells, so a blocked statement must release the uncommitted results. + for _, result := range results { + c.freeTemporarySymbol(result, "collector_skip") + } + }) } // compileCondRangedStatement lowers ranged statement conditions. @@ -1052,9 +1076,18 @@ func (c *Compiler) compileCondRangedStatement(stmt *ast.LetStatement, condRanges } } + var collectorAdmitted llvm.Value + if len(collectors) > 0 { + collectorAdmitted = c.createEntryBlockAlloca(c.Context.Int1Type(), "cond_collector_admitted") + c.createStore(llvm.ConstInt(c.Context.Int1Type(), 0, false), collectorAdmitted, Int{Width: 1}) + } + // Guards and RHS staging must read the same loop-carried destination slots. aliases := c.aliasCondDests(assignSlots) c.withCondRangeLoop(condRanges, condExprs, loopProbes, "cond_iter_guard", "cond_iter_if", "cond_iter_cont", func() { + if !collectorAdmitted.IsNil() { + c.createStore(llvm.ConstInt(c.Context.Int1Type(), 1, false), collectorAdmitted, Int{Width: 1}) + } c.compileCondRangedIteration(assignExprs, assignSlots, appendCollectors) }) c.restoreCondDests(aliases) @@ -1064,17 +1097,10 @@ func (c *Compiler) compileCondRangedStatement(stmt *ast.LetStatement, condRanges DeleteBulk(c.Scopes, slotTempStrings(assignSlots)) } - for _, collector := range collectors { - c.commitStatementArrayCollector(collector) - } -} - -func (c *Compiler) storeAccumulatedArray(dest *ast.Identifier, result, oldValue *Symbol) { - c.storeValue(dest.Value, result, false) - if oldValue == nil || c.skipBorrowedOldValueFree(oldValue) { - return + if len(collectors) > 0 { + didAdmit := c.createLoad(collectorAdmitted, Int{Width: 1}, "cond_collector_admitted") + c.finishStatementArrayCollectors(collectors, didAdmit) } - c.freeSymbolValue(oldValue, "old_accum") } // compileCondRangedIteration runs inside the per-iteration body of diff --git a/compiler/format.go b/compiler/format.go index 337025b6..16ba75c2 100644 --- a/compiler/format.go +++ b/compiler/format.go @@ -49,14 +49,13 @@ func defaultSpecifier(t Type) (string, error) { case StrKind: return "%s", nil case RangeKind: + // Range descriptors are converted to char* via range_i64_str return "%s", nil case ArrayKind: // Arrays are converted to char* via runtime helpers return "%s", nil case TableKind: return "%s", nil - case ArrayRangeKind: - return "%s", nil case StructKind: return "%s", nil default: @@ -594,10 +593,6 @@ func (c *Compiler) formatAsString(mainSym *Symbol, result *formattedMarker) bool strPtr := c.floatStrArg(mainSym) result.args = append(result.args, strPtr) result.toFree = append(result.toFree, strPtr) - case RangeKind: - strPtr := c.rangeStrArg(mainSym) - result.args = append(result.args, strPtr) - result.toFree = append(result.toFree, strPtr) case ArrayKind: arrType := mainSym.Type.(Array) if arrType.Rank == 1 && !hasConcreteArrayElemType(arrType.ElemType) { @@ -611,16 +606,15 @@ func (c *Compiler) formatAsString(mainSym *Symbol, result *formattedMarker) bool strPtr := c.tableStrArg(mainSym) result.args = append(result.args, strPtr) result.toFree = append(result.toFree, strPtr) + case RangeKind: + strPtr := c.rangeStrArg(mainSym) + result.args = append(result.args, strPtr) + result.toFree = append(result.toFree, strPtr) case StructKind: fmtStr, fmtArgs, fmtFree := c.structFormatArgs(mainSym) result.text = fmtStr result.args = append(result.args, fmtArgs...) result.toFree = append(result.toFree, fmtFree...) - case ArrayRangeKind: - arrStr, rangeStr := c.arrayRangeStrArgs(mainSym) - result.text += "[%s]" - result.args = append(result.args, arrStr, rangeStr) - result.toFree = append(result.toFree, arrStr, rangeStr) default: return false } @@ -857,7 +851,7 @@ func maybeMarker(runes []rune, i int) bool { // Output format: // // Point -// :x y +// : x y // 1 2 func (c *Compiler) structFormatArgs(s *Symbol) (fmtStr string, args []llvm.Value, toFree []llvm.Value) { st := s.Type.(Struct) @@ -881,15 +875,16 @@ func (c *Compiler) structFormatArgs(s *Symbol) (fmtStr string, args []llvm.Value } valueParts = append(valueParts, spec) } - fmtStr = st.Name + "\n :" + strings.Join(headerParts, " ") + "\n " + strings.Join(valueParts, " ") + "\n" + fmtStr = st.Name + "\n : " + strings.Join(headerParts, " ") + "\n " + strings.Join(valueParts, " ") + "\n" return } -// hasValidMarkers checks if a format string contains any markers (-identifier) -// where the main identifier is defined according to the provided isDefined callback. -// This aligns with parseMarker/formatString semantics: each marker is resolved -// independently, including markers in text following an unresolved marker. -func hasValidMarkers(value string, isDefined func(string) bool) bool { +// formatMarkerIdentifiers returns the identifiers read by resolved markers in +// source order, separating main-marker identifiers from dynamic +// width/precision identifiers. A main marker formats its value whatever the +// type, while a specifier operand is consumed as a number, so only specifier +// identifiers can turn a named Range into an iteration driver. +func formatMarkerIdentifiers(value string, isDefined func(string) bool) (mains, specs []string) { runes := []rune(value) for i := 0; i < len(runes); i++ { if runes[i] == '\\' { @@ -900,12 +895,29 @@ func hasValidMarkers(value string, isDefined func(string) bool) bool { if !maybeMarker(runes, i) { continue } - // Parse the identifier after the '-' - mainId, _ := parseIdentifier(runes, i+1) - // Only the main identifier matters - aligns with parseMarker behavior - if isDefined(mainId) { - return true + mainID, end := parseIdentifier(runes, i+1) + if !isDefined(mainID) { + continue + } + mains = append(mains, mainID) + + if end >= len(runes) || runes[end] != '%' { + i = end - 1 + continue + } + spec, _ := parseSpecifierSyntax(token.Token{}, value, runes, end) + for _, specID := range spec.ids { + if isDefined(specID) { + specs = append(specs, specID) + } } + i = spec.end - 1 } - return false + return mains, specs +} + +// hasValidMarkers checks if a format string contains a resolved marker. +func hasValidMarkers(value string, isDefined func(string) bool) bool { + mains, _ := formatMarkerIdentifiers(value, isDefined) + return len(mains) > 0 } diff --git a/compiler/loop.go b/compiler/loop.go index c484aa8e..ad4a3f7e 100644 --- a/compiler/loop.go +++ b/compiler/loop.go @@ -13,11 +13,11 @@ type Loop struct { Exit *llvm.BasicBlock } -// extractRangeSymbol loads a range aggregate from a symbol when needed. -func (c *Compiler) extractRangeSymbol(sym *Symbol, name string) (*Symbol, bool) { +// rangeSymbol loads a range aggregate from a symbol when needed. +func (c *Compiler) rangeSymbol(sym *Symbol, name string) *Symbol { switch t := sym.Type.(type) { case Range: - return sym, true + return sym case Ptr: rangeType, ok := t.Elem.(Range) if ok { @@ -27,74 +27,30 @@ func (c *Compiler) extractRangeSymbol(sym *Symbol, name string) (*Symbol, bool) FuncArg: sym.FuncArg, Borrowed: true, ReadOnly: sym.ReadOnly, - }, true + } } } - return nil, false -} -// extractArrayRangeSymbol loads an array-range aggregate from a symbol when needed. -func (c *Compiler) extractArrayRangeSymbol(sym *Symbol, name string) (*Symbol, bool) { - switch t := sym.Type.(type) { - case ArrayRange: - return sym, true - case Ptr: - arrRangeType, ok := t.Elem.(ArrayRange) - if ok { - return &Symbol{ - Val: c.createLoad(sym.Val, arrRangeType, name+"_arrrange"), - Type: arrRangeType, - FuncArg: sym.FuncArg, - Borrowed: true, - ReadOnly: sym.ReadOnly, - }, true - } - } - return nil, false + panic(fmt.Sprintf("internal: %q is not a Range during lowering (got %s)", name, sym.Type.String())) } func (c *Compiler) rangeAggregateFromSymbol(sym *Symbol, name string) llvm.Value { - if rangeSym, ok := c.extractRangeSymbol(sym, name); ok { - return rangeSym.Val - } - - if arrRangeSym, ok := c.extractArrayRangeSymbol(sym, name); ok { - return c.builder.CreateExtractValue(arrRangeSym.Val, 1, name+"_range") - } - - panic(fmt.Sprintf("internal: %q is not a Range or ArrayRange during lowering (got %s)", name, sym.Type.String())) + return c.rangeSymbol(sym, name).Val } func (c *Compiler) iterOverDriverSymbol(sym *Symbol, name string, body func(*Symbol)) { - if rangeSym, ok := c.extractRangeSymbol(sym, name); ok { - c.iterOverRange(rangeSym.Type.(Range), rangeSym.Val, func(iter llvm.Value, iterType Type) { - body(&Symbol{ - Val: iter, - Type: iterType, - FuncArg: rangeSym.FuncArg, - Borrowed: true, - }) - }) - return - } - - if arrRangeSym, ok := c.extractArrayRangeSymbol(sym, name); ok { - c.iterOverArrayRange(arrRangeSym, func(iter llvm.Value, iterType Type) { - body(&Symbol{ - Val: iter, - Type: iterType, - FuncArg: arrRangeSym.FuncArg, - Borrowed: true, - }) + rangeSym := c.rangeSymbol(sym, name) + c.iterOverRange(rangeSym.Type.(Range), rangeSym.Val, func(iter llvm.Value, iterType Type) { + body(&Symbol{ + Val: iter, + Type: iterType, + FuncArg: rangeSym.FuncArg, + Borrowed: true, }) - return - } - - panic(fmt.Sprintf("internal: %q is not a Range or ArrayRange during lowering (got %s)", name, sym.Type.String())) + }) } // rangeAggregateForRI builds the {start,stop,step} aggregate for a driver. -// Named ArrayRange drivers contribute their underlying range component. func (c *Compiler) rangeAggregateForRI(ri *RangeInfo) llvm.Value { if ri.RangeLit != nil { return c.ToRange(ri.RangeLit, Range{Iter: Int{Width: 64}}) @@ -320,7 +276,3 @@ func (c *Compiler) createLoop(r llvm.Value, bodyGen func(iter llvm.Value)) { return llvm.Value{} }) } - -func (c *Compiler) createLoopState(r llvm.Value, seed llvm.Value, stateType llvm.Type, bodyGen func(iter llvm.Value, current llvm.Value) llvm.Value) llvm.Value { - return c.createLoopCore(r, seed, stateType, true, bodyGen) -} diff --git a/compiler/mangle_test.go b/compiler/mangle_test.go index fb81bf38..0c058d85 100644 --- a/compiler/mangle_test.go +++ b/compiler/mangle_test.go @@ -336,6 +336,54 @@ func TestMangle(t *testing.T) { } } +func TestArrayRangeMangleIsStructural(t *testing.T) { + tests := []struct { + name string + typ ArrayRange + expected string + }{ + { + name: "rank one I64 array", + typ: ArrayRange{ + Array: Array{ElemType: I64, Rank: 1}, + Range: Range{Iter: I64}, + }, + expected: "ArrayRange_t2_Array_t1_I64_Range_t1_I64", + }, + { + name: "rank two I64 array", + typ: ArrayRange{ + Array: Array{ElemType: I64, Rank: 2}, + Range: Range{Iter: I64}, + }, + expected: "ArrayRange_t2_Array_t1_Array_t1_I64_Range_t1_I64", + }, + { + name: "rank one F64 array", + typ: ArrayRange{ + Array: Array{ElemType: F64, Rank: 1}, + Range: Range{Iter: I64}, + }, + expected: "ArrayRange_t2_Array_t1_F64_Range_t1_I64", + }, + { + name: "different range iterator", + typ: ArrayRange{ + Array: Array{ElemType: I64, Rank: 1}, + Range: Range{Iter: F64}, + }, + expected: "ArrayRange_t2_Array_t1_I64_Range_t1_F64", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + mangled := tt.typ.Mangle() + assert.Equal(t, tt.expected, mangled) + }) + } +} + func TestMangleDistinguishesModuleFromSubdir(t *testing.T) { // This is the key test: ensure module path vs module+subdir produce different results // Module: github.com/user/math/stats (as a single module) @@ -561,6 +609,10 @@ func TestMangleDemangleRoundTrip(t *testing.T) { {"github.com/user/pkg", "sub", "Run", []Type{F64}, "github.com/user/pkg/sub.Run(F64)"}, {"math", "", "move", []Type{Struct{Name: "Person"}, I64}, "math.move(Person, I64)"}, {"math", "", "pair", []Type{Struct{Name: "Person"}, Struct{Name: "Animal"}}, "math.pair(Person, Animal)"}, + {"iter", "", "sum", []Type{ArrayRange{ + Array: Array{ElemType: I64, Rank: 2}, + Range: Range{Iter: I64}, + }}, "iter.sum(ArrayRange_t2_Array_t1_Array_t1_I64_Range_t1_I64)"}, // Mixed nominal identifiers (ASCII + Unicode) {"math", "", "f", []Type{Struct{Name: "foo_π"}, I64}, "math.f(foo_π, I64)"}, {"math", "", "f", []Type{Struct{Name: "πbar"}, I64}, "math.f(πbar, I64)"}, diff --git a/compiler/solver.go b/compiler/solver.go index 9d71c61e..c33aded6 100644 --- a/compiler/solver.go +++ b/compiler/solver.go @@ -10,10 +10,8 @@ import ( ) type RangeInfo struct { - Name string - RangeLit *ast.RangeLiteral - ArrayExpr ast.Expression - ArrayType Array + Name string + RangeLit *ast.RangeLiteral } // CondMode classifies how a comparison in value position is lowered. @@ -39,6 +37,7 @@ type ExprInfo struct { ScalarCallParamTypes []Type // Param types to use once outer loops consume ranges into scalars CompareModes []CondMode // Per-slot lowering mode for comparisons in value position (nil for non-comparisons) ArrayShape []uint64 // Statically known dimensions for array literals; nil when runtime-dependent + RangeDriverCond bool // Solver-classified loop-domain condition; true implies len(Ranges) > 0. } // HasCondScalar returns true if any slot is a scalar conditional expression. @@ -293,6 +292,8 @@ func (ts *TypeSolver) HandleRanges(e ast.Expression) (ranges []*RangeInfo, rew a return ts.HandleCallRanges(t) case *ast.Identifier: return ts.HandleIdentifierRanges(t) + case *ast.StringLiteral: + return ts.HandleStringLiteralRanges(t) default: return } @@ -483,9 +484,27 @@ func (ts *TypeSolver) collectExprRanges(exprs []ast.Expression) (ranges []*Range // HandleCallRanges processes function call expressions, handling all arguments // and merging their range information for proper loop generation. func (ts *TypeSolver) HandleCallRanges(call *ast.CallExpression) (ranges []*RangeInfo, rew ast.Expression) { - ranges, args, changed := ts.collectExprRanges(call.Arguments) + var args []ast.Expression + var changed bool + // Print is a sink rather than an operation, so its bare descriptor + // arguments print as values and must not be rewritten into loop iterators. + if call.Function.Value == Print { + ranges, args, changed = ts.collectPrintArgRanges(call.Arguments) + } else { + ranges, args, changed = ts.collectExprRanges(call.Arguments) + } info := ts.ExprCache[key(ts.FuncNameMangled, call)] + // A surrounding collector consumes these ranges and invokes the call once + // per scalar yield, so that scalar callee variant must exist even though the + // immediate call selected a range specialization. Promoting an argument to + // an internal ArrayRange changes the mangled name without rewriting the + // argument list, so this cannot be gated on a syntactic rewrite. LoopInside + // is true for any ordinary call, so require ranges to reach only collectors. + if _, builtin := Builtins[call.Function.Value]; len(ranges) > 0 && info.LoopInside && !builtin { + ts.ensureScalarCallVariant(call) + } + if !changed { info.Ranges = ranges info.Rewrite = call @@ -509,42 +528,161 @@ func (ts *TypeSolver) HandleCallRanges(call *ast.CallExpression) (ranges []*Rang return } -// isBareRangeExpr checks if expression is a bare range expression. -// These are "simple" range arguments that can be passed to specialized functions. -// For ArrayRangeExpression, it's only bare if the array part doesn't have ranges -// and the index is itself bare (e.g., arr[i] is bare, but [i][j] or arr[i+1] is not). +// collectPrintArgRanges keeps bare Range descriptors out of the ordinary +// argument pass until sibling drivers are known. Every other argument uses +// collectExprRanges exactly as it does for an ordinary call. A bare descriptor +// whose name a sibling binds then joins that driver; any other bare descriptor +// keeps its original expression and prints as a value. +func (ts *TypeSolver) collectPrintArgRanges(exprs []ast.Expression) (ranges []*RangeInfo, args []ast.Expression, changed bool) { + args = append([]ast.Expression(nil), exprs...) + + var ordinaryArgs []ast.Expression + var ordinaryIndexes []int + for i, arg := range exprs { + if ts.bareRangeDescriptorArg(arg) { + continue + } + ordinaryArgs = append(ordinaryArgs, arg) + ordinaryIndexes = append(ordinaryIndexes, i) + } + + ranges, rewrites, changed := ts.collectExprRanges(ordinaryArgs) + for i, argIndex := range ordinaryIndexes { + args[argIndex] = rewrites[i] + } + + for i, arg := range exprs { + if !ts.bareRangeDescriptorArg(arg) { + continue + } + if ident, ok := arg.(*ast.Identifier); ok && rangeDriverNamed(ranges, ident.Value) { + argRanges, rew := ts.HandleRanges(arg) + args[i] = rew + changed = changed || rew != arg + ranges = mergeUses(ranges, argRanges) + continue + } + info := ts.ExprCache[key(ts.FuncNameMangled, arg)] + info.Ranges = nil + info.HasRanges = false + info.Rewrite = nil + } + return ranges, args, changed +} + +// bareRangeDescriptorArg reports whether a print argument is a complete Range +// descriptor: a range literal or a name bound to a Range. +func (ts *TypeSolver) bareRangeDescriptorArg(arg ast.Expression) bool { + switch a := arg.(type) { + case *ast.RangeLiteral: + return true + case *ast.Identifier: + typ, ok := ts.GetIdentifier(a.Value) + return ok && typ.Kind() == RangeKind + } + return false +} + +// isBareRangeExpr reports whether expr is a driver that a function can consume +// through a range-specialized variant. A ranged array access is eligible only +// while it is an immediate call argument; collectCallArgs gives that call site +// an internal ArrayRange type without exposing it to other expression roots. func (ts *TypeSolver) isBareRangeExpr(expr ast.Expression) bool { switch e := expr.(type) { case *ast.Identifier, *ast.RangeLiteral: return true case *ast.ArrayRangeExpression: - // Only bare if array doesn't have ranges and index is bare - arrInfo := ts.ExprCache[key(ts.FuncNameMangled, e.Array)] - return !arrInfo.HasRanges && ts.isBareRangeExpr(e.Range) + _, _, ok := ts.callScopedArrayRangeType(e) + return ok default: return false } } -// HandleIdentifierRanges processes identifier expressions, detecting if they refer -// to range-typed variables and including them in range tracking. -// Note: This returns ranges but does NOT set info.Ranges on the identifier itself. -// This is intentional - bare identifiers like `i` should print as range representations. -// Ranges are only extracted when the identifier is used in an expression context -// (e.g., in a function call or infix operation) that needs scalar values. +// HandleIdentifierRanges processes identifier expressions, detecting if they +// refer to range-typed variables and including them in range tracking. The +// enclosing context decides whether that occurrence consumes the driver or a +// complete assignment copies the descriptor. func (ts *TypeSolver) HandleIdentifierRanges(ident *ast.Identifier) (ranges []*RangeInfo, rew ast.Expression) { typ, ok := ts.GetIdentifier(ident.Value) - if ok && (typ.Kind() == RangeKind || typ.Kind() == ArrayRangeKind) { + if ok && typ.Kind() == RangeKind { ri := &RangeInfo{ Name: ident.Value, RangeLit: nil, } ranges = []*RangeInfo{ri} + info := ts.ExprCache[key(ts.FuncNameMangled, ident)] + info.Ranges = append([]*RangeInfo(nil), ranges...) + info.Rewrite = ident } rew = ident return } +// HandleStringLiteralRanges exposes named Range dependencies hidden inside +// formatting markers so interpolation follows the same driver semantics as an +// ordinary identifier expression. +func (ts *TypeSolver) HandleStringLiteralRanges(lit *ast.StringLiteral) (ranges []*RangeInfo, rew ast.Expression) { + // A main marker formats its value, so a bare Range there stays a + // descriptor. Width and precision operands are consumed as numbers, which + // makes a named Range in a specifier an iteration driver. + _, specs := formatMarkerIdentifiers(lit.Token.Literal, ts.isDefined) + for _, name := range specs { + typ, ok := ts.GetIdentifier(name) + if !ok || typ.Kind() != RangeKind { + continue + } + ranges = mergeUses(ranges, []*RangeInfo{{Name: name}}) + } + + info := ts.ExprCache[key(ts.FuncNameMangled, lit)] + info.Ranges = append([]*RangeInfo(nil), ranges...) + info.HasRanges = len(ranges) > 0 + info.Rewrite = lit + return ranges, lit +} + +func rangeDriverNamed(ranges []*RangeInfo, name string) bool { + for _, ri := range ranges { + if ri.RangeLit == nil && ri.Name == name { + return true + } + } + return false +} + +// resolveBareRangeAssignment distinguishes Range descriptor copies from uses of +// a Range that an enclosing statement condition has already bound as an +// iterator. `copy = source` and `copy = 0:n` preserve the descriptor; in +// `filtered = source > 2 source`, the RHS reads the current scalar yield. +func (ts *TypeSolver) resolveBareRangeAssignment(expr ast.Expression, types []Type, condRanges []*RangeInfo) { + if len(types) != 1 { + return + } + + rangeType, ok := types[0].(Range) + if !ok { + return + } + + info := ts.ExprCache[key(ts.FuncNameMangled, expr)] + switch e := expr.(type) { + case *ast.Identifier: + if !rangeDriverNamed(condRanges, e.Value) { + info.Ranges = nil + info.HasRanges = false + info.Rewrite = nil + return + } + types[0] = rangeType.Iter + info.OutTypes[0] = rangeType.Iter + case *ast.RangeLiteral: + info.Ranges = nil + info.HasRanges = false + info.Rewrite = nil + } +} + func (ts *TypeSolver) TypeStatement(stmt ast.Statement) { switch s := stmt.(type) { case *ast.LetStatement: @@ -612,11 +750,8 @@ func (ts *TypeSolver) ensureScalarCallVariant(ce *ast.CallExpression) { } for _, t := range argInfo.OutTypes { innerType := t - switch t.Kind() { - case RangeKind: + if t.Kind() == RangeKind { innerType = t.(Range).Iter - case ArrayRangeKind: - innerType = t.(ArrayRange).Array.ElemType } scalarArgs = append(scalarArgs, innerType) } @@ -630,53 +765,64 @@ func (ts *TypeSolver) ensureScalarCallVariant(ce *ast.CallExpression) { } func (ts *TypeSolver) isRangeDriverCond(expr ast.Expression, condTypes []Type) bool { - return len(condTypes) == 1 && - ts.isBareRangeExpr(expr) && - isRangeDriverType(condTypes[0]) -} - -func (ts *TypeSolver) collectDriverRanges(expr ast.Expression, condTypes []Type) []*RangeInfo { - info := ts.ExprCache[key(ts.FuncNameMangled, expr)] - if len(info.Ranges) > 0 { - return info.Ranges + if len(condTypes) != 1 { + return false } - - // Non-driver conditions (for example, comparisons with no ranged operands) - // fall through here harmlessly: they contribute no loop driver ranges. - if !ts.isRangeDriverCond(expr, condTypes) { - return nil + info := ts.ExprCache[key(ts.FuncNameMangled, expr)] + if len(info.Ranges) == 0 { + return false } - ident, ok := expr.(*ast.Identifier) - if !ok { - panic(fmt.Sprintf("internal: bare range driver %T missing cached ranges", expr)) + switch e := expr.(type) { + case *ast.Identifier, *ast.RangeLiteral: + return true + case *ast.ArrayRangeExpression: + arrInfo := ts.ExprCache[key(ts.FuncNameMangled, e.Array)] + return !arrInfo.HasRanges && ts.isBareRangeExpr(e.Range) + default: + return false } - return []*RangeInfo{{Name: ident.Value}} } -// expressionCanFail reports whether a value-position expression can propagate -// a failed yield to its parent. Array cells resolve failures locally. A || can -// fail only when its final fallback can; other nodes propagate a root scalar -// comparison/&& or a failure from any child. -func (ts *TypeSolver) expressionCanFail(expr ast.Expression) bool { +// treeCanFail reports whether a value-position expression can propagate a +// failed yield to its parent, asking nodeFails to classify each node. The +// solver and the CFG pass different predicates but share this walk, so the two +// resolver boundaries cannot drift apart: an array literal settles a failed +// cell locally, and a || fails only when its final fallback does. +func treeCanFail(expr ast.Expression, nodeFails func(ast.Expression) bool) bool { if _, ok := expr.(*ast.ArrayLiteral); ok { return false } if infix, ok := ast.IsLogicalOr(expr); ok { - return ts.expressionCanFail(infix.Right) + return treeCanFail(infix.Right, nodeFails) } - info := ts.ExprCache[key(ts.FuncNameMangled, expr)] - if info != nil && (info.HasCondScalar() || info.HasCondAnd()) { + if nodeFails(expr) { return true } for _, child := range ast.ExprChildren(expr) { - if ts.expressionCanFail(child) { + if treeCanFail(child, nodeFails) { return true } } return false } +// conditionPropagates classifies one node for the solver: a root scalar +// comparison or gating &&. Conditions only, because this also decides which +// programs are valid — ||, && and statement conditions all require an operand +// that can fail — so anything that merely fails to yield at runtime, such as an +// out-of-bounds read, must not be folded in. +func (ts *TypeSolver) conditionPropagates(expr ast.Expression) bool { + info := ts.ExprCache[key(ts.FuncNameMangled, expr)] + // An invalid composite can stop typing before all descendants are cached; + // logical validation still walks that partial tree to report diagnostics. + return info != nil && (info.HasCondScalar() || info.HasCondAnd()) +} + +func (ts *TypeSolver) expressionCanFail(expr ast.Expression) bool { + return treeCanFail(expr, ts.conditionPropagates) +} + func (ts *TypeSolver) validateStatementCondition(expr ast.Expression, condTypes []Type) { // Defer until the operand types resolve (an unresolved cell may still become a // comparison during fixpoint iteration). @@ -699,7 +845,9 @@ func (ts *TypeSolver) validateStatementCondition(expr ast.Expression, condTypes return } - if ts.isRangeDriverCond(expr, condTypes) { + info := ts.ExprCache[key(ts.FuncNameMangled, expr)] + info.RangeDriverCond = ts.isRangeDriverCond(expr, condTypes) + if info.RangeDriverCond { return } @@ -712,7 +860,7 @@ func (ts *TypeSolver) validateStatementCondition(expr ast.Expression, condTypes // A condition that carries a comparison but can never fail (an unconditional // || fallback like `a > 0 || b`, which always yields b) does not gate. - if info := ts.ExprCache[key(ts.FuncNameMangled, expr)]; info != nil && info.HasCondExpr() { + if info.HasCondExpr() { ts.Errors = append(ts.Errors, &token.CompileError{ Token: expr.Tok(), Msg: "statement condition can never fail (its || fallback always yields a value)", @@ -722,7 +870,7 @@ func (ts *TypeSolver) validateStatementCondition(expr ast.Expression, condTypes ts.Errors = append(ts.Errors, &token.CompileError{ Token: expr.Tok(), - Msg: fmt.Sprintf("statement condition must be a comparison or bare range/array-range driver, got %s", condType), + Msg: fmt.Sprintf("statement condition must be a comparison or bare range/array-selection driver, got %s", condType), }) } @@ -734,49 +882,24 @@ func (ts *TypeSolver) collectConditionRanges(conditions []ast.Expression) []*Ran var ranges []*RangeInfo for _, expr := range conditions { info := ts.ExprCache[key(ts.FuncNameMangled, expr)] - ranges = mergeUses(ranges, ts.collectDriverRanges(expr, info.OutTypes)) + ranges = mergeUses(ranges, info.Ranges) } return ranges } // mergeCondRangesIntoValue merges condition ranges into a value expression's // ExprInfo so ranged statement conditions can drive per-iteration RHS lowering. -// Bare range-like values (identifiers, direct range literals, and bare -// array-range views) also merge their own ranges here so they scalarize to the -// iterator / element type only inside that outer ranged context. Outside it -// they remain Range / ArrayRange values. Array literals still control -// accumulation; non-literal values remain last-value-wins. -func (ts *TypeSolver) mergeCondRangesIntoValue(expr ast.Expression, exprTypes []Type, condRanges []*RangeInfo) { +// Bare Range assignments have already been classified as descriptor copies or +// reads of a driver bound by this statement. Array indexing is element-typed in +// every context. Array literals still control accumulation; non-literal values +// remain last-value-wins. +func (ts *TypeSolver) mergeCondRangesIntoValue(expr ast.Expression, condRanges []*RangeInfo) { if len(condRanges) == 0 { return } info := ts.ExprCache[key(ts.FuncNameMangled, expr)] - - merged := condRanges - // Bare range values become per-iteration scalars only when the statement - // condition already introduced outer iteration. Outside that context they - // remain Range / ArrayRange values. - if len(exprTypes) == 1 && ts.isBareRangeExpr(expr) { - selfRanges := info.Ranges - if ident, ok := expr.(*ast.Identifier); ok && exprTypes[0].Kind() == RangeKind { - selfRanges = []*RangeInfo{{Name: ident.Value}} - } - merged = mergeUses(condRanges, selfRanges) - - switch exprTypes[0].Kind() { - case RangeKind: - iterType := exprTypes[0].(Range).Iter - exprTypes[0] = iterType - info.OutTypes[0] = iterType - case ArrayRangeKind: - elemType := arrayIndexResultType(exprTypes[0].(ArrayRange).Array) - exprTypes[0] = elemType - info.OutTypes[0] = elemType - } - } - - info.Ranges = mergeUses(merged, info.Ranges) + info.Ranges = mergeUses(condRanges, info.Ranges) info.HasRanges = true } @@ -803,7 +926,8 @@ func (ts *TypeSolver) TypeLetStatement(stmt *ast.LetStatement) { exprIdxs := make([]int, 0, len(stmt.Name)) for _, expr := range stmt.Value { exprTypes := ts.TypeExpression(expr, true) - ts.mergeCondRangesIntoValue(expr, exprTypes, condRanges) + ts.resolveBareRangeAssignment(expr, exprTypes, condRanges) + ts.mergeCondRangesIntoValue(expr, condRanges) for idx := range exprTypes { types = append(types, exprTypes[idx]) exprRefs = append(exprRefs, expr) @@ -1424,7 +1548,7 @@ func (ts *TypeSolver) TypeRangeExpression(r *ast.RangeLiteral, isRoot bool) []Ty return types } -func (ts *TypeSolver) TypeArrayRangeExpression(ax *ast.ArrayRangeExpression, isRoot bool) []Type { +func (ts *TypeSolver) TypeArrayRangeExpression(ax *ast.ArrayRangeExpression, _ bool) []Type { info := &ExprInfo{OutTypes: []Type{Unresolved{}}, ExprLen: 1} ts.ExprCache[key(ts.FuncNameMangled, ax)] = info @@ -1441,7 +1565,9 @@ func (ts *TypeSolver) TypeArrayRangeExpression(ax *ast.ArrayRangeExpression, isR } resultType := arrayIndexResultType(arrType) - idxTypes := ts.TypeExpression(ax.Range, isRoot) + // Preserve the Range type long enough to validate the driver. The enclosing + // range rewrite later shadows it with a scalar index. + idxTypes := ts.TypeExpression(ax.Range, true) info.HasRanges = ts.ExprCache[key(ts.FuncNameMangled, ax.Array)].HasRanges || ts.ExprCache[key(ts.FuncNameMangled, ax.Range)].HasRanges if len(idxTypes) != 1 { ts.Errors = append(ts.Errors, &token.CompileError{ @@ -1472,41 +1598,20 @@ func (ts *TypeSolver) TypeArrayRangeExpression(ax *ast.ArrayRangeExpression, isR } } if idxType.Kind() == RangeKind { - if arrType.Rank > 1 { - ts.Errors = append(ts.Errors, &token.CompileError{ - Token: ax.Tok(), - Msg: "range indexing is currently supported only for rank-1 arrays", - }) - return info.OutTypes - } iterType := idxType.(Range).Iter if !TypeEqual(iterType, I64) { ts.Errors = append(ts.Errors, &token.CompileError{ Token: ax.Tok(), - Msg: fmt.Sprintf("array range index expects I64 iterator, got %s", iterType), + Msg: fmt.Sprintf("range-valued array index expects an I64 iterator, got %s", iterType), }) return info.OutTypes } } - if !isRoot { - // Nested indexing still removes one outer dimension. A range index is a - // rank-1 driver here because higher-rank ranges were rejected above. - info.OutTypes = []Type{resultType} - info.ExprLen = 1 - return info.OutTypes - } - - if idxType.Kind() == IntKind { - info.OutTypes = []Type{resultType} - info.ExprLen = 1 - return info.OutTypes - } - - info.OutTypes = []Type{ArrayRange{ - Array: arrType, - Range: idxType.(Range), - }} + // A ranged index is an ephemeral stream of resultType values, not a + // first-class view. Its surrounding context retains the final value, + // collects all values, or invokes a function once per yielded element. + info.OutTypes = []Type{resultType} info.ExprLen = 1 return info.OutTypes } @@ -1611,7 +1716,7 @@ func (ts *TypeSolver) TypeIdentifier(ident *ast.Identifier) (t Type) { return } - ts.ExprCache[key(ts.FuncNameMangled, ident)] = &ExprInfo{OutTypes: []Type{t}, ExprLen: 1, HasRanges: t.Kind() == RangeKind || t.Kind() == ArrayRangeKind} + ts.ExprCache[key(ts.FuncNameMangled, ident)] = &ExprInfo{OutTypes: []Type{t}, ExprLen: 1, HasRanges: t.Kind() == RangeKind} return } @@ -1759,7 +1864,7 @@ func (ts *TypeSolver) typeLogicalOrExpression(expr *ast.InfixExpression, left, r ts.ExprCache[key(ts.FuncNameMangled, expr)] = &ExprInfo{ OutTypes: types, ExprLen: len(types), - HasRanges: (leftInfo != nil && leftInfo.HasRanges) || (rightInfo != nil && rightInfo.HasRanges), + HasRanges: leftInfo.HasRanges || rightInfo.HasRanges, CompareModes: compareModes, } return types @@ -1820,7 +1925,7 @@ func (ts *TypeSolver) typeLogicalAndExpression(expr *ast.InfixExpression, left, ts.ExprCache[key(ts.FuncNameMangled, expr)] = &ExprInfo{ OutTypes: types, ExprLen: len(types), - HasRanges: (leftInfo != nil && leftInfo.HasRanges) || (rightInfo != nil && rightInfo.HasRanges), + HasRanges: leftInfo.HasRanges || rightInfo.HasRanges, CompareModes: compareModes, } return types @@ -1879,7 +1984,8 @@ func (ts *TypeSolver) TypeInfixExpression(expr *ast.InfixExpression) (types []Ty // with it (solver and compiler share the same ExprCache map). func treeHasLogicalCond(cache map[ExprKey]*ExprInfo, funcNameMangled string, expr ast.Expression) bool { if infix, ok := expr.(*ast.InfixExpression); ok && (infix.IsLogicalOr() || infix.IsLogicalAnd()) { - if info := cache[key(funcNameMangled, expr)]; info != nil && (info.HasFallbackOr() || info.HasCondAnd()) { + info := cache[key(funcNameMangled, expr)] + if info.HasFallbackOr() || info.HasCondAnd() { return true } } @@ -2125,8 +2231,6 @@ func (ts *TypeSolver) TypeCallExpression(ce *ast.CallExpression, isRoot bool) [] ts.ExprCache[key(ts.FuncNameMangled, ce)] = info args, innerArgs, loopInside := ts.collectCallArgs(ce, isRoot) - info.CallParamTypes = append([]Type(nil), args...) - info.ScalarCallParamTypes = append([]Type(nil), innerArgs...) // Compute hasRanges from all arguments hasRanges := false @@ -2136,6 +2240,14 @@ func (ts *TypeSolver) TypeCallExpression(ce *ast.CallExpression, isRoot bool) [] break } } + // Print has no callee body that can own iteration. Any driver arguments are + // expanded at the statement and printed as yielded scalar values. + if ce.Function.Value == Print { + loopInside = false + args = innerArgs + } + info.CallParamTypes = append([]Type(nil), args...) + info.ScalarCallParamTypes = append([]Type(nil), innerArgs...) // Handle builtins - no template lookup needed if builtin, ok := Builtins[ce.Function.Value]; ok { @@ -2178,6 +2290,14 @@ func (ts *TypeSolver) TypeExprsForIter(exprs []ast.Expression, isRoot bool) (out } } + // Reusing one driver in multiple parameters means those parameters advance + // together, not as a cartesian product. A callee specialization has one loop + // per Range/ArrayRange parameter, so keep this case at the caller where the + // shared RangeInfo is naturally deduplicated. + if loopInside && callArgsShareRangeDriver(exprs, ts.ExprCache, ts.FuncNameMangled) { + loopInside = false + } + if loopInside { return } @@ -2200,6 +2320,46 @@ func (ts *TypeSolver) TypeExprsForIter(exprs []ast.Expression, isRoot bool) (out return } +func callArgsShareRangeDriver(exprs []ast.Expression, cache map[ExprKey]*ExprInfo, funcNameMangled string) bool { + owner := make(map[string]int) + for argIndex, expr := range exprs { + info := cache[key(funcNameMangled, expr)] + for _, driver := range info.Ranges { + if previousArg, exists := owner[driver.Name]; exists && previousArg != argIndex { + return true + } + owner[driver.Name] = argIndex + } + } + return false +} + +// callScopedArrayRangeType returns the internal parameter type for a bare array +// selection and the yielded type seen by the function body. +func (ts *TypeSolver) callScopedArrayRangeType(expr ast.Expression) (ArrayRange, Type, bool) { + ax, ok := expr.(*ast.ArrayRangeExpression) + if !ok { + return ArrayRange{}, nil, false + } + + arrInfo := ts.ExprCache[key(ts.FuncNameMangled, ax.Array)] + idxInfo := ts.ExprCache[key(ts.FuncNameMangled, ax.Range)] + // An invalid array source can stop before its index is typed. + if idxInfo == nil { + return ArrayRange{}, nil, false + } + if arrInfo.HasRanges || len(arrInfo.OutTypes) != 1 || len(idxInfo.OutTypes) != 1 { + return ArrayRange{}, nil, false + } + + arrType, arrayOK := arrInfo.OutTypes[0].(Array) + rangeType, rangeOK := idxInfo.OutTypes[0].(Range) + if !arrayOK || !rangeOK || !ts.isBareRangeExpr(ax.Range) { + return ArrayRange{}, nil, false + } + return ArrayRange{Array: arrType, Range: rangeType}, arrayIndexResultType(arrType), true +} + // collectCallArgs types arguments and builds arg type lists for function lookup. // Uses the shared TypeExprsForIter for the core logic. func (ts *TypeSolver) collectCallArgs(ce *ast.CallExpression, isRoot bool) (args []Type, innerArgs []Type, loopInside bool) { @@ -2207,14 +2367,19 @@ func (ts *TypeSolver) collectCallArgs(ce *ast.CallExpression, isRoot bool) (args // Build args and innerArgs from outer types // If loopInside=false, ALL range args become their inner type (loop outside) - for _, outerTypes := range outerTypesPerArg { + for argIndex, outerTypes := range outerTypesPerArg { + if loopInside { + if arrayRangeType, yieldedType, ok := ts.callScopedArrayRangeType(ce.Arguments[argIndex]); ok { + args = append(args, arrayRangeType) + innerArgs = append(innerArgs, yieldedType) + continue + } + } + for _, outerType := range outerTypes { innerType := outerType - switch outerType.Kind() { - case RangeKind: + if outerType.Kind() == RangeKind { innerType = outerType.(Range).Iter - case ArrayRangeKind: - innerType = outerType.(ArrayRange).Array.ElemType } innerArgs = append(innerArgs, innerType) @@ -2228,43 +2393,6 @@ func (ts *TypeSolver) collectCallArgs(ce *ast.CallExpression, isRoot bool) (args return } -/* -// getInnerType returns the type that operations work with when given a Range/ArrayRange. -// Range → Range.Iter, ArrayRange → element type, other → unchanged - - func getInnerType(t Type) Type { - switch t.Kind() { - case RangeKind: - return t.(Range).Iter - case ArrayRangeKind: - return t.(ArrayRange).Array.ElemType - default: - return t - } - } - - func (ts *TypeSolver) appendStandardCallArg(arg Type, args *[]Type, innerArgs *[]Type, hasIter *bool) { - var paramType Type - switch arg.Kind() { - case RangeKind: - paramType = arg - *innerArgs = append(*innerArgs, getInnerType(arg)) - *hasIter = true - case ArrayRangeKind: - arrRange := arg.(ArrayRange) - paramType = arrRange - // Like Range parameters, ArrayRange parameters are passed as-is to the function. - // The function will handle iteration internally via funcLoopNest. - // We pass the element type as innerArgs so the function body is typed correctly. - *innerArgs = append(*innerArgs, getInnerType(arg)) - *hasIter = true - default: - paramType = arg - *innerArgs = append(*innerArgs, arg) - } - *args = append(*args, paramType) - } -*/ func (ts *TypeSolver) expectSingleArray(source ast.Expression, tok token.Token, context string) (Array, bool) { arrayTypes := ts.TypeExpression(source, false) // nested expression if len(arrayTypes) != 1 { diff --git a/compiler/solver_test.go b/compiler/solver_test.go index bf910e1c..289b2dc5 100644 --- a/compiler/solver_test.go +++ b/compiler/solver_test.go @@ -179,10 +179,10 @@ y` func TestTypeStructLiteralCanonicalizesToSchema(t *testing.T) { code := mustParseCode(t, `p = Person - :name age height + : name age height "Tejas" 35 184.5 q = Person - :age + : age 28 r = Person`) @@ -212,7 +212,7 @@ r = Person`) func TestTypeStructLiteralValidatesAgainstCanonicalSchema(t *testing.T) { code := mustParseCode(t, `p = Person - :name age + : name age "Tejas" 35`) ctx := llvm.NewContext() @@ -241,7 +241,7 @@ func TestTypeStructLiteralValidatesAgainstCanonicalSchema(t *testing.T) { func TestTypeStructLiteralWidensStringFieldsFromValues(t *testing.T) { code := mustParseCode(t, `p = Person - :name age + : name age "Tejas" 35`) ctx := llvm.NewContext() @@ -348,11 +348,6 @@ func TestCollectionTypeErrors(t *testing.T) { script: "flat = [1 2]\nnested = [[3 4] [5 6]]\njoined = flat ⊕ nested\njoined", expectError: "cannot concatenate arrays with different ranks: 1 and 2", }, - { - name: "RangeIndexOnRank2", - script: "m = [\n 1 2\n 3 4\n]\nsub = m[0:2]\nsub", - expectError: "range indexing is currently supported only for rank-1 arrays", - }, } for _, tc := range cases { @@ -789,7 +784,7 @@ func TestScalarConditionEmitsTypeDiagnostic(t *testing.T) { ts.Solve() require.Len(t, ts.Errors, 1, "scalar-valued statement condition should emit one diagnostic") - require.Contains(t, ts.Errors[0].Msg, "statement condition must be a comparison or bare range/array-range driver, got I64") + require.Contains(t, ts.Errors[0].Msg, "statement condition must be a comparison or bare range/array-selection driver, got I64") } func TestLogicalAndDiagnostics(t *testing.T) { @@ -1013,14 +1008,59 @@ res = [idx]` require.IsType(t, &ast.ArrayLiteral{}, info.Rewrite) } -func TestArrayRangeTyping(t *testing.T) { +func TestBareRangeAssignmentsCopyDescriptors(t *testing.T) { + ctx := llvm.NewContext() + cc := NewCodeCompiler(ctx, "bareRangeCopies", "", ast.NewCode()) + program := mustParseScript(t, `source = 0:5 +copy = (source) +last = source + 0 +outer = 0:2 +gatedCopy = outer < 2 source +filtered = source > 2 source`) + + sc := NewScriptCompiler(ctx, program, cc, make(map[string]*Func), make(map[ExprKey]*ExprInfo)) + ts := NewTypeSolver(sc) + ts.Solve() + require.Emptyf(t, ts.Errors, "unexpected type errors: %v", ts.Errors) + + for _, name := range []string{"source", "copy", "gatedCopy"} { + typ, ok := ts.GetIdentifier(name) + require.Truef(t, ok, "expected %s binding", name) + require.Equal(t, Range{Iter: I64}, typ) + } + for _, name := range []string{"last", "filtered"} { + typ, ok := ts.GetIdentifier(name) + require.Truef(t, ok, "expected %s binding", name) + require.Equal(t, I64, typ) + } + + copyExpr := program.Statements[1].(*ast.LetStatement).Value[0] + copyInfo := ts.ExprCache[key("", copyExpr)] + require.False(t, copyInfo.HasRanges) + require.Empty(t, copyInfo.Ranges) + require.Nil(t, copyInfo.Rewrite) + + gatedCopyExpr := program.Statements[4].(*ast.LetStatement).Value[0] + gatedCopyInfo := ts.ExprCache[key("", gatedCopyExpr)] + require.Equal(t, []Type{Range{Iter: I64}}, gatedCopyInfo.OutTypes) + require.Len(t, gatedCopyInfo.Ranges, 1) + require.Equal(t, "outer", gatedCopyInfo.Ranges[0].Name) + + filteredExpr := program.Statements[5].(*ast.LetStatement).Value[0] + filteredInfo := ts.ExprCache[key("", filteredExpr)] + require.Equal(t, []Type{I64}, filteredInfo.OutTypes) + require.Len(t, filteredInfo.Ranges, 1) + require.Equal(t, "source", filteredInfo.Ranges[0].Name) +} + +func TestRangedArrayAccessTypesAsElementStream(t *testing.T) { ctx := llvm.NewContext() code := ast.NewCode() - cc := NewCodeCompiler(ctx, "arrayRangeTyping", "", code) + cc := NewCodeCompiler(ctx, "rangedArrayAccessTyping", "", code) cc.Compile() script := "arr = [1 2 3]\nvalue = arr[0:2]\nsum = 0\nsum = sum + arr[0:2]" - sl := lexer.New("ArrayRangeTyping.spt", script) + sl := lexer.New("RangedArrayAccessTyping.spt", script) sp := parser.NewScriptParser(sl) program := sp.Parse() require.Empty(t, sp.Errors(), "unexpected parse errors: %v", sp.Errors()) @@ -1033,10 +1073,15 @@ func TestArrayRangeTyping(t *testing.T) { valueType, ok := ts.GetIdentifier("value") require.True(t, ok, "expected value identifier") - value, ok := valueType.(ArrayRange) - require.Truef(t, ok, "expected value to be ArrayRange, got %T", valueType) - require.EqualValues(t, value.Array.ElemType, Int{Width: 64}) - require.EqualValues(t, value.Range, Range{Iter: Int{Width: 64}}) + value, ok := valueType.(Int) + require.Truef(t, ok, "expected ranged access to finalize as Int, got %T", valueType) + require.EqualValues(t, 64, value.Width) + + valueStmt := program.Statements[1].(*ast.LetStatement) + valueExpr := valueStmt.Value[0].(*ast.ArrayRangeExpression) + valueInfo := ts.ExprCache[key(ts.FuncNameMangled, valueExpr)] + require.Equal(t, []Type{Int{Width: 64}}, valueInfo.OutTypes) + require.Len(t, valueInfo.Ranges, 1) sumType, ok := ts.GetIdentifier("sum") require.True(t, ok, "expected sum identifier") @@ -1045,6 +1090,47 @@ func TestArrayRangeTyping(t *testing.T) { require.EqualValues(t, 64, sumInt.Width) } +func TestImmediateArraySelectionUsesCallScopedArrayRange(t *testing.T) { + ctx := llvm.NewContext() + code := mustParseCode(t, `out = Identity(x) + out = x`) + cc := NewCodeCompiler(ctx, "callScopedArrayRange", "", code) + require.Empty(t, cc.Compile()) + + program := mustParseScript(t, `i = 0:2 +arr = [1 2 3] +value = Identity(arr[i]) +arr[i]`) + sc := NewScriptCompiler(ctx, program, cc, make(map[string]*Func), make(map[ExprKey]*ExprInfo)) + ts := NewTypeSolver(sc) + ts.Solve() + require.Emptyf(t, ts.Errors, "unexpected type errors: %v", ts.Errors) + + valueStmt := program.Statements[2].(*ast.LetStatement) + call := valueStmt.Value[0].(*ast.CallExpression) + callInfo := ts.ExprCache[key("", call)] + require.True(t, callInfo.LoopInside) + require.Equal(t, []Type{I64}, callInfo.ScalarCallParamTypes) + require.Len(t, callInfo.CallParamTypes, 1) + + arrayRange, ok := callInfo.CallParamTypes[0].(ArrayRange) + require.Truef(t, ok, "expected call-only ArrayRange, got %T", callInfo.CallParamTypes[0]) + require.Equal(t, Array{ElemType: I64, Rank: 1}, arrayRange.Array) + require.Equal(t, Range{Iter: I64}, arrayRange.Range) + + selection := call.Arguments[0].(*ast.ArrayRangeExpression) + require.Equal(t, []Type{I64}, ts.ExprCache[key("", selection)].OutTypes, + "the source expression must remain element-typed outside the call ABI") + valueType, ok := ts.GetIdentifier("value") + require.True(t, ok) + require.Equal(t, I64, valueType) + + printCall := program.Statements[3].(*ast.PrintStatement).Expression + printInfo := ts.ExprCache[key("", printCall)] + require.False(t, printInfo.LoopInside, "print must consume the selection at the caller") + require.Equal(t, []Type{I64}, printInfo.CallParamTypes) +} + func TestArrayIndexRejectsI1(t *testing.T) { ctx := llvm.NewContext() cc := NewCodeCompiler(ctx, "arrayIndexI1", "", ast.NewCode()) @@ -1114,7 +1200,7 @@ func TestArrayRangeIndexRequiresI64Iter(t *testing.T) { found := false for _, err := range ts.Errors { - if strings.Contains(err.Msg, "array range index expects I64 iterator") { + if strings.Contains(err.Msg, "range-valued array index expects an I64 iterator") { found = true break } diff --git a/compiler/types.go b/compiler/types.go index 3eddc062..70b167fe 100644 --- a/compiler/types.go +++ b/compiler/types.go @@ -403,9 +403,9 @@ func isHeaderOnlyTableType(table Table) bool { return true } -// ArrayRange represents an iteration over a range of an array. -// It carries the underlying array schema so type comparisons and mangling -// can remain structural; the actual range bounds are runtime values. +// ArrayRange is an internal, call-scoped view of an array selection. It keeps +// the full source array and range schemas so specialization identity remains +// structural without exposing ArrayRange as a source-level storable type. type ArrayRange struct { Array Array Range Range @@ -418,8 +418,9 @@ func (ar ArrayRange) String() string { func (ar ArrayRange) Kind() Kind { return ArrayRangeKind } func (ar ArrayRange) Mangle() string { - return "ArrayRange" + SEP + T + "1" + SEP + ar.Array.ElemType.Mangle() + return "ArrayRange" + SEP + T + "2" + SEP + ar.Array.Mangle() + SEP + ar.Range.Mangle() } + func (ar ArrayRange) Key() Type { return ArrayRange{ Array: ar.Array.Key().(Array), diff --git a/docs/Pluto ABI Optimization Plan.md b/docs/Pluto ABI Optimization Plan.md index 40dfbf85..0fb2d699 100644 --- a/docs/Pluto ABI Optimization Plan.md +++ b/docs/Pluto ABI Optimization Plan.md @@ -60,13 +60,22 @@ Concretely, it hooks between `TypeLetStatement` / `TypeExpression` (which resolv ### 3.3 Internal vs external ABI -| | Internal (Pluto-to-Pluto) | External (C callers) | -| -------------- | ------------------------------------- | ------------------------ | -| **Convention** | Classified ABI (direct scalars, etc.) | Current all-pointer ABI | -| **Stability** | Can change between compiler versions | Stable, documented | -| **Migration** | Transparent to Pluto code | Wrapper thunks if needed | - -Name mangling encodes semantic types, not physical ABI. Changing `I64` from pointer-passed to value-passed does not require a mangling change. But the binary calling convention does change, so external callers need ABI wrappers or versioning. +The current compiler emits one classified mangled entry point used by both +Pluto calls and direct C callers. There is not yet a separate stable wrapper: + +| Entry point | Consumers | Convention | Stability | +| ---------------------- | ----------- | ------------------------- | ----------------------------------------------- | +| Current mangled symbol | Pluto and C | Documented classified ABI | Write-effect-independent within the ABI version | +| Future private clone | Pluto only | Internally optimized | May vary freely | +| Future public wrapper | C | Documented wrapper ABI | Stable or explicitly versioned | + +Name mangling encodes semantic types, not physical ABI. Changing `I64` from +pointer-passed to value-passed therefore does not change the name, even though +it changes the C prototype. Until wrappers exist, every physical ABI decision +on the current symbol must be derived from its solved signature—not output write +effects in its body or the bodies of callees. Because return types are inferred +and not mangled, a body edit that changes the solved parameter/output signature +is still an ABI-breaking source change even though the symbol name is unchanged. ## 4. Implementation Phases @@ -76,8 +85,22 @@ Direct lowering for scalar numeric inputs and single scalar outputs. - pass `I64`/`F64` by value instead of by pointer - return single scalar in register instead of via `sret` -- keep function-body semantics stable by spilling direct scalar params into local addressable slots in the callee -- preserve range-bearing accumulator / empty-range behavior with hidden alias/seed state where needed +- keep direct scalar params as SSA values in function scope, materializing + addressable slots only when semantically required +- give every direct scalar return a final hidden destination seed, preserving + skipped conditional writes and empty-range behavior without making the + physical signature depend on the function body +- preserve range-bearing accumulator behavior with additional hidden alias + selectors where needed + +`MustWrite`/`MayWrite` has limited utility at the public boundary and must not +decide whether the seed parameter exists. Adding one conditional output write +to a function body—or making a reachable output-producing callee conditional— +could otherwise change the C prototype without changing the type-based mangled +name. Previously compiled C callers would then invoke the same symbol with the +wrong argument list. Write-effect information may still eliminate seed use +inside Pluto code; a seedless fast path needs a distinctly named private clone +behind the stable public entry point. This was the highest-value initial optimization because it benefits all scalar-heavy code, not just specific patterns. It reduces stack traffic, simplifies IR, and materially improved `fib`, `fib_tail`, and `harmonic`. @@ -108,7 +131,9 @@ Broaden to more scalar types, small direct aggregates in both params and results ### Phase 5: External ABI wrappers -Once internal ABI is stable, decide whether exported symbols keep the current C ABI (add wrappers) or version the ABI docs explicitly. +Add stable/versioned public wrappers if private Pluto-only entry points begin +using more aggressive conventions. Until then, the documented classified +mangled symbol remains the C boundary. ### Parallel track: non-ABI loop/codegen work @@ -160,7 +185,8 @@ Each phase should: 1. **Feature-flag the new ABI** — compile both old and new paths, compare program behavior and test results (not raw IR, which will differ by design) 2. **Run the full test suite** (`python3 test.py --leak-check`) against both paths 3. **Benchmark before/after** using `bench/` suite to validate the expected gains -4. **Merge internal ABI first** — external wrappers come later (Phase 5) +4. **Keep public symbols write-effect-independent** — private ABI experiments + require distinct symbols until external wrappers arrive in Phase 5 ## 6. Practical Recommendation diff --git a/docs/Pluto Array Semantics.md b/docs/Pluto Array Semantics.md index 084ba88b..a6f5106c 100644 --- a/docs/Pluto Array Semantics.md +++ b/docs/Pluto Array Semantics.md @@ -137,17 +137,35 @@ cube[1][2] # rank 1 cube[1][2][0] # scalar ``` -A range-valued index is an iteration driver, not a materialized slice. Wrap -the access in `[]` to collect its results. For a rank-2 array, this stacks the -selected rows into another rank-2 array: +A range-valued index is an iteration driver, not a slice or view value. +An assignment root keeps the final valid selected element or subarray; wrap +the access in `[]` to collect all selected values: ```pluto i = 0:2 -selected = [matrix[i]] +last = vector[i] +selected = [vector[i]] + +lastRow = matrix[i] +selectedRows = [matrix[i]] ``` -Deferred nested range construction also uses chained indexing and is specified -in [Pluto Range Semantics](Pluto%20Range%20Semantics.md#deferred-nested-range-construction). +`last` is the final selected element, while `selected` contains every element. +For higher-rank arrays, `lastRow` owns the final selected row and +`selectedRows` stacks every selected row. +Range-indexed access cannot be stored or printed as an internal view; it must +be consumed, finalized, or collected. + +An immediate bare `array[range]` function argument may be consumed by a +specialized callee. The compiler can carry the array and range in an internal, +call-scoped descriptor and perform the iteration there. This descriptor is not +a source-level value and cannot be stored, returned, printed, or otherwise +escape the call. + +Planned deferred nested range construction also uses chained indexing and is +specified in +[Pluto Range Semantics](Pluto%20Range%20Semantics.md#deferred-nested-range-construction); +it remains deferred until PIR represents those scopes directly. Array-scalar operations preserve shape. Array-array element-wise operations require equal rank and zip every dimension to the shorter corresponding diff --git a/docs/Pluto C ABI Spec.md b/docs/Pluto C ABI Spec.md index 69054baa..9c8a0e57 100644 --- a/docs/Pluto C ABI Spec.md +++ b/docs/Pluto C ABI Spec.md @@ -205,8 +205,8 @@ Built-in compound types use the `_tN_` pattern: | Range | `Range_t1_[Iter]` | `Range_t1_I64` | | Array | `Array_t1_[Elem]` | `Array_t1_I64` | | Rank-N Array | repeated `Array_t1_` | `Array_t1_Array_t1_F64` | +| Internal ArrayRange | `ArrayRange_t2_[Array]_[Range]` | `ArrayRange_t2_Array_t1_I64_Range_t1_I64` | | Table | `Table_t2N_[EncodedName Elem]...` | `Table_t4_5nName_StrH_6nScore_I64` | -| ArrayRange | `ArrayRange_t1_[Elem]` | `ArrayRange_t1_I64` | | Function | `Func_tN_[ParamTypes...]` | `Func_t2_I64_F64` | **Note:** Function types only mangle parameter types; return types are NOT included (per §2.4 Arity rules). @@ -220,6 +220,56 @@ arrays lower as `{ data, dim0, ..., dim(N-1) }`, where `data` is one flat row-ma runtime vector. Dimension lengths are runtime values and do not participate in type mangling. +An `ArrayRange` is an internal, call-scoped descriptor used when an immediate +bare `array[range]` argument is specialized for callee-side iteration. Its two +type arguments are the full array type and the full range type: +`ArrayRange_t2__`. Both are part of the +mangle, so array rank, array element type, and range iterator type cannot +collide. For example, a rank-2 I64 array indexed by an I64 range mangles as: + +``` +ArrayRange_t2_Array_t1_Array_t1_I64_Range_t1_I64 +``` + +`ArrayRange` is not a source-level type. It cannot be bound to a variable, +stored, returned, printed, or otherwise escape the call that created it. The +function body observes one yielded element or owned subarray per iteration, +not the descriptor itself. + +At the native boundary the descriptor is passed indirectly as a pointer to +`{ , }`. For example, the following +illustrative C declarations show the rank-1 and rank-2 I64 layouts (the +emitted LLVM structs are structural rather than named with these C names): + +```c +typedef struct { + int64_t start; + int64_t stop; + int64_t step; +} PtRangeI64; + +typedef struct { + PtArrayI64 *array; + PtRangeI64 range; +} PtArrayRangeI64Rank1; + +typedef struct { + PtArrayI64 *data; + int64_t dim0; + int64_t dim1; +} PtArrayI64Rank2; + +typedef struct { + PtArrayI64Rank2 array; + PtRangeI64 range; +} PtArrayRangeI64Rank2; +``` + +The descriptor occupies the ordinary source-parameter position. An indirect +result carrier, when present, comes first; all source parameters follow in +source order; hidden alias selectors follow them; and a hidden direct-return +seed is last. + --- ## 4. Examples @@ -258,18 +308,102 @@ Module: `github.com/user/math`, RelPath: `stats/integral` ## 5. Calling Convention -* **SRET:** All functions return `void`. Return via pointer at arg 0. -* **Pass by reference:** All arguments (including primitives) are pointers. -* **Methods:** `self` is arg 1 (after SRET). +The native calling convention is selected from the solved parameter and output +types: + +- `I64` and `F64` parameters are passed directly. Ranges, internal + `ArrayRange` descriptors, and other values are passed indirectly. +- A function with exactly one `I64` or `F64` output returns that scalar + directly and receives one hidden seed value. The seed preserves the caller's + staged value when the callee does not write its output, including a failed + conditional assignment or an empty `Range`/internal `ArrayRange`. +- All other output lists use an indirect `void` return. Argument zero points + to a carrier whose first `N` fields are output pointers and whose next `N` + fields are pointers to `i1` write markers. +- The caller initializes every write marker to false. A callee sets the marker + only when that output is actually written. This lets an empty range or + skipped conditional preserve an existing caller destination. +- Output expressions are staged independently at the call site, so one output + cannot mutate a destination before a sibling right-hand side reads its + statement-start value. +- When a compatible caller destination has a different ownership or shape + representation from the declared output, the ABI slot starts at the declared + type's zero value. The caller commits it only if its write marker is set. + +The direct-return seed is always present, even when the function body +unconditionally overwrites its output. Schematically, with mangled names +abbreviated: ```c -void Pt_..._6Person_m_5Clone_f2_..._6Person_I64( - Person* ret, // 0: SRET - Person* self, // 1: self - I64* count // 2: argument +int64_t Pt_Square_I64(int64_t x, int64_t seed); +int64_t Pt_ConditionalSquare_I64(int64_t x, int64_t seed); +int64_t Pt_Acc_I64_Range( + int64_t a, + const PtRangeI64 *range, + int32_t a_output_alias, + int64_t seed ); ``` +A C caller passes the destination's current value to request Pluto's keep-old +semantics, or the output type's zero value for a fresh destination. The seed is +`noundef` and must still be supplied to a function such as `Square` that does +not inspect it. Whether the body must write or may skip a write never changes +the public signature or mangled name. In particular, adding a conditional +assignment—or changing a reachable output-producing callee from must-write to +may-write—cannot alter the prototype of an existing symbol. A future seedless +internal fast path must therefore use a distinct private symbol behind this +stable boundary. + +Conceptually, a two-output indirect call uses: + +```c +struct Results { + T0 *out0; + T1 *out1; + bool *wrote0; + bool *wrote1; +}; + +void Pt_example(Results *results, I64 direct_arg, Other *indirect_arg); +``` + +Range-bearing variants may also receive hidden alias selectors for direct +scalar parameters that refer to an output destination. These preserve +loop-carried accumulation without changing the source signature or mangled +specialization identity. They do change the native C signature. +For compatible indirect parameters, the caller instead passes the matching +staged output pointer itself, so the callee observes the same loop-carried +value without another hidden parameter. +Hidden ABI fields and parameters are not part of name mangling. + +An eligible immediate bare `array[range]` call argument may therefore select +an `ArrayRange` specialization and run its loop inside the callee. This +placement does not change source semantics. Calls that reuse one driver in +multiple argument positions, such as `F(i, i)`, must preserve one shared +iteration domain; the current lowering handles that case caller-side. Distinct +drivers, such as `F(i, j)`, retain their cartesian domain. + +### 5.1 Reserved Collector Specialization Suffix + +The following suffix is reserved for a possible future specialization that +writes each yielded result directly into one or more collectors: + +``` +_cN_ +``` + +`N` is the number of item types that follow. Examples are `_c1_I64`, +`_c1_Array_t1_I64`, and `_c2_I64_F64`. The suffix would follow the ordinary +function specialization mangle and includes the full type of every collected +item. + +This suffix is reserved only. The current compiler does not emit collector +specializations, and no collector ABI is implemented. If that ABI is added, a +collector for an item type `T` will be passed as `PtArrayT *` in the final +native parameter position; this statement reserves the position but does not +make it part of the current calling convention. + --- ## 6. Grammar diff --git a/docs/Pluto Conditional Value Semantics.md b/docs/Pluto Conditional Value Semantics.md index 99bb55d7..1fd65f16 100644 --- a/docs/Pluto Conditional Value Semantics.md +++ b/docs/Pluto Conditional Value Semantics.md @@ -329,9 +329,8 @@ old), sibling expressions in one statement commit independently (`a, b = arr[oob], 5` keeps `a`, sets `b`), a call merges its lanes (an OOB argument keeps that call's outputs old as a unit), comparisons and `||` fallbacks fed by an OOB read keep old rather than judging a fabricated zero, -and an unevaluated `||` right side cannot fail anything. The one place OOB -still reads as `0` is a **collector cell over an explicit range** — the -documented full-control opt-in. +and an unevaluated `||` right side cannot fail anything. Inside any collector +or fixed-array cell, an OOB read zero-fills that cell to preserve shape. ## Why this model diff --git a/docs/Pluto IR Plan.md b/docs/Pluto IR Plan.md index 2278ffc1..21234537 100644 --- a/docs/Pluto IR Plan.md +++ b/docs/Pluto IR Plan.md @@ -55,6 +55,39 @@ before the final commit; for example, `x = arr[i] > 0 || 0` is `MustWrite`, whil `x = arr[i] > 0 || other[j] > 0` remains `MayWrite`. This matches the compiler's existing solver-then-CFG order, so migration requires no pass reordering. +Inside a function body, range domain ownership decides which statements an +empty domain can skip. A Range argument establishes a function-level domain +whose yielded values drive the whole body, so its possibly-empty domain +contributes one shared effect at the function boundary rather than making every +statement that reads the parameter independently conditional. A locally +created range owns only the statements it drives, so its empty domain suspends +exactly those slots. +Template-time CFG has neither distinction — it misreports a body like +`i = 0:n` / `y = 10` / `y = i + 1` as a dead store — and typed effects +computed once per mangled specialization are what resolve it. Any such +per-specialization cache must bundle write effects, binding types, and +validation results atomically: FuncCache is already shared across the scripts +of one run while BindingTypes is rebuilt per script, and that split is exactly +what produced issue #71's compile-order-dependent wrong output. + +Output spans, unlike write effects, are structural before any typing: a call +site must consume exactly `len(callee.Outputs)` destinations, and that arity +is fixed by the template declaration, so template analysis can place spans for +direct calls and single-value expressions in mixed statements like +`a, b, c = MaybePair(x), 5` — the literal's slot is a definite write even +while the call's slots stay conditional. Only shapes whose slot count +genuinely needs types, such as multi-slot value-position comparisons, keep the +all-conditional fallback. + +This per-target `WriteEffect` is not a public function-ABI classifier. Every +exported direct `I64`/`F64` return keeps its final hidden seed parameter. +Collapsing statement effects into a function-level `MustWrite`/`MayWrite` +summary may optimize internal seed use, but it cannot add or remove that +parameter: a local conditional or a newly conditional output-producing callee +would otherwise change the C prototype of the same type-mangled symbol after a +body-only edit. A seedless variant requires a distinctly named private clone +behind the stable entry point. + PIR may refer to solved AST expressions, but LLVM lowering must not reclassify their range, conditional, OOB, collector, affine, or commit behavior. @@ -708,8 +741,8 @@ long-lived parallel path. - mixed RHS expressions produce effects aligned per LHS slot, such as `[]WriteEffect{MayWrite, MustWrite}` for `a, b = arr[i], i + 1` - shared conditions and possibly empty ranges produce `MayWrite` for keep-old or - unresolved last-yield targets, while an unconditional collector or zero-fill - closing policy can still produce `MustWrite` + unresolved last-yield targets, while an ungated collector or cell-local + zero-fill closing policy can still produce `MustWrite` - a fallback that resolves every conditional or checked-access failure produces `MustWrite` - a fallback whose final alternative can still fail remains `MayWrite` diff --git a/docs/Pluto Memory Model.md b/docs/Pluto Memory Model.md index 3daea8a1..4c267e46 100644 --- a/docs/Pluto Memory Model.md +++ b/docs/Pluto Memory Model.md @@ -4,14 +4,23 @@ This document describes Pluto's semantic model and compares it with other major ## The Pluto Model (Summary) -1. **Assignment is Copy:** `a = b` creates a new independent value (Snapshot). +1. **Materialized Assignment is Copy:** assigning a scalar, Range descriptor, + array, table, string, or struct creates an independent value. 2. **Arrays are Values:** `arr2 = arr1` copies data (COW). -3. **Range Selections are Views:** `s = arr[i]` (where `i` is a Range) - borrows `arr`; `[]` materializes the selection. -4. **Ranges are Loop Syntax:** `x = i + 1` generates a loop, not a lazy type. -5. **Zero-Value Initialization:** Variables in range expressions auto-initialize to zero. -6. **IterName Determines Looping:** Same range variable → zip, different → cartesian. -7. **Function Arguments by Value:** Scalar parameters passed by value, outputs initialized from parameters. +3. **Range Selections are Streams:** `s = arr[i]` keeps the final selected + value (an element or owned subarray); `s = [arr[i]]` materializes every + selected value. +4. **Ranges are Descriptor Values:** `j = i` copies a Range; consuming it in + `x = i + 1`, `arr[i]`, a call, or `[]` drives a loop. Print and main + interpolation markers format the descriptor itself. +5. **Empty-Domain Initialization:** An empty Range descriptor still assigns. + An empty ranged computation leaves an existing destination unchanged and a + fresh destination at its type's zero value. +6. **Driver Identity Determines Looping:** Repeated use of one Range binding + shares a loop; distinct bindings form a cartesian domain even when their + descriptors have equal bounds. +7. **Function Arguments by Value:** Scalar parameters are passed by value; + outputs write into caller destination slots. 8. **Function Locking:** Input arguments hold read locks, outputs hold write locks (automatic concurrency safety). 9. **Memory Management:** Automatic scope-based deallocation (no GC pauses). @@ -24,8 +33,8 @@ This document describes Pluto's semantic model and compares it with other major | **Assignment (`a=b`)** | **Copy** | Reference | Move / Copy | Copy | Reference | Copy | | **Array Assign** | **Copy** (COW) | Reference | Move | Reference (Slice) | Reference | Copy | | **Function Args** | **Value** (Scalars) | Reference | Move / Borrow | Copy (Slice Ref) | Reference | Copy | -| **Range selection (`a[range]`)** | **Borrowed view** | Copy (List) / View (NumPy) | View (Slice) | View (Slice) | Copy (default) / View (`@view`) | View (Slice) | -| **Range Usage** | **Loop Syntax** (Immediate) | Reference (Generator) | Reference (Iterator) | N/A | Reference (Iterator) | N/A | +| **Range selection (`a[range]`)** | **Value stream** (final value or explicit collection) | Copy (List) / View (NumPy) | View (Slice) | View (Slice) | Copy (default) / View (`@view`) | View (Slice) | +| **Range Usage** | **Copyable descriptor; operations iterate** | Reference (Generator) | Reference (Iterator) | N/A | Reference (Iterator) | N/A | | **Mutability** | **In-Place Only** | Mutable Objects | Mutable (if `mut`) | Mutable | Mutable | Mutable | | **Memory Mgmt** | **Auto (Scope)** | Auto (GC) | Auto (Owner) | Auto (GC) | Auto (GC) | Manual | @@ -48,12 +57,16 @@ x = (i+1 for i in iter) # Lazy generator a = [1]; b = a; a[0] = 2 # b sees 1 (independent copy) i = 0:5 +j = i # Descriptor copy; no loop +x = i + 0 # Loop executes, x = 4 (last yield) x = i + 1 # Loop executes, x = 5 (last value) -i = 0:10 # OK! Ranges execute immediately -y = i + 1 # New loop, y = 10 +i = 0:10 # Bind a new reusable Range domain +y = i + 1 # Consuming statement runs the loop; y = 10 ``` -**Difference:** Pluto is safer and more predictable. Ranges execute immediately as loops, not lazy generators. +**Difference:** Pluto is safer and more predictable. A range literal binds a +reusable descriptor. A bare assignment copies it; a consuming expression runs +it as a loop rather than creating a lazy generator. --- @@ -66,14 +79,18 @@ let a = vec![1]; let b = a; // a is MOVED (invalidated) let s = &a[..]; // Borrow checking prevents mutation ``` -**Pluto:** "Copy on Write." +**Pluto:** "Values + Explicit Collection." ```python a = [1]; b = a # Both valid and independent -s = arr[i] # Runtime/Compiler checks ownership scope +i = 0:3 +x = arr[i] # Final selected element +s = [arr[i]] # Independent materialized array ``` -**Difference:** Pluto is easier to use (no borrow checker fighting) but relies on COW optimization instead of static moves. +**Difference:** Pluto does not expose a borrowed range-selection value, so the +selection cannot outlive its source. Materialized arrays use value semantics +and may use COW internally. --- @@ -89,15 +106,16 @@ s := arr[0:2] s[0] = 99 // Mutates arr via s ``` -**Pluto:** "Arrays are Values, Range Selections are Views with Locking." +**Pluto:** "Arrays are Values, Range Selections are Streams." ```python arr = [1 2 3 4 5] -s = arr[0:2] # View (like Go) -s[0] = 99 # Mutates arr +last = arr[0:2] # 2 +s = [arr[0:2]] # [1 2], independent of arr ``` -**Difference:** Pluto enforces **Read/Write Locks** on function arguments automatically, whereas Go allows data races (user must use `sync.Mutex`). +**Difference:** Pluto does not expose slice aliasing through range indexing. +Collected selections are ordinary array values. --- @@ -111,11 +129,12 @@ a[1:5] # Copy by default @view a[1:5] # View (explicit) ``` -**Pluto:** A range-valued `arr[i]` creates an `ArrayRange` view. Wrapping the -access as `[arr[i]]` materializes the selected values. +**Pluto:** A range-valued `arr[i]` is a value stream. At an assignment root it +keeps the final valid element or owned subarray; `[arr[i]]` materializes the +selected values. -**Difference:** Pluto separates range views from explicit collection, while -Julia copies a range selection unless a view is requested explicitly. +**Difference:** Pluto separates final-value selection from explicit collection; +it does not expose a persistent range-selection view. --- @@ -128,8 +147,8 @@ Julia copies a range selection unless a view is requested explicitly. // No hidden allocations. ``` -**Pluto:** `ArrayRange` similarly borrows an array and carries an iteration -range, but Pluto does not expose a separate slice type. +**Pluto:** Range-indexed access is consumed as a value stream and does not +expose a slice value. **Difference:** Pluto manages memory automatically (scope-based), Zig is manual. @@ -139,42 +158,43 @@ range, but Pluto does not expose a separate slice type. ### Statement-Level Loop Generation -Ranges generate loops at statement boundaries. All operations inside work on scalar values: +Range-consuming expressions generate loops at statement boundaries. +Operations consume one yielded value at a time; a rank-N selection can yield +an owned subarray: ```python i = 0:5 +j = i # Same bounds, independent named driver +x = i + 0 # Loop at statement: x = 4 (last yielded iterator) x = i + 1 # Loop at statement: x = 5 (last scalar value) y = i * 2 # Loop at statement: y = 8 (last scalar value) z = (i + 1) / (i + 2) # Single loop: z = 5/6 (last value) ``` -Bare ranged expressions execute as loop drivers rather than becoming lazy -values. An explicit range-indexed array access is the separate borrowed-view -case described above. +Complete Range expressions construct or copy descriptors. Operations and +range-indexed array accesses consume descriptors as loop drivers. An +assignment root keeps the last computation yield; `[]` collects every yield. -### IterName Determines Loop Structure +### Driver Identity Determines Loop Structure ```python i = 0:5 j = 0:5 -# Same variable → Zip (single loop) -x = i + 1 -y = i + 2 -result = x / y # Single loop over i +# Repeated use of one driver → one shared loop +ratio = (i + 1) / (i + 2) -# Different variables → Cartesian (nested loops) -x = i + 1 -y = j + 1 -result = x * y # Nested loops: i × j +# Distinct drivers → cartesian nested loops +product = (i + 1) * (j + 1) ``` -### Three Execution Modes +### Four Execution Modes | Mode | Syntax | Behavior | |------|--------|----------| -| **Last Value** | `x = i + 1` | Loop runs, x = last value | -| **Accumulate** | `x += i` | Loop runs, x accumulates | +| **Descriptor Copy** | `j = i` | No loop; j receives the Range value | +| **Last Value** | `x = i + 0` or `x = arr[i]` | Loop runs, x = last yielded value | +| **Accumulate** | `x = x + i` | Loop runs, x accumulates | | **Collect** | `arr = [i * 2]` | Loop runs, collects to array | ### Statement gates and value-position `&&` @@ -202,7 +222,7 @@ Guard expressions with conditions: res = arr[i] > res arr[i] # Conditional update -res = i * i > 10 i +res = (i * i) > 10 i ``` Desugars to: `if (condition) res = expression` @@ -214,10 +234,13 @@ Desugars to: `if (condition) res = expression` Pluto sits in a "Sweet Spot" for parallel computing: 1. **Value Semantics (like R/Matlab)** make reasoning about concurrent code easy. "If I have `x`, I own `x`." -2. **Explicit Views (like Rust/Go)** allow high-performance mutation without copying. -3. **Loop Syntax Ranges (Unique)** provide clean iteration without lazy complexity. -4. **IterName-Based Zipping (Unique)** makes user intent explicit — same variable name means related iterations. -5. **Zero-Value Initialization (Unique)** simplifies accumulation patterns. +2. **Explicit Collection** makes every allocation and materialization boundary visible. +3. **Range-Driven Execution (Unique)** separates copyable descriptors from + operations that iterate without lazy-generator complexity. +4. **Named Driver Reuse (Unique)** makes user intent explicit — repeated use + of one range name shares one loop. +5. **Defined Empty Domains (Unique)** give fresh and existing destinations + predictable behavior. It combines the **safety of R** with the **performance of Rust/Zig** and the **expressiveness of Julia**. @@ -235,18 +258,29 @@ res = sum(a, b) ``` - **Parameters**: Input values (passed by value for scalars) -- **Outputs**: Initialized from corresponding parameters at same position +- **Outputs**: Independently staged result slots. An existing destination + supplies the initial value, while a fresh destination starts at its type's + zero value. The real destinations are committed only after every sibling + right-hand side has been evaluated. - **No name overlap**: Parameters and outputs must have distinct names +When a caller destination and a function's declared output use different +representations of a compatible value (for example, owned versus static +strings, or an empty array type versus a concrete-rank array), the callee sees +the zero value of its declared representation. A per-output write marker tells +the caller whether to commit that adapted value. If the function does not +write the output, the caller's staged value is preserved. This avoids treating +one ownership or shape representation as if it were another. + ### Call Site ```python res = sum(res, 5) # - Parameter 'a' receives value of 'res' # - Parameter 'b' receives 5 -# - Output 'res' initialized to 'a' (caller's res value) +# - Staged output 'res' starts with the caller destination's existing value # - Body executes: res = a + b -# - Result assigned back to caller's res +# - Result commits back to the caller's res after sibling RHS evaluation ``` ### Range Parameters @@ -256,18 +290,21 @@ res = process(a, i) res = a * i ``` -**Desugars to:** +**Semantically:** ```c for (int64_t i_val = 0; i_val < N; i_val++) { - res = a * i_val; // Function receives SCALAR i_val, not range + res = a * i_val; } ``` -**Key:** Functions always receive scalar values, not ranges. The loop is generated at the call site. +**Key:** The function body is evaluated once per yielded range value. Whether +the compiler places that loop around the call or in a specialized callee is an +implementation detail. -### Everything Is Scalar Inside Loops +### Per-Yield Evaluation -When a statement contains range variables, the loop is generated at the statement level, and all operations work on scalars: +When a statement contains range variables, its source-level meaning is +per-yield evaluation: ```python i = 0:5 @@ -277,25 +314,32 @@ result = Square(i) + i **Desugars to:** ```c for (int64_t i_val = 0; i_val < 5; i_val++) { - result = Square(i_val) + i_val; // Square called with scalar! + result = Square(i_val) + i_val; } ``` -- ✅ Operators work on scalars -- ✅ Functions receive scalar arguments +- ✅ Operators work on the current yielded values +- ✅ Function bodies evaluate once per yielded value - ✅ No special "range-aware" operations - ✅ Simple, unified model ### Composition Using Functions -For complex expressions with named intermediates, use functions: +For complex expressions with named intermediates, define a function in `.pt`: ```python -i = 0:5 -res += compute_ratio(i) +ratio = compute_ratio(i) numerator = i + 1 denominator = i + 2 - res = numerator / denominator + ratio = numerator / denominator +``` + +Then consume it from `.spt`: + +```python +i = 0:5 +res = 0 +res = res + compute_ratio(i) ``` This avoids the issue where intermediate assignments execute immediately: @@ -303,8 +347,8 @@ This avoids the issue where intermediate assignments execute immediately: ```python i = 0:5 x = i + 1 # Loop NOW: x = 5 (scalar) -y = i + 2 # Loop NOW: y = 7 (scalar) -res += x / y # No loop! Just res += 5/7 +y = i + 2 # Loop NOW: y = 6 (scalar) +res = res + x / y # No loop! Just scalar addition of 5/6 ``` Functions keep intermediates within the loop context. @@ -316,4 +360,4 @@ Functions keep intermediates within the loop context. - **Scope-based deallocation**: Memory freed when variables go out of scope - **No GC pauses**: Deterministic cleanup - **COW optimization**: Arrays copied only when modified -- **View safety**: Compiler/runtime prevents dangling references +- **No range-view lifetimes**: Range-indexed access is finalized or collected diff --git a/docs/Pluto Range Semantics.md b/docs/Pluto Range Semantics.md index becdece0..eb589749 100644 --- a/docs/Pluto Range Semantics.md +++ b/docs/Pluto Range Semantics.md @@ -2,32 +2,112 @@ ## Core Model -Expressions that mention ranges produce ordered per-iteration values. -Those values are not arrays by default. +A `Range` is a descriptor value. A range literal constructs one, and a +complete Range-valued assignment copies it: -There are two explicit closing steps: +```pluto +i = 0:5 +j = i +k = (i) +``` -1. `[]` closes a value stream into an array. -2. The root expression of a scalar assignment closes any remaining outer - iteration by taking the final yielded value in iteration order. +`i`, `j`, and `k` contain equal descriptor values with the same captured +bounds. Parentheses are transparent. Each binding is nevertheless a distinct +driver identity: consuming `i` and `j` together forms a cartesian domain, +while repeated uses of `i` share one loop. -This keeps array materialization and scalar finalization separate. +Using a Range in an operation creates a ranged computation. `Ranged` is a +useful description of that expression effect, not a storable source type. An +operation produces ordered per-iteration values; an individual yield may be a +scalar or an owned subarray. -## Ranges And Drivers +There are two explicit closing steps for a ranged computation: -A range or array-range used in an expression contributes an iteration driver. -Multiple distinct drivers form a nested iteration domain in source order. -Repeated use of the same driver name refers to the same loop, not a nested copy. +1. `[]` closes it into an array. +2. The root expression of an assignment closes any remaining outer iteration + by taking the final yielded value in iteration order. -Example: +A Range descriptor assignment is not a closing step and does not iterate. +This keeps descriptor copying, collection, and final-value selection separate. -```pluto -i = 0:5 -x = i + 1 -``` +## Migration From Bare-Range Finalization + +Previously, assigning a bare named Range kept its final yield. Code that relied +on that behavior should use an operation such as `last = i + 0`; for +`i = 0:5`, `last` becomes `4`, while `copy = i` now copies the descriptor. + +This change can be silent for a fresh destination. A later call, index, or +collector consumes the copied Range and runs its whole domain, while print and +interpolation format the descriptor itself. Assigning a Range +to an existing scalar is instead rejected as a type-changing reassignment. +Descriptor copies are unconditional writes and participate in the ordinary +dead-store checks. Range-indexed expressions such as `last = data[i]` are +unchanged because indexing is already a ranged computation. -This iterates `i` over `0, 1, 2, 3, 4` and the root assignment keeps the final -value, so `x = 5`. +## Ranges And Drivers + +A range identifier consumed by an operator, array index, collector, statement +condition, or function argument contributes an iteration driver. A +range-indexed array access is itself a ranged computation. +Multiple distinct drivers form a nested iteration domain in source order: the +first distinct driver is outermost and the last is innermost. Repeated use of +the same driver name refers to the same loop, not a nested copy. +Driver identity belongs to the binding name, not to descriptor equality. +Substituting one Range name for another can therefore change a shared loop into +a cartesian domain. + +Range `start`/`stop`/`step` fields are not part of the language. +The bound values are captured when the range is constructed, so later changes +to the source variables do not mutate the existing range. Functions that need +those bounds as data should currently receive the scalar values explicitly. + +Range is nameable and copyable, but is not yet a fully first-class container +element. Arrays and tables contain scalar/string elements rather than Range +descriptors, so `[i]` consumes `i` and collects its yields. Passing a Range to a +function likewise consumes it as a driver rather than passing inert metadata. +A function may return a Range descriptor to a binding, but anonymous +Range-returning expressions are not yet accepted uniformly by every consuming +context; bind the result before consuming it. + +Range-indexed arrays follow the same rule: + +```pluto +arr = [10 20 30 40] +i = 1:4 +last = arr[i] +selected = [arr[i]] +``` + +`last` becomes `40`, while `selected` becomes `[20 30 40]`. A range-indexed +access is not a public slice or view value: it is either consumed by its +surrounding expression, finalized at an assignment root, or materialized by +`[]`. For a rank-N source, one yield is an owned rank-(N-1) subarray, so the +final value can itself be an array. This ownership is semantic: copy-on-write +is permitted, but an escaping view into the source is not. + +Assigning an empty Range descriptor still performs a normal write. Consuming +an empty Range produces no yields: a fresh ranged-computation destination +retains its type's zero value (an empty array for a subarray result), while an +existing destination is unchanged. Outside `[]`, an out-of-bounds selection +point yields nothing, so the last valid selected value wins. Inside `[]`, +failed cells are zero-filled to preserve collection shape, as described below. + +Print is a sink, not an operation: a bare Range argument or main marker +formats the descriptor as `start:stop` (or `start:stop:step`), exactly as the +value it is. Computations still drive the print loop, and a bare name bound as +a driver by a sibling computation prints its per-iteration scalar. A width or +precision marker consumes its Range operand as a number, so that operand still +drives; see +[Pluto String and Formatting Semantics](Pluto%20String%20and%20Formatting%20Semantics.md#interpolation-markers) +for the formatting rules and examples. For ordinary print arguments: + +```pluto +i = 0:2 +j = 2:4 +i, j # one line: 0:2 2:4 +i + 0, j + 0 # cartesian: 0 2 / 0 3 / 1 2 / 1 3 +i, Square(i) # i is driven: 0 0 / 1 1 +``` Distinct drivers nest in source order, so collecting over two ranges walks their cartesian product: @@ -48,7 +128,28 @@ produces: Calls, infix operators, and prefix operators all follow the same rule: they transform the current per-iteration values of their range drivers. -They do not choose a special "base function" that owns the loop. +Whether the compiler places a call's loop around the call or in a specialized +callee is an implementation detail. + +An immediate bare range or range-indexed argument may select such a +range-bearing specialization. In particular, `F(arr[i])` may pass an internal, +call-scoped `ArrayRange` descriptor so the callee loops over the selection. +That descriptor is not a language value: it cannot be stored, returned, +printed, or otherwise escape the call, and the parameter inside `F` observes +one yielded element or owned subarray at a time. + +Driver identity takes priority over loop placement. When one driver occurs in +multiple call arguments, as in `F(i, i)`, both arguments must observe the same +iteration; the current lowering runs that loop caller-side. Distinct drivers, +as in `F(i, j)`, still form their normal cartesian domain. These choices do not +change the results visible to source code. + +Function outputs are staged independently before the call. If an empty driver +or skipped condition means an output is not written, its staged value is +preserved. At representation boundaries, such as a static string result being +assigned into an owned-string destination, the callee receives its declared +zero value and the caller commits the adapted result only when the callee +actually writes it. Examples: @@ -123,7 +224,8 @@ expression. Once the literal has materialized, the result is just an ordinary array value. Binding freezes that value, so later statements treat it like any other named -array. +array. With no active drivers, a collector evaluates once and produces a +singleton array. ### Collectors And Binding @@ -160,16 +262,6 @@ This produces: because `y` is collected as `[0 0 0 0 0]` under the admitted `i` domain. -By contrast: - -```pluto -i = 0:5 -y = [0] -res = i + y -``` - -also produces `[4]`. - Example: ```pluto @@ -238,6 +330,11 @@ rejected domain point, none of its RHS expressions, collector appends, carried updates, or output commits execute. RHS-local ranges are nested inside each admitted point. +If no domain point is admitted, the statement performs no write and an existing +collector destination keeps its old value. This differs from an ungated +collector over an empty range: `[i]` still evaluates to `[]` when `i` itself has +an empty domain. + Example: ```pluto @@ -342,26 +439,23 @@ range and validate that ownership before LLVM lowering. ## Statement Conditions And Tuples -Statement conditions are shared across the whole assignment. -They determine the admitted outer iteration domain for every output in the -statement. - -Sibling RHS expressions do not share their local value drivers with each -other. -Each RHS adds only the extra drivers mentioned inside that expression. +For tuple assignments, the statement-wide gate described above applies to +every output. Each RHS adds only the local drivers mentioned inside that +expression; sibling RHS expressions do not share those drivers. Examples: ```pluto i = 0:3 j = 0:2 -x, y = i < 2 [1], j +x, y = i < 2 [1], j + 0 ``` The statement condition `i < 2` is shared. `x` collects once for each admitted `i`, producing `[1 1]`. -`y` uses its own local `j` driver inside that shared gate and ends with the -final `j` value `1`. +`y`'s operation uses its own local `j` driver inside that shared gate and ends +with the final result `1`. A bare `j` in this position would instead copy the +Range descriptor because only `i` belongs to the active statement domain. Likewise: @@ -383,6 +477,12 @@ the statement condition opens that outer loop first. Inside the RHS, the same name refers to the current scalar iterator value, not to a fresh nested loop. +Consequently, `filtered = i > 2 i` keeps the final admitted scalar, while +`copy = outer > 2 i` copies `i` when only `outer` belongs to the statement +domain. If the statement domain is empty, it performs no write: an existing +Range destination stays unchanged and a fresh one keeps the zero Range +descriptor. + For non-collector tuple outputs, one admitted statement iteration is still one shared scalar update step, but each RHS has its own local yield outcome. If one RHS hits an out-of-bounds failure, only that RHS keeps its previous value; @@ -406,40 +506,10 @@ The statement condition admits `i = 3 4 5`. The outer expression then continues with the frozen array value, so the final result is `[8 9 10]`. -Sibling expression ranges still do not cross into nested collectors: - -```pluto -i = 0:5 -res = i + [([0] + 1)[0]] -``` - -`[0]` is a singleton because it has no internal range and no statement gate. -`[([0] + 1)[0]]` is also a singleton, and the outer `i` finalizes to `4`, so -the result is `[5]`. - -This is a semantic materialization boundary. -The compiler may later hoist or fuse loops as an optimization, but that does -not change the language meaning. - -## Scalar Contexts - -Outside `[]`, ranged expressions remain per-iteration values until the root -assignment or statement consumes them. - -Examples: - -```pluto -i = 0:5 -x = i + 1 -``` - -`x` becomes `5`. - -```pluto -arr = [i + 1] -``` - -`arr` becomes `[1 2 3 4 5]`. +This is the same semantic materialization boundary described for all +collectors: sibling expression ranges do not cross it. The compiler may later +hoist or fuse loops as an optimization, but that does not change the language +meaning. ## Self-Reference: Fold @@ -480,20 +550,3 @@ n = 0:5 Both produce `[0 1 1.41421 1.73205 2]`. Streams and materialized arrays agree wherever both readings exist; the difference is only *when* the array comes into being. - -## Singleton Arrays - -If no range drivers are open inside `[]`, the literal evaluates once and -produces a singleton array. - -Example: - -```pluto -x = 7 -[x] -``` - -produces `[7]`. - -This is not a special array-literal mode. -It is the same collector rule applied to an expression with no active drivers. diff --git a/docs/Pluto String and Formatting Semantics.md b/docs/Pluto String and Formatting Semantics.md index 18ac49a4..d82b65ac 100644 --- a/docs/Pluto String and Formatting Semantics.md +++ b/docs/Pluto String and Formatting Semantics.md @@ -63,6 +63,36 @@ width = 5 "-missing%(-width)d" # -missing%(5)d ``` +A main marker formats its value whatever the type, so a bare `Range` formats +its descriptor and contributes no iteration: the string is a single value in +assignment and in print alike. A Range identifier used for dynamic width or +precision is consumed as a number, which makes it an iteration driver. An +explicit numeric conversion such as `-i%d` is a compile error while `i` is an +undriven Range descriptor — a descriptor is not a number. + +```pluto +i = 0:3 +s = "item -i" # "item 0:3" +"item -i" # prints item 0:3 on one line +n = 7 +w = 1:3 +"|-n%(-w)d|" # one line per width: |7| then | 7| +``` + +Within a print statement, another argument that consumes the same named `Range` +makes it a driver for that print loop. During each iteration, the main marker +reads the current `I64` yield, so an explicit numeric conversion applies to the +yield. This sibling binding is specific to print arguments; it does not apply +across sibling values in an assignment. + +```pluto +i = 0:3 +"i=-i%d", i + 0 +# i=0 0 +# i=1 1 +# i=2 2 +``` + ## Literal percent and strict formatting A `%` outside a resolved marker is ordinary text. A `%` immediately after a diff --git a/parser/codeparser_test.go b/parser/codeparser_test.go index 0878213c..f15bb343 100644 --- a/parser/codeparser_test.go +++ b/parser/codeparser_test.go @@ -369,7 +369,7 @@ func TestFunctionParameterParsing(t *testing.T) { func TestParseStructDefinition(t *testing.T) { input := `p = Person - :name age height + : name age height "Tejas" 35 184.5` cp := NewCodeParser(lexer.New("TestParseStructDefinition", input)) @@ -401,7 +401,21 @@ func TestParseStructDefinition(t *testing.T) { _, constExists := code.ConstNames["p"] require.True(t, constExists) - require.Equal(t, "p = Person\n :name age height\n \"Tejas\" 35 184.5", stmt.String()) + require.Equal(t, "p = Person\n : name age height\n \"Tejas\" 35 184.5", stmt.String()) +} + +func TestStructDefNextStatement(t *testing.T) { + input := `p = Person + : name age + "Tejas" 35 +answer = 42` + + cp := NewCodeParser(lexer.New("TestStructDefNextStatement", input)) + code := cp.Parse() + require.Empty(t, cp.Errors()) + require.Len(t, code.Struct.Statements, 1) + require.Len(t, code.Const.Statements, 1) + require.Equal(t, "answer", code.Const.Statements[0].Name[0].Value) } func TestStructDefErrors(t *testing.T) { @@ -413,24 +427,45 @@ func TestStructDefErrors(t *testing.T) { { name: "duplicate struct field header", input: `p = Person - :name age age + : name age age "Tejas" 35 184.5`, errMsg: "duplicate struct field header: age", }, { name: "multiple lhs bindings not allowed", input: `p, q = Person - :name age + : name age "Tejas" 35`, errMsg: "struct definition must bind exactly one constant name", }, { name: "comma-separated struct row not allowed", input: `p = Person - :name age + : name age "Tejas", 35`, errMsg: "struct value row values must be separated by spaces, not commas", }, + { + name: "struct header requires space after colon", + input: `p = Person + :name age + "Tejas" 35`, + errMsg: "expected a space after ':' in struct field header", + }, + { + name: "struct row requires nested indent", + input: `p = Person + : name age + "Tejas" 35`, + errMsg: "struct value row must be indented beneath its field header", + }, + { + name: "struct row must align with header", + input: `p = Person + : name age + "Tejas" 35`, + errMsg: "struct value row must align with the first field header", + }, } for _, tt := range tests { @@ -452,10 +487,10 @@ func TestStructDefErrors(t *testing.T) { func TestStructDefRepeat(t *testing.T) { input := `p = Person - :name age + : name age "Tejas" 35 q = Person - :name age + : name age "Ada" 28` cp := NewCodeParser(lexer.New("TestStructDefRepeat", input)) @@ -466,10 +501,10 @@ q = Person func TestStructDefSubset(t *testing.T) { input := `p = Person - :name age height + : name age height "Tejas" 35 184.5 q = Person - :age name + : age name 28 "Ada"` cp := NewCodeParser(lexer.New("TestStructDefSubset", input)) @@ -480,7 +515,7 @@ q = Person func TestStructDefZeroInit(t *testing.T) { input := `p = Person - :name age + : name age "Tejas" 35 q = Person` @@ -496,7 +531,7 @@ q = Person` func TestStructDefZeroInitBeforeDef(t *testing.T) { input := `q = Person p = Person - :name age + : name age "Tejas" 35` cp := NewCodeParser(lexer.New("TestStructDefZeroInitBeforeDef", input)) diff --git a/parser/parser.go b/parser/parser.go index 0dff96b2..05e955d0 100644 --- a/parser/parser.go +++ b/parser/parser.go @@ -684,6 +684,13 @@ func (p *StmtParser) parseStructLiteralStatement(assignTok token.Token, idents [ } p.nextToken() + if p.curTokenIs(token.IDENT) && !p.curToken.HadSpace { + p.errors = append(p.errors, &token.CompileError{ + Token: p.curToken, + Msg: "expected a space after ':' in struct field header", + }) + return nil + } headers, ok := p.parseStructHeaders() if !ok { return nil @@ -698,6 +705,21 @@ func (p *StmtParser) parseStructLiteralStatement(assignTok token.Token, idents [ } p.nextToken() + if !p.curTokenIs(token.INDENT) { + p.errors = append(p.errors, &token.CompileError{ + Token: p.curToken, + Msg: "struct value row must be indented beneath its field header", + }) + return nil + } + p.nextToken() + if p.curToken.Column != headers[0].Column { + p.errors = append(p.errors, &token.CompileError{ + Token: p.curToken, + Msg: "struct value row must align with the first field header", + }) + return nil + } row, ok := p.parseStructRowConstants() if !ok { return nil @@ -714,6 +736,11 @@ func (p *StmtParser) parseStructLiteralStatement(assignTok token.Token, idents [ if p.curTokenIs(token.NEWLINE) { p.nextToken() } + if p.curTokenIs(token.DEINDENT) { + // Consume the value-row indentation. The surrounding struct-body + // DEINDENT remains current for CodeParser to consume. + p.nextToken() + } if !p.curTokenIs(token.EOF) && !p.curTokenIs(token.DEINDENT) { p.errors = append(p.errors, &token.CompileError{ @@ -745,7 +772,7 @@ func (p *StmtParser) conditionsOk(expList []ast.Expression) bool { if p.isCondition(exp) { continue } - msg := fmt.Sprintf("Expression %q is not a condition. Statement conditions must be comparisons or bare range/array-range drivers", exp.String()) + msg := fmt.Sprintf("Expression %q is not a condition. Statement conditions must be comparisons or bare range/array-selection drivers", exp.String()) ce := &token.CompileError{ Token: exp.Tok(), Msg: msg, diff --git a/parser/scriptparser_test.go b/parser/scriptparser_test.go index b8242b70..736ef492 100644 --- a/parser/scriptparser_test.go +++ b/parser/scriptparser_test.go @@ -478,8 +478,8 @@ func TestImplicitMultParsingSpaces(t *testing.T) { expErrLen int expErr string }{ - {"implicit mult with space", "x = 5 a", 1, "TestImplicitMultParsingSpaces:1:5:Expression \"5\" is not a condition. Statement conditions must be comparisons or bare range/array-range drivers"}, - {"implicit mult with space poly", "y = 1 + 2 x + 3 x^2", 2, "TestImplicitMultParsingSpaces:1:7:Expression \"(1 + 2)\" is not a condition. Statement conditions must be comparisons or bare range/array-range drivers TestImplicitMultParsingSpaces:1:15:expected next token to be =, got IDENT instead"}, + {"implicit mult with space", "x = 5 a", 1, "TestImplicitMultParsingSpaces:1:5:Expression \"5\" is not a condition. Statement conditions must be comparisons or bare range/array-selection drivers"}, + {"implicit mult with space poly", "y = 1 + 2 x + 3 x^2", 2, "TestImplicitMultParsingSpaces:1:7:Expression \"(1 + 2)\" is not a condition. Statement conditions must be comparisons or bare range/array-selection drivers TestImplicitMultParsingSpaces:1:15:expected next token to be =, got IDENT instead"}, } for _, tt := range tests { diff --git a/runtime/runtime.c b/runtime/runtime.c index 5bdd6260..f06d9e5d 100644 --- a/runtime/runtime.c +++ b/runtime/runtime.c @@ -173,7 +173,7 @@ char *str_hex(const char *s, int64_t byte_limit, int32_t uppercase, int32_t alte return result; } -// Convert a range [s..t) with step p into a NUL-terminated string. +// Format a range descriptor [s..t) with step p as a NUL-terminated string. // Caller is responsible for free()ing the returned buffer. char *range_i64_str(int64_t s, int64_t t, int64_t p) { // Reserve enough space: up to 20 digits per number, two colons, plus NUL. @@ -182,16 +182,13 @@ char *range_i64_str(int64_t s, int64_t t, int64_t p) { if (!buf) return NULL; if (p == 1) { // omit the default ":1" - /* bounded print to avoid CRT warnings/overflow */ snprintf(buf, 65, "%" PRId64 ":%" PRId64, s, t); } else { - /* bounded print to avoid CRT warnings/overflow */ snprintf(buf, 65, "%" PRId64 ":%" PRId64 ":%" PRId64, s, t, p); } return buf; } - /* ---------- portable float formatting ---------- */ /* Canonicalize special values across platforms: - NaN => "NaN" (no sign) @@ -248,11 +245,9 @@ char *f32_str(float xf) { // Format a string using snprintf with variadic arguments. // Uses snprintf to determine size, then allocates exact buffer needed. -// Returns a newly allocated string that the caller must free(). -// NOTE: Currently, formatted strings are not automatically freed and will -// leak unless explicitly freed by the caller or at program exit. -// TODO: Implement proper string lifetime management with reference counting -// or scope-based cleanup. +// Returns a newly allocated string. Ownership transfers to generated code, +// which represents it as StrH and releases it through normal temporary, +// assignment, argument, and scope cleanup. char *sprintf_alloc(const char *fmt, ...) { va_list args1, args2; va_start(args1, fmt); diff --git a/tests/array/array.exp b/tests/array/array.exp index fc94131c..ac7676b3 100644 --- a/tests/array/array.exp +++ b/tests/array/array.exp @@ -32,6 +32,9 @@ selected rows: [ 1 2 3 4 ] +last selected row: [3 4] +empty selected row: [] +kept selected row: [9 8] selected rows OOB: [ 3 4 0 0 diff --git a/tests/array/array.spt b/tests/array/array.spt index 07472fde..9e534995 100644 --- a/tests/array/array.spt +++ b/tests/array/array.spt @@ -57,6 +57,16 @@ matrixCell = matrixRow[0] rowRange = 0:2 selectedRows = [nestedMatrix[rowRange]] "selected rows: -selectedRows" +lastSelectedRow = nestedMatrix[rowRange] +"last selected row: -lastSelectedRow" + +emptyRowRange = 0:0 +emptySelectedRow = nestedMatrix[emptyRowRange] +"empty selected row: -emptySelectedRow" + +keptSelectedRow = [9 8] +keptSelectedRow = nestedMatrix[emptyRowRange] +"kept selected row: -keptSelectedRow" rowRangeOOB = 1:4 selectedRowsOOB = [nestedMatrix[rowRangeOOB]] diff --git a/tests/array/array_capture.exp b/tests/array/array_capture.exp index 0e9c7cbc..bc55cb0d 100644 --- a/tests/array/array_capture.exp +++ b/tests/array/array_capture.exp @@ -7,5 +7,5 @@ ScaledWithoutSpace: [3 12] DescendingCapture: [6 5 4 3 2 1] DescendingConcat: [12 10 8 6 4 2] MultiRangeCapture: [-14 -10 -6 -2 2] -ArrayRangeCapture: [2 3 4] -ArrayRangeConcat: [3 4 5] +RangeSelectionCapture: [2 3 4] +RangeSelectionConcat: [3 4 5] diff --git a/tests/array/array_capture.spt b/tests/array/array_capture.spt index 6ff2b249..8a1663b8 100644 --- a/tests/array/array_capture.spt +++ b/tests/array/array_capture.spt @@ -34,9 +34,9 @@ multiRangeCapture = i + [i] - [3j] "MultiRangeCapture: -multiRangeCapture" arrBase = [1 2 3 4 5] -arrRangeCapture = [arrBase[1:4]] -"ArrayRangeCapture: -arrRangeCapture" +rangeSelectionCapture = [arrBase[1:4]] +"RangeSelectionCapture: -rangeSelectionCapture" -arrRangeConcat = [] -arrRangeConcat = arrRangeConcat ⊕ [arrBase[2:5]] -"ArrayRangeConcat: -arrRangeConcat" +rangeSelectionConcat = [] +rangeSelectionConcat = rangeSelectionConcat ⊕ [arrBase[2:5]] +"RangeSelectionConcat: -rangeSelectionConcat" diff --git a/tests/array/array_func.exp b/tests/array/array_func.exp index 767dd00e..9e6b2292 100644 --- a/tests/array/array_func.exp +++ b/tests/array/array_func.exp @@ -23,6 +23,17 @@ EmptyTableIdentity: [ ] TableCallColumn: [10 12] GatedTableColumn: ["" "Lin"] +ConditionalTableBefore: [ + : Name Score + "Grace" 30 +] +ConditionalTableSkipped: [ + : Name Score + "Grace" 30 +] +ConditionalTableWritten: [ + : Name Score +] DirectConcatScalar: [0] DirectConcatRange: [0 1 2 3 4] ConcatElemDirect: [1] @@ -31,6 +42,39 @@ ConcatElemEmptyNamed: [] ConcatElemInPlace: [3] ConcatEmpty: [] IdentityEmpty: [] +RangedRankReset: [ +] +RangedRankResetEmpty: [ + 5 6 + 7 8 +] +GatedRangedRankResetWritten: [ +] +GatedRangedRankResetEmpty: [ + 31 32 + 33 34 +] +RangedArrayKept: [4 5] +ConditionalRankResetBefore: [ + 11 12 + 13 14 +] +ConditionalRankResetSkipped: [ + 11 12 + 13 14 +] +RangedConditionalRankResetBefore: [ + 21 22 + 23 24 +] +RangedConditionalRankResetSkipped: [ + 21 22 + 23 24 +] +ZeroWidthRankResetBefore: [[] []] +ZeroWidthRankResetSkipped: [[] []] +ZeroWidthRankResetWritten: [ +] BeforeEmptyReset: [9] AfterEmptyReset: [] EmptyResetKeepsType: [4] @@ -43,4 +87,9 @@ RebuildNoAlias: [14] RebuildAssign2: [2] RebuildNoAlias2: [6] PairSumRange: [3 4 4 5] +ArrayRangeLastNamedRow: [3 4] +ArrayRangeLastLiteralRow: [7 8] +ArrayRangeEmptyKeepsRow: [9 10] +ArrayRangeLastLiteralWord: blue +ArrayRangeEmptyKeepsWord: keep [8 9 11] diff --git a/tests/array/array_func.pt b/tests/array/array_func.pt index 3b6bd5f5..1b075a48 100644 --- a/tests/array/array_func.pt +++ b/tests/array/array_func.pt @@ -27,3 +27,17 @@ res = ConcatElem(a, elem) res = ConcatArrays(a, b) res = a ⊕ b + +# Rank-1 [] can reset an established higher-rank destination. +res = ResetArray(x) + res = x > -1 [] + +# A ranged collector can leave an indirect output unwritten. +res = KeepRangedArray(i) + res = i < 0 [i] + +# A header-only table can conditionally reset an established concrete table. +res = ResetTable(x) + res = x > -1 [ + : Name Score + ] diff --git a/tests/array/array_func.spt b/tests/array/array_func.spt index bf664537..54b492d2 100644 --- a/tests/array/array_func.spt +++ b/tests/array/array_func.spt @@ -55,6 +55,16 @@ gatedTableColumn = (1 > 0 && Identity([ ])).Name > "K" "GatedTableColumn: -gatedTableColumn" +conditionalTable = [ + : Name Score + "Grace" 30 +] +"ConditionalTableBefore: -conditionalTable" +conditionalTable = ResetTable(-1) +"ConditionalTableSkipped: -conditionalTable" +conditionalTable = ResetTable(1) +"ConditionalTableWritten: -conditionalTable" + arr1 = [] ⊕ [0] "DirectConcatScalar: -arr1" @@ -80,6 +90,72 @@ emptyConcat = ConcatArrays([], []) emptyIdentity = Identity([]) "IdentityEmpty: -emptyIdentity" +# Ranged indirect calls returning rank-1 [] bridge safely into rank-N slots. +resetMatrix = [ + 1 2 + 3 4 +] +resetDriver = 0:2 +resetMatrix = ResetArray(resetDriver) +"RangedRankReset: -resetMatrix" + +keptMatrix = [ + 5 6 + 7 8 +] +emptyResetDriver = 0:0 +keptMatrix = ResetArray(emptyResetDriver) +"RangedRankResetEmpty: -keptMatrix" + +# A statement condition stages a ranged call through a synthetic destination. +# A non-empty driver must commit the rank-1 [] result into the rank-2 slot. +gatedWrittenMatrix = [ + 41 42 + 43 44 +] +gatedResetDriver = 0:2 +gatedWrittenMatrix = 1 > 0 ResetArray(gatedResetDriver + 0) +"GatedRangedRankResetWritten: -gatedWrittenMatrix" + +# With an empty driver, the staged slot must retain its rank-2 value. +gatedKeptMatrix = [ + 31 32 + 33 34 +] +gatedEmptyResetDriver = 0:0 +gatedKeptMatrix = 1 > 0 ResetArray(gatedEmptyResetDriver + 0) +"GatedRangedRankResetEmpty: -gatedKeptMatrix" + +# A blocked ranged collector inside a function leaves its caller's output intact. +rangedArrayKept = [4 5] +rangedArrayKept = KeepRangedArray(0:3) +"RangedArrayKept: -rangedArrayKept" + +# An ordinary conditional [] return keeps an established rank-N destination +# when the callee leaves its output unwritten. +conditionalResetMatrix = [ + 11 12 + 13 14 +] +"ConditionalRankResetBefore: -conditionalResetMatrix" +conditionalResetMatrix = ResetArray(-1) +"ConditionalRankResetSkipped: -conditionalResetMatrix" + +rangedConditionalResetMatrix = [ + 21 22 + 23 24 +] +"RangedConditionalRankResetBefore: -rangedConditionalResetMatrix" +rangedConditionalResetMatrix = ResetArray(-1:0) +"RangedConditionalRankResetSkipped: -rangedConditionalResetMatrix" + +zeroWidthMatrix = [[] []] +"ZeroWidthRankResetBefore: -zeroWidthMatrix" +zeroWidthMatrix = ResetArray(-1) +"ZeroWidthRankResetSkipped: -zeroWidthMatrix" +zeroWidthMatrix = ResetArray(1) +"ZeroWidthRankResetWritten: -zeroWidthMatrix" + lockedArray = [9] "BeforeEmptyReset: -lockedArray" lockedArray = [] @@ -126,6 +202,36 @@ rebuiltNoAlias2 = Rebuild([5 6 7], -1:1) pairSum = [PairSum(0:2, 3:5)] "PairSumRange: -pairSum" +# A bare rank-N array selection stays internal to the call. Identity receives +# one rank-reduced row per iteration and the indirect result retains the last. +rowSource = [ + 1 2 + 3 4 +] +rowDriver = 0:2 +lastNamedRow = Identity(rowSource[rowDriver]) +"ArrayRangeLastNamedRow: -lastNamedRow" + +lastLiteralRow = Identity([ + 5 6 + 7 8 +][rowDriver]) +"ArrayRangeLastLiteralRow: -lastLiteralRow" + +keptRow = [9 10] +emptyRowDriver = 0:0 +keptRow = Identity(rowSource[emptyRowDriver]) +"ArrayRangeEmptyKeepsRow: -keptRow" + +# A temporary string backing array is released after the call, while the +# selected scalar result remains independently owned. +lastLiteralWord = Identity(["red" "blue"][0:2]) +"ArrayRangeLastLiteralWord: -lastLiteralWord" + +keptWord = "keep" +keptWord = Identity(["red" "blue"][0:0]) +"ArrayRangeEmptyKeepsWord: -keptWord" + # Accumulate in array res = [2 3 5] res = Acc(res, 0:4) diff --git a/tests/array/array_range.exp b/tests/array/array_range.exp index b9c6439c..5c0014fc 100644 --- a/tests/array/array_range.exp +++ b/tests/array/array_range.exp @@ -1,13 +1,20 @@ Index: 30 -RangeAssign: [10 20 30 40 50][1:4] +RangeFinal: 40 RangeWrappedMaterialized: [20 30] -RangeViewPrint: [10 20 30 40 50][1:3] +RangeElement 20 +RangeElement 30 RangeReduce: 150 RangeInfix: 51 -RangeRoot: [10 20 30 40 50][0:5] +RangeRoot: 50 NestedOp: 103 -DirectLiteral: [5 6 7 8][0:3] -IdentRange: [10 20 30 40 50][0:5] +DirectLiteral: 7 +IdentRange: 50 +DescendingRange: 30 +UnevenRange: 50 +EmptyRangeFresh: 0 +EmptyRangeExisting: 77 +OOBLastValid: 50 +AllOOBExisting: 88 PrefixRange: -40 LiteralIterChain: [1 1 0 0 0 0] CallRangeRoot: 100 @@ -15,5 +22,11 @@ CallRootVec: [20 40 60 80 100] CallRangeInfix: 104 CallInfixVec: [20 21 22 23 24 40 41 42 43 44 60 61 62 63 64 80 81 82 83 84 100 101 102 103 104] CallInfixVecSingleRange: [20 41 62 83 104] -BorrowedReassignArr: [10 20 30 40 50] -BorrowedReassignView: [10 20 30 40 50][1:4] +StringRangeFinal: gamma +StringRangeReassign: beta +StringRangeSource: ["alpha" "beta" "gamma"] +LocalRangeWord: two +LocalRangePlane: [ + 9 10 + 11 12 +] diff --git a/tests/array/array_range.pt b/tests/array/array_range.pt index 5534ddfd..c3cb0cbf 100644 --- a/tests/array/array_range.pt +++ b/tests/array/array_range.pt @@ -1,2 +1,14 @@ res = Double(x) res = x * 2 + +word = LocalWord(i) + words = ["zero" "one" "two"] + word = words[i] + +plane = LocalPlane(i) + cube = [ + [1 2] [3 4] + [5 6] [7 8] + [9 10] [11 12] + ] + plane = cube[i] diff --git a/tests/array/array_range.spt b/tests/array/array_range.spt index 96c96fe4..90e2caee 100644 --- a/tests/array/array_range.spt +++ b/tests/array/array_range.spt @@ -3,13 +3,13 @@ value = arr[2] "Index: -value" current = arr[1:4] -"RangeAssign: -current" +"RangeFinal: -current" wrapped = [arr[1:3]] "RangeWrappedMaterialized: -wrapped" -viewPrint = arr[1:3] -"RangeViewPrint: -viewPrint" +# A direct range selection prints its yielded elements, never an internal view. +"RangeElement", arr[1:3] sum = 0 sum = sum + arr[0:5] @@ -31,6 +31,28 @@ idx = 0:5 viaIdent = arr[idx] "IdentRange: -viaIdent" +descending = arr[4:0:-2] +"DescendingRange: -descending" + +uneven = arr[0:5:2] +"UnevenRange: -uneven" + +# Empty selections keep existing destinations and zero-initialize fresh ones. +emptyFresh = arr[2:2] +"EmptyRangeFresh: -emptyFresh" + +emptyExisting = 77 +emptyExisting = arr[2:2] +"EmptyRangeExisting: -emptyExisting" + +# Out-of-bounds points are skipped, so the last valid selected element wins. +lastValid = arr[-2:7] +"OOBLastValid: -lastValid" + +allOOBExisting = 88 +allOOBExisting = arr[8:10] +"AllOOBExisting: -allOOBExisting" + pref = -arr[1:4] "PrefixRange: -pref" @@ -40,6 +62,7 @@ chain = [1] chain = chain ⊕ [chain[idx]] "LiteralIterChain: -chain" +# A range-indexed call argument invokes the function for each valid element. callRoot = Double(arr[0:5]) "CallRangeRoot: -callRoot" @@ -55,8 +78,19 @@ callInfixVec = [Double(arr[0:5]) + idx] callInfixVecSingleRange = [Double(arr[idx]) + idx] "CallInfixVecSingleRange: -callInfixVecSingleRange" -# Borrowed ArrayRange reassignment must not free backing array ownership -view = arr[0:3] -view = arr[1:4] -"BorrowedReassignArr: -arr" -"BorrowedReassignView: -view" +# Final string elements become independently owned scalar results. +words = ["alpha" "beta" "gamma"] +lastWord = words[0:3] +"StringRangeFinal: -lastWord" +lastWord = words[1:2] +"StringRangeReassign: -lastWord" +"StringRangeSource: -words" + +# A Range parameter can select the final owned value from a function-local +# array after that local array has been cleaned up. +localDriver = 0:3 +localWord = LocalWord(localDriver) +"LocalRangeWord: -localWord" + +localPlane = LocalPlane(localDriver) +"LocalRangePlane: -localPlane" diff --git a/tests/array/cond_accum.exp b/tests/array/cond_accum.exp index 686c590d..3bce1d8f 100644 --- a/tests/array/cond_accum.exp +++ b/tests/array/cond_accum.exp @@ -2,16 +2,22 @@ Fill: [0 0 0 0 0] LiteralDriver: [1 1 1 1 1] IdentifierDriver: [2 2 2 2] MixedDriverGuard: [0 1 2] -ArrayRangeDriver: [7 7 7] -NamedArrayRangeDriver: [8 8 8] +RangeSelectionDriver: [7 7 7] +RepeatedRangeSelectionDriver: [8 8 8] RangeMixedTupleA: [0 1 2 3]. RangeMixedTupleB: 13 -ArrayRangeMixedTupleA: [8 8 8]. ArrayRangeMixedTupleB: 40 +RangeSelectionMixedTupleA: [8 8 8]. RangeSelectionMixedTupleB: 40 NestedDrivers: [0 1 2 10 11 12] MixedDrivers: [9 9 9 9 9 9] +NestedRangeSelectionDriver: [1 1] +MixedGateCollectorBlocked: [90] +MixedGateScalarBlocked: 30 +MixedGateAdmitted: [0 1 2 3 4] +MixedGateLoopCarried: 3 DriverScalar: 5 [0 1 2] -FalseCondEmpty: [] +FalseCondBlocked: [10 20 30] [] +EmptyDomainBlocked: [7] RangeCell: [0 1 0 1] [10 20 0] FloatBounded: [1.5 2.5 0 0] @@ -31,13 +37,15 @@ MultiCell: [0 10 1 11] ScalarArith: 3 4 ScalarElem: 20 -4 -LitCross: 4 +CrossRange: [0 1 2 3 4] +LitCross: [0 1 2 3 4] 30 -SeedLit: 88 +SeedLit: [8 9] +FreshLit: [] 77 -StrView: c +StrSelection: c ["a" "b"] +StrAccumBlocked: ["a" "b"] TupleValueRangesA: [0 1 0 1]. TupleValueRangesB: [0 1] CondLocalDriversA: [1 1]. CondLocalDriversB: 1 CondSharedReuse: [0 0 1 1 2 2] @@ -63,7 +71,9 @@ GatedRows: [ 10 11 20 21 ] -GatedRowsEmpty: [ +GatedRowsBlocked: [ + 90 91 + 92 93 ] GatedNestedRows: [ 11 12 @@ -73,4 +83,5 @@ CondCollectorAffine: [20 30 40 50] CondCollectorAffineOOB: [20 40] CapturedCollector: [0 0 0]. CapturedCollectorUse: [5 5 5] NestedCondCell: [3 4 5] +[1 2 3] [9 9] diff --git a/tests/array/cond_accum.spt b/tests/array/cond_accum.spt index 38c1f570..adea5774 100644 --- a/tests/array/cond_accum.spt +++ b/tests/array/cond_accum.spt @@ -18,25 +18,24 @@ i = 0:5 mixed = i && i < 3 [i] "MixedDriverGuard: -mixed" -# Bare array-range expression driver uses its view range as the loop driver +# A range-indexed array expression can directly drive a statement. driverSrc = [10 20 30 40] sevens = driverSrc[1:4] [7] -"ArrayRangeDriver: -sevens" +"RangeSelectionDriver: -sevens" -# Bare array-range identifier driver behaves the same way -view = driverSrc[1:4] -eights = view [8] -"NamedArrayRangeDriver: -eights" +# Reuse names the index range, not a persistent selection value. +selection = 1:4 +eights = driverSrc[selection] [8] +"RepeatedRangeSelectionDriver: -eights" # Bare range driver with mixed tuple outputs accumulates arrays and keeps scalar last-value-wins i = 0:4 mixRangeA, mixRangeB = i [i], i + 10 "RangeMixedTupleA: -mixRangeA. RangeMixedTupleB: -mixRangeB" -# Bare array-range driver does the same with the per-iteration element value -viewMix = driverSrc[1:4] -mixViewA, mixViewB = viewMix [8], viewMix -"ArrayRangeMixedTupleA: -mixViewA. ArrayRangeMixedTupleB: -mixViewB" +# Reusing the same named index range keeps both selections on one driver. +mixSelectionA, mixSelectionB = driverSrc[selection] [8], driverSrc[selection] +"RangeSelectionMixedTupleA: -mixSelectionA. RangeSelectionMixedTupleB: -mixSelectionB" # Bare range drivers compose as nested loops i = 0:2 @@ -44,12 +43,39 @@ j = 0:3 grid = i && j [i * 10 + j] "NestedDrivers: -grid" -# Range and array-range drivers compose as nested loops +# Range and range-indexed array drivers compose as nested loops i = 0:2 -view2 = driverSrc[1:4] -gridView = i && view2 [9] +gridView = i && driverSrc[1:4] [9] "MixedDrivers: -gridView" +# A nested range selection can itself own a statement's iteration domain. +nestedDriverIdx = [0 1] +nestedDriverData = [10 20] +nestedDriverOut = nestedDriverData[nestedDriverIdx[0:2]] [1] +"NestedRangeSelectionDriver: -nestedDriverOut" + +# Scalar conjuncts remain per-iteration guards when another conjunct supplies +# the range, regardless of their order or destination shape. +mixedGateDriver = 0:7 +mixedGateFalse = 0 +mixedGateTrue = 1 + +mixedGateCollector = [90] +mixedGateCollector = mixedGateFalse > 0 && mixedGateDriver < 5 [mixedGateDriver] +"MixedGateCollectorBlocked: -mixedGateCollector" + +mixedGateScalar = 30 +mixedGateScalar = mixedGateDriver < 5 && mixedGateFalse > 0 mixedGateDriver + 7 +"MixedGateScalarBlocked: -mixedGateScalar" + +mixedGateAdmitted = mixedGateTrue > 0 && mixedGateDriver < 5 [mixedGateDriver] +"MixedGateAdmitted: -mixedGateAdmitted" + +# The scalar guard must observe loop-carried updates rather than being hoisted. +mixedGateLoopCarried = 1 +mixedGateLoopCarried = mixedGateLoopCarried < 3 && mixedGateDriver mixedGateLoopCarried + 1 +"MixedGateLoopCarried: -mixedGateLoopCarried" + # Bare range driver with scalar RHS keeps last iteration value i = 0:5 driverScalar = i i + 1 @@ -60,17 +86,22 @@ j = 0:5 arr = j < 3 [j] arr -# Empty result when condition always false +# When the ranged statement gate admits no iteration, the whole assignment is +# blocked and the collector keeps its previous value. old = [10 20 30] k = 0:3 old = k < 0 [99] -"FalseCondEmpty: -old" +"FalseCondBlocked: -old" # Empty result when the driving range itself is empty i = 0:0 empty = i < 5 [i] empty +emptyExisting = [7] +emptyExisting = i < 5 [i] +"EmptyDomainBlocked: -emptyExisting" + # Range cell in value literal i = 0:3 ranged = i < 2 [0:2] @@ -174,41 +205,49 @@ q = 0:3 elem = q < 2 arr4[q] "ScalarElem: -elem" -# Scalar conditional iteration: cross-range +# A statement driver gates a distinct bare Range descriptor without consuming it. r = 0:3 i = 0:5 cross = r < 4 i -cross +crossValues = [cross] +"CrossRange: -crossValues" -# Scalar conditional iteration: direct bare range literal +# A direct bare Range literal is likewise assigned as a descriptor. r = 0:3 litcross = r < 2 0:5 -"LitCross: -litcross" +litcrossValues = [litcross] +"LitCross: -litcrossValues" -# Scalar conditional iteration: direct bare array-range view +# Scalar conditional iteration: direct range-indexed array expression arr5 = [10 20 30 40] r = 0:3 -viewcross = r < 2 arr5[0:3] -viewcross +selectionCross = r < 2 arr5[0:3] +selectionCross -# Scalar conditional iteration: direct bare range literal false path keeps old value -seedlit = 88 +# A rejected descriptor assignment keeps the existing Range value. +seedlit = 8:10 r = 0:3 seedlit = r < 0 0:5 -"SeedLit: -seedlit" +seedlitValues = [seedlit] +"SeedLit: -seedlitValues" + +# A fresh rejected descriptor assignment keeps the zero Range descriptor. +freshlit = r < 0 0:5 +freshlitValues = [freshlit] +"FreshLit: -freshlitValues" -# Scalar conditional iteration: direct bare array-range view false path keeps old value -seedview = 77 +# A rejected direct range-indexed array expression keeps the old value +seedSelection = 77 arr6 = [10 20 30 40] r = 0:3 -seedview = r < 0 arr6[0:3] -seedview +seedSelection = r < 0 arr6[0:3] +seedSelection -# Scalar conditional iteration: direct bare string array-range view +# Direct string selection finalization owns its scalar result sarr = ["a" "b" "c" "d"] r = 0:3 -strview = r < 2 sarr[0:3] -"StrView: -strview" +strSelection = r < 2 sarr[0:3] +"StrSelection: -strSelection" # String accumulation sarr2 = ["a" "b"] @@ -216,6 +255,12 @@ i = 0:3 stracc = i < 2 [sarr2[i]] stracc +# An all-false ranged gate preserves the owned string array and releases the +# unused empty accumulator. +i = 0:3 +stracc = i < 0 ["unused"] +"StrAccumBlocked: -stracc" + # Tuple accumulation with mixed value-level ranges keeps each [] collector local. i = 0:3 tc, td = i < 2 [0:2], [i] @@ -225,7 +270,7 @@ tc, td = i < 2 [0:2], [i] # drivers stay local to the expression that mentions them. i = 0:3 j = 0:2 -localLeakA, localLeakB = i < 2 [1], j +localLeakA, localLeakB = i < 2 [1], j + 0 "CondLocalDriversA: -localLeakA. CondLocalDriversB: -localLeakB" # When a statement condition and an RHS mention the same driver, the condition @@ -352,9 +397,14 @@ domainMatrix = [ i = 0:3 gatedRows = i < 2 [domainMatrix[i]] "GatedRows: -gatedRows" +# A distinct seed makes any accidentally admitted row observable. +gatedRows = [ + 90 91 + 92 93 +] i = 0:3 gatedRows = i < 0 [domainMatrix[i]] -"GatedRowsEmpty: -gatedRows" +"GatedRowsBlocked: -gatedRows" i = 0:3 gatedNestedRows = i < 2 [domainMatrix[0]] + 1 "GatedNestedRows: -gatedNestedRows" @@ -385,6 +435,7 @@ nestedCondCell = i > 2 [([0] + i)[0]] # Existing array replaced on successful accumulation (leak check) seed = [1 2 3] +seed p = 0:3 seed = p < 2 [9] seed diff --git a/tests/cond/expr_forms.exp b/tests/cond/expr_forms.exp index afa2b30f..71af2633 100644 --- a/tests/cond/expr_forms.exp +++ b/tests/cond/expr_forms.exp @@ -1,12 +1,13 @@ -CondArrayRangeExprTrue: 90 -CondArrayRangeExprFalseExisting: 90 -CondArrayRangeExprFalseNew: 0 -CondArrayRangeExprTrueNew: 40 +CondRangeSelectionExprTrue: 90 +CondRangeSelectionExprFalseExisting: 90 +CondRangeSelectionExprFalseNew: 0 +CondRangeSelectionExprTrueNew: 40 CondSelfRefScalarTrue: 15 CondSelfRefScalarFalseExisting: 15 CondPrefixTrue: 10 CondPrefixFalseExisting: 10 CondPrefixFalseNew: 0 -CondPrefixArrayRangeTrue: -40 -CondPrefixArrayRangeFalseExisting: -40 -CondPrefixArrayRangeFalseNew: 0 +CondPrefixRangeSelectionTrue: -40 +CondPrefixRangeSelectionFalseExisting: -40 +CondPrefixRangeSelectionFalseNew: 0 +NestedCallUnderOperator: 10 diff --git a/tests/cond/expr_forms.spt b/tests/cond/expr_forms.spt index 346319bf..f274f805 100644 --- a/tests/cond/expr_forms.spt +++ b/tests/cond/expr_forms.spt @@ -1,16 +1,16 @@ arr = [10 20 30 40 50] -# Conditional expressions that include ArrayRange operands. +# Conditional expressions that include range-indexed array operands. sum = 0 # Self-reference: RHS must read from the same temp slot the IF branch writes. sum = 2 > 1 sum + arr[1:4] -"CondArrayRangeExprTrue: -sum" +"CondRangeSelectionExprTrue: -sum" sum = 1 > 2 sum + arr[2:5] -"CondArrayRangeExprFalseExisting: -sum" +"CondRangeSelectionExprFalseExisting: -sum" newSum = 1 > 2 0 + arr[1:4] -"CondArrayRangeExprFalseNew: -newSum" +"CondRangeSelectionExprFalseNew: -newSum" newSumTrue = 2 > 1 0 + arr[1:4] -"CondArrayRangeExprTrueNew: -newSumTrue" +"CondRangeSelectionExprTrueNew: -newSumTrue" sx = 5 sx = 2 > 1 sx + 10 @@ -29,8 +29,14 @@ z = 1 > 2 3x + -x pr = 7 pr = 1 > 0 0 + -arr[1:4] -"CondPrefixArrayRangeTrue: -pr" +"CondPrefixRangeSelectionTrue: -pr" pr = 1 > 2 0 + -arr[1:2] -"CondPrefixArrayRangeFalseExisting: -pr" +"CondPrefixRangeSelectionFalseExisting: -pr" k = 1 > 2 0 + -arr[1:4] -"CondPrefixArrayRangeFalseNew: -k" +"CondPrefixRangeSelectionFalseNew: -k" + +# A failed condition nested under a call and an operator makes the whole value +# optional, so the established destination remains live and unchanged. +nestedCallKeep = 10 +nestedCallKeep = Double(15 < 10) + 5 +"NestedCallUnderOperator: -nestedCallKeep" diff --git a/tests/math/acc.exp b/tests/math/acc.exp index 8f1c2453..ea9e9b29 100644 --- a/tests/math/acc.exp +++ b/tests/math/acc.exp @@ -2,3 +2,7 @@ 10 25 10 +10 +13 +16 3 +ReversedHalf: 1.5 ReversedSum: 7 diff --git a/tests/math/acc.pt b/tests/math/acc.pt index 7bfdded5..f55dfba5 100644 --- a/tests/math/acc.pt +++ b/tests/math/acc.pt @@ -7,3 +7,16 @@ res = AccViaId(a, x) res = Acc(a, x) res = a + x + +res = ConditionalAcc(a, x) + res = x > 0 a + x + +sum, last = AccPair(a, x) + sum = a + x + last = x + +# Sibling first, so the accumulator sits at the second output and its selector +# keeps the skipped first output as a numbering gap. +half, sum = ReversedAcc(a, x) + half = x * 0.5 + sum = a + x diff --git a/tests/math/acc.spt b/tests/math/acc.spt index d0a50739..3ea406e3 100644 --- a/tests/math/acc.spt +++ b/tests/math/acc.spt @@ -13,3 +13,20 @@ res res = 10 res = AccViaId(res, 0:-2) res + +res = 10 +res = ConditionalAcc(res, -1) +res + +res = 10 +res = ConditionalAcc(res, -2:3) +res + +sum = 10 +sum, last = AccPair(sum, 1:4) +sum, last + +reversedHalf = 0.0 +reversedSum = 1 +reversedHalf, reversedSum = ReversedAcc(reversedSum, 0:4) +"ReversedHalf: -reversedHalf ReversedSum: -reversedSum" diff --git a/tests/math/func.exp b/tests/math/func.exp index 220c2caa..cf452f67 100644 --- a/tests/math/func.exp +++ b/tests/math/func.exp @@ -2,6 +2,14 @@ SquareInt: 25 SquareFloat: 4.84 SquareArr: [1 4 9 25] +ConditionalSquareKeep: 14 +ConditionalSquareWrite: 49 +ConditionalSquareFresh: 0 +ConditionalSquareForwarded: 14 +ConditionalSquareFloatKeep: 14.5 +ConditionalSquareFloatWrite: 56.25 +ConditionalSquareFloatFresh: 0 + SquareRange: 4 SquareArrRange: 9 SquareArrMask: [0 0 0 25] diff --git a/tests/math/func.spt b/tests/math/func.spt index 35798714..4752b9b4 100644 --- a/tests/math/func.spt +++ b/tests/math/func.spt @@ -9,6 +9,28 @@ arrVal = Square(arr) "SquareFloat: -floatVal" "SquareArr: -arrVal\n" +# A skipped direct-scalar function output preserves an existing caller +# destination, while a fresh destination keeps its zero seed. +conditionalSquareKeep = 14 +conditionalSquareKeep = ConditionalSquare(conditionalSquareKeep - 12) +conditionalSquareWrite = 14 +conditionalSquareWrite = ConditionalSquare(conditionalSquareWrite - 7) +conditionalSquareFresh = ConditionalSquare(2) +conditionalSquareForwarded = 14 +conditionalSquareForwarded = ForwardConditionalSquare(conditionalSquareForwarded - 12) +conditionalSquareFloatKeep = 14.5 +conditionalSquareFloatKeep = ConditionalSquare(conditionalSquareFloatKeep - 12.5) +conditionalSquareFloatWrite = ConditionalSquare(7.5) +conditionalSquareFloatFresh = ConditionalSquare(2.0) + +"ConditionalSquareKeep: -conditionalSquareKeep" +"ConditionalSquareWrite: -conditionalSquareWrite" +"ConditionalSquareFresh: -conditionalSquareFresh" +"ConditionalSquareForwarded: -conditionalSquareForwarded" +"ConditionalSquareFloatKeep: -conditionalSquareFloatKeep" +"ConditionalSquareFloatWrite: -conditionalSquareFloatWrite" +"ConditionalSquareFloatFresh: -conditionalSquareFloatFresh\n" + # Range filters and array masks (non-accumulated) rangeVal = Square(1:3) arrRangeVal = Square(arr[1:3]) @@ -32,7 +54,7 @@ arrRangeFilterKeepPrev = Square(arr[1:3] > 2) "SquareRangeFilterTailFalse: -rangeFilterTailFalseVal\n" -# Range and array-range accumulation +# Range and range-indexed array collection rangeAccVal = [Square(1:3)] arrRangeAccVal = [Square(arr[1:3])] arrRangeFilterAccFalseVal = [Square(arr[1:3] > 3)] diff --git a/tests/math/func_array_range.exp b/tests/math/func_array_range.exp index 3c330f17..11f0d8c0 100644 --- a/tests/math/func_array_range.exp +++ b/tests/math/func_array_range.exp @@ -1,4 +1,4 @@ -Square of [0:5][0:2] with loop inside: 1 -Square of [0:5][0:2 + 1] with loop outside: 9 -SumSquares of arr[0:5], arr[1:3] with loop inside: 20 -SumSquares of [0 1 2 3][0:2], [1 2 3 4][1:3 + 1] with loop outside: 17 +Square of [0:5][0:2] final selection: 1 +Square of [0:5][0:2 + 1] final selection: 9 +SumSquares of arr[0:5], arr[1:3] nested selections: 20 +SumSquares of [0 1 2 3][0:2], [1 2 3 4][1:3 + 1] final selection: 17 diff --git a/tests/math/func_array_range.spt b/tests/math/func_array_range.spt index 0577137a..69c12cb6 100644 --- a/tests/math/func_array_range.spt +++ b/tests/math/func_array_range.spt @@ -1,14 +1,14 @@ i = 0:5 arr = [i] -loopInside = Square(arr[0:2]) -"Square of [0:5][0:2] with loop inside: -loopInside" +literalSelection = Square(arr[0:2]) +"Square of [0:5][0:2] final selection: -literalSelection" j = 1:3 -loopOutside = Square(arr[j + 1]) -"Square of [0:5][0:2 + 1] with loop outside: -loopOutside" +offsetSelection = Square(arr[j + 1]) +"Square of [0:5][0:2 + 1] final selection: -offsetSelection" -sumLoopInside = SumSquares(arr[i], arr[j]) -"SumSquares of arr[0:5], arr[1:3] with loop inside: -sumLoopInside" +nestedSelections = SumSquares(arr[i], arr[j]) +"SumSquares of arr[0:5], arr[1:3] nested selections: -nestedSelections" -sumLoopOutside = SumSquares([0 1 2 3][0:2], [1 2 3 4][j + 1]) -"SumSquares of [0 1 2 3][0:2], [1 2 3 4][1:3 + 1] with loop outside: -sumLoopOutside" \ No newline at end of file +literalSelections = SumSquares([0 1 2 3][0:2], [1 2 3 4][j + 1]) +"SumSquares of [0 1 2 3][0:2], [1 2 3 4][1:3 + 1] final selection: -literalSelections" diff --git a/tests/math/func_nested_range.spt b/tests/math/func_nested_range.spt index 6de165f5..2a559e32 100644 --- a/tests/math/func_nested_range.spt +++ b/tests/math/func_nested_range.spt @@ -2,23 +2,23 @@ i = 0:5 j = 0:3 -# [i] materializes to [0 1 2 3 4], then [j] indexes that array-range. +# [i] materializes to [0 1 2 3 4], then [j] selects from that array. # Last iteration for j is 2, so Square([0 1 2]) keeps the last value 4. nested = Square([i][j]) "Square([i][j]): -nested" -# Similar with SumSquares - both collectors materialize first, then the views iterate. +# Similar with SumSquares - both collectors materialize first, then indexing iterates. k = 1:4 # Last values are 2 and 3, so SumSquares = 2^2 + 3^2 = 13. sumNested = SumSquares([i][j], [i][k]) "SumSquares([i][j], [i][k]): -sumNested" -# Mix: first arg is bare range arr[m], second recollects [i] before indexing. +# Mix: the first argument is range-indexed arr[m]; the second recollects [i]. arr = [10 20 30 40 50] m = 0:3 n = 1:4 arr2 = [i] -# arr[m] is ArrayRange (loop inside), [i][n+0] materializes [0 1 2 3 4] then indexes it. +# arr[m] supplies one selected element per invocation; [i][n+0] materializes first. # SumSquares(30, 3) = 900 + 9 = 909 mixedNested = SumSquares(arr[m], [i][n + 0]) "SumSquares(arr[m], stored[n+0]): -mixedNested" diff --git a/tests/math/func_range.exp b/tests/math/func_range.exp index 66c13958..bb986026 100644 --- a/tests/math/func_range.exp +++ b/tests/math/func_range.exp @@ -1,4 +1,4 @@ -Square of 0:5 with loop inside: 16 -Square of 0:5 with loop outside: 25 -SumSquares of 0:5, 0:5 with loop inside: 32 -SumSquares of 0:5, 0:5 with loop outside: 16.6944444444444 \ No newline at end of file +Square of bare 0:5: 16 +Square of 0:5 + 1: 25 +SumSquares of bare 0:5, 0:5: 32 +SumSquares of 0:5 and range expression: 16.6944444444444 diff --git a/tests/math/func_range.spt b/tests/math/func_range.spt index 458f8222..bce1cf48 100644 --- a/tests/math/func_range.spt +++ b/tests/math/func_range.spt @@ -1,13 +1,13 @@ i = 0:5 -loopInside = Square(i) -"Square of 0:5 with loop inside: -loopInside" +bareRange = Square(i) +"Square of bare 0:5: -bareRange" -loopOutside = Square(i + 1) -"Square of 0:5 with loop outside: -loopOutside" +rangeExpression = Square(i + 1) +"Square of 0:5 + 1: -rangeExpression" j = 0:5 -sumLoopInside = SumSquares(i, j) -"SumSquares of 0:5, 0:5 with loop inside: -sumLoopInside" +twoBareRanges = SumSquares(i, j) +"SumSquares of bare 0:5, 0:5: -twoBareRanges" -sumLoopOutside = SumSquares(i, (j + 1) / (j + 2)) -"SumSquares of 0:5, 0:5 with loop outside: -sumLoopOutside" \ No newline at end of file +mixedRangeExpression = SumSquares(i, (j + 1) / (j + 2)) +"SumSquares of 0:5 and range expression: -mixedRangeExpression" diff --git a/tests/math/math.pt b/tests/math/math.pt index 5372be05..f9166b25 100644 --- a/tests/math/math.pt +++ b/tests/math/math.pt @@ -7,6 +7,12 @@ greeting = "hello" res = Square(x) res = x * x +res = ConditionalSquare(x) + res = x > 5 x * x + +res = ForwardConditionalSquare(x) + res = ConditionalSquare(x) + z = SumSquares(a, b) z = Square(a) + Square(b) @@ -22,4 +28,4 @@ x, y = F(i) x, y = 2 + i, 3 + i a, b = G(i) - a, b = 2.3 * i, 5.6 * i \ No newline at end of file + a, b = 2.3 * i, 5.6 * i diff --git a/tests/math/print_func.exp b/tests/math/print_func.exp index 9d049aa9..7aa03591 100644 --- a/tests/math/print_func.exp +++ b/tests/math/print_func.exp @@ -14,23 +14,18 @@ 2 4 3 9 0:4 1:3 -0 2 -0 3 -1 2 -1 3 -2 2 -2 3 -3 2 -3 3 -0 4 -0 9 -1 4 -1 9 -2 4 -2 9 -3 4 -3 9 +0:4 2 +0:4 3 +0:4 4 +0:4 9 1 4 2 9 1 0 2 2 +0:9 0 +0:9 1 +0:9 2 +0:9 3 +0:9 1 +0:9 2 +0:9 3 diff --git a/tests/math/print_func.spt b/tests/math/print_func.spt index 10335ed3..9fc9fea1 100644 --- a/tests/math/print_func.spt +++ b/tests/math/print_func.spt @@ -17,20 +17,28 @@ Square(i) # Named range both direct and in function call i, Square(i) -# Two bare ranges - should print as representations +# Two bare ranges print as descriptor values on one line. j = 1:3 i, j -# One bare range, one expression - should iterate +# A computation drives only its own name; the undriven bare range stays a +# descriptor, printed once per driver yield. i, j + 1 -# Different ranges - nested iteration (4 x 2 = 8 lines) +# The undriven bare range prints its descriptor beside each computed value. i, Square(j + 1) -# Same range used bare and in expression - single loop +# Same driver bare and in a computation - the computation binds it, single loop j, Square(j + 1) # Regression test: mixed expression with function not called with scalars before # SumSquares(k, k) creates Range variant, but k+1 forces iteration needing scalar variant k = 0:2 k + 1, SumSquares(k, k) + +# Regression: a descriptor literal must not be rewritten into a loop iterator +# when a sibling computation drives the print loop. +0:9, i + 0 + +# Regression: a descriptor literal beside a literal-range computation. +0:9, 1:4 + 0 diff --git a/tests/mem/mem_alias_refine.exp b/tests/mem/mem_alias_refine.exp new file mode 100644 index 00000000..8b32da5b --- /dev/null +++ b/tests/mem/mem_alias_refine.exp @@ -0,0 +1 @@ +Refined: static Sibling: hello! diff --git a/tests/mem/mem_alias_refine.pt b/tests/mem/mem_alias_refine.pt new file mode 100644 index 00000000..26a0ff20 --- /dev/null +++ b/tests/mem/mem_alias_refine.pt @@ -0,0 +1,7 @@ +# A heap-string argument whose same-named destination receives a static output. +# The ownership flavors differ (StrH in, StrG out), so the input must keep its +# own pointer: redirecting it to the output's adapter would make this sibling +# output, which reads the input, see the adapter's value instead. +out, echo = RefineEcho(s, x) + out = "static" + echo = x > -1 s ⊕ "!" diff --git a/tests/mem/mem_alias_refine.spt b/tests/mem/mem_alias_refine.spt new file mode 100644 index 00000000..c32420dc --- /dev/null +++ b/tests/mem/mem_alias_refine.spt @@ -0,0 +1,7 @@ +# The range driver is what makes the callee alias-bearing, so this only covers +# the intended path while the call carries a range. +text = "he" ⊕ "llo" +sibling = "z" +i = 0:3 +text, sibling = RefineEcho(text, i) +"Refined: -text Sibling: -sibling" diff --git a/tests/mem/mem_cmp_lhs.exp b/tests/mem/mem_cmp_lhs.exp new file mode 100644 index 00000000..3ace287b --- /dev/null +++ b/tests/mem/mem_cmp_lhs.exp @@ -0,0 +1,7 @@ +Picked: 49 +Flag: 1 +Name: world +mmnn +Label: mmnn +qqrr +Chained: qqrr diff --git a/tests/mem/mem_cmp_lhs.pt b/tests/mem/mem_cmp_lhs.pt new file mode 100644 index 00000000..fd167560 --- /dev/null +++ b/tests/mem/mem_cmp_lhs.pt @@ -0,0 +1,5 @@ +# A heap string used as a comparison LHS. The comparison must not leave the +# named binding permanently borrowed, or scope cleanup never frees its payload. +out = pickByHeapCompare(k) + label = "he" ⊕ "llo" + out = label > "abc" k diff --git a/tests/mem/mem_cmp_lhs.spt b/tests/mem/mem_cmp_lhs.spt new file mode 100644 index 00000000..bc452922 --- /dev/null +++ b/tests/mem/mem_cmp_lhs.spt @@ -0,0 +1,23 @@ +# Running the comparison over a range makes any per-iteration leak of the +# comparison LHS observable to the leak checker. +i = 0:50 +picked = pickByHeapCompare(i) +"Picked: -picked" + +# The same shape at script scope, where the binding outlives the comparison. +name = "wor" ⊕ "ld" +flag = 0 +flag = name > "abc" 1 +"Flag: -flag" +"Name: -name" + +# A comparison at print position yields its left operand's payload. That value +# belongs to the binding, so the print path must not free it: doing so left the +# name dangling and then double-freed it at scope cleanup. +label = "mm" ⊕ "nn" +label > "abc" +"Label: -label" + +chained = "qq" ⊕ "rr" +chained > "aa" > "A" +"Chained: -chained" diff --git a/tests/mem/mem_str.exp b/tests/mem/mem_str.exp index 8038d8b0..1627afa8 100644 --- a/tests/mem/mem_str.exp +++ b/tests/mem/mem_str.exp @@ -24,6 +24,12 @@ left_owned reset heap from func static from func +keep_heap +CallStaticSeedKeep: keep_heap +CallStaticSeedWrite: static changed +CallStaticShareBefore: < shared_left > < shared_right > +CallStaticShareSkipped: < shared_left > < > +CallStaticShareWritten: < shared static > < shared static > slot plain ["slot" "array"] @@ -43,3 +49,14 @@ zero one two localarray +RangeStaticExpression: static ranged +RangeStaticExpressionEmpty: heap from func +RangeStaticBare: static ranged +RangeStaticBareEmpty: heap from func +RangeStaticConditional: static ranged +RangeStaticSimultaneous: static ranged heap from func +RangeStaticPair: pair static 2 +CallHeapSimultaneous: heap from func call_left +CondHeapSwap: cond_right cond_left +RangeCondSource: z_source z_source +RangeCondSelf: z_self diff --git a/tests/mem/mem_str.pt b/tests/mem/mem_str.pt index a6ec985e..59d20312 100644 --- a/tests/mem/mem_str.pt +++ b/tests/mem/mem_str.pt @@ -7,6 +7,24 @@ result = identity(s) s = getStatic() s = "static from func" +# Return a static string while consuming a scalar/range argument. +s = getStaticAt(x) + s = x > -1 "static ranged" + +# Conditionally replace a caller-supplied heap output with a static string. +s = maybeStatic(flag) + s = flag > 0 "static changed" + +# Propagate one conditionally written static output into another output. +s, t = shareStaticOutput(flag) + s = flag > 0 "shared static" + t = s + +# Mixed indirect outputs exercise one widened string slot and one exact scalar slot. +s, n = getStaticPair(x) + s = x > -1 "pair static" + n = x + # Return a heap string (concatenation) s = getHeap() s = "heap" ⊕ " from func" diff --git a/tests/mem/mem_str.spt b/tests/mem/mem_str.spt index 1b3f8253..579bdcf4 100644 --- a/tests/mem/mem_str.spt +++ b/tests/mem/mem_str.spt @@ -81,6 +81,26 @@ callReset callReset = getStatic() callReset +# A StrG-returning conditional callee still sees the established StrH +# destination seed when its write is skipped. +staticCallSeed = "keep" ⊕ "_heap" +staticCallSeed +staticCallSeed = maybeStatic(0) +"CallStaticSeedKeep: -staticCallSeed" +staticCallSeed = maybeStatic(1) +"CallStaticSeedWrite: -staticCallSeed" + +# Refinement adapters are ABI-zero-seeded inside the callee. An unwritten StrG +# output preserves its established StrH destination, while an explicit sibling +# copy of that ABI value commits the empty StrG value safely. +sharedStaticLeft = "shared" ⊕ "_left" +sharedStaticRight = "shared" ⊕ "_right" +"CallStaticShareBefore: < -sharedStaticLeft > < -sharedStaticRight >" +sharedStaticLeft, sharedStaticRight = shareStaticOutput(0) +"CallStaticShareSkipped: < -sharedStaticLeft > < -sharedStaticRight >" +sharedStaticLeft, sharedStaticRight = shareStaticOutput(1) +"CallStaticShareWritten: < -sharedStaticLeft > < -sharedStaticRight >" + # Array element copy infers StrH for the binding; later static stores should work arrSlot = ["slot" "array"] elemReset = arrSlot[0] @@ -151,3 +171,63 @@ elem2 # Function that uses array elements internally result = useArrayElem() result + +# Ranged indirect calls keep their ABI output flavor separate from an +# established owning destination. Bare and expression drivers must both +# preserve empty ranges and remain safe under simultaneous writes. +rangeStatic = getHeap() +rangeArg = 0:3 +rangeStatic = getStaticAt(rangeArg + 0) +"RangeStaticExpression: -rangeStatic" + +rangeStatic = getHeap() +emptyRangeArg = 0:0 +rangeStatic = getStaticAt(emptyRangeArg + 0) +"RangeStaticExpressionEmpty: -rangeStatic" + +rangeStatic = getHeap() +rangeStatic = getStaticAt(rangeArg) +"RangeStaticBare: -rangeStatic" + +rangeStatic = getHeap() +rangeStatic = getStaticAt(emptyRangeArg) +"RangeStaticBareEmpty: -rangeStatic" + +rangeStatic = getHeap() +rangeStatic = 1 > 0 getStaticAt(rangeArg + 0) +"RangeStaticConditional: -rangeStatic" + +rangeStatic = getHeap() +rangeStatic, rangeSibling = getStaticAt(rangeArg), rangeStatic +"RangeStaticSimultaneous: -rangeStatic -rangeSibling" + +pairStatic = getHeap() +pairNumber = -1 +pairStatic, pairNumber = getStaticPair(rangeArg + 0) +"RangeStaticPair: -pairStatic -pairNumber" + +# Ordinary indirect-return calls also stage their outputs until every sibling +# RHS has read the statement-start values. +callSiblingLeft = "call" ⊕ "_left" +callSiblingLeft, callSiblingRight = getHeap(), callSiblingLeft +"CallHeapSimultaneous: -callSiblingLeft -callSiblingRight" + +# Conditional-value RHS expressions remain simultaneous: committing the first +# heap-string result cannot invalidate a later sibling read. +condSwapLeft = "cond" ⊕ "_left" +condSwapRight = "cond" ⊕ "_right" +condSwapLeft, condSwapRight = condSwapRight > "", condSwapLeft +"CondHeapSwap: -condSwapLeft -condSwapRight" + +# A ranged comparison retains an owned copy of an identifier LHS. Cover both +# a distinct source and self-reference, where the staged output initially +# points at the same logical value. +rangeCompare = 0:3 +rangeCompareSource = "z" ⊕ "_source" +rangeCompareOut = "old" ⊕ "_out" +rangeCompareOut = rangeCompareSource > "item -rangeCompare" +"RangeCondSource: -rangeCompareOut -rangeCompareSource" + +rangeCompareSelf = "z" ⊕ "_self" +rangeCompareSelf = rangeCompareSelf > "item -rangeCompare" +"RangeCondSelf: -rangeCompareSelf" diff --git a/tests/range.exp b/tests/range.exp index d178c5ba..d4e337d7 100644 --- a/tests/range.exp +++ b/tests/range.exp @@ -1,2 +1,7 @@ -0:5 1:10 0:4:2 -3:5 0:6 -i is 0:6, j is 1:8, k is 6:8:10 0:8:3 +[0 1 2 3 4] [1 2 3 4 5 6 7 8 9] [0 2] [-3 -2 -1 0 1 2 3 4] [0 1 2 3 4 5] +[0 1 2 3 4 5] [1 2 3 4 5 6 7] [6] [0 3 6] +Descriptors 0:2 2:4 +Cartesian 0 2 +Cartesian 0 3 +Cartesian 1 2 +Cartesian 1 3 diff --git a/tests/range.spt b/tests/range.spt index 01159e42..a1453771 100644 --- a/tests/range.spt +++ b/tests/range.spt @@ -2,7 +2,7 @@ i = 0:5 j = 1:10 k = 0:4:2 l = -3:5:1 -i, j, k, l, 0:6 +[i], [j], [k], [l], [0:6] zero = 0 a = 6 @@ -15,4 +15,11 @@ j = 1:b k = a:b:c u = 0:b:d -"i is -i, j is -j, k is -k", u \ No newline at end of file +[i], [j], [k], [u] + +# Bare descriptors print as values on one line; computations over distinct +# drivers still form a cartesian iteration domain. +p = 0:2 +q = 2:4 +"Descriptors", p, q +"Cartesian", p + 0, q + 0 diff --git a/tests/range_finalize.exp b/tests/range_finalize.exp new file mode 100644 index 00000000..49591e22 --- /dev/null +++ b/tests/range_finalize.exp @@ -0,0 +1,23 @@ +AscendingCopy: [0 1 2 3 4] +DescendingCopy: [6 4 2] +EmptyRangeFresh: [] +EmptyRangeExisting: [0 1] -> [] +EmptyComputeFresh: 0 +EmptyComputeExisting: 91 +CallFinal: 20 +ReturnedRange: [2 3 4] +MarkerFinal: Marker 0:3 +MarkerDescriptor 0:3 +MarkerWidthFinal: | 7| +MarkerWidthEach: |7| +MarkerWidthEach: | 7| +MarkerWidthEach: | 7| +LiteralDescriptor 0:3 +DrivenMarker i=0 0 +DrivenMarker i=1 1 +DrivenMarker i=2 2 +EmptyDescriptor: 3:3 +SteppedDescriptor: 6:0:-2 +SimultaneousRange: 2 77 +SimultaneousArray: 30 88 +SimultaneousString: Marker 0:3 old diff --git a/tests/range_finalize.spt b/tests/range_finalize.spt new file mode 100644 index 00000000..aff7855d --- /dev/null +++ b/tests/range_finalize.spt @@ -0,0 +1,85 @@ +# Bare range assignments copy descriptors. Brackets observe the copied stream. +ascendingRange = 0:5 +ascending = ascendingRange +ascendingValues = [ascending] +"AscendingCopy: -ascendingValues" + +descendingRange = 6:0:-2 +descending = descendingRange +descendingValues = [descending] +"DescendingCopy: -descendingValues" + +# Empty descriptors still assign: both destinations become empty ranges. +emptyRange = 3:3 +freshRange = emptyRange +freshRangeValues = [freshRange] +"EmptyRangeFresh: -freshRangeValues" + +existingRange = 0:2 +existingRangeBefore = [existingRange] +existingRange = emptyRange +existingRangeValues = [existingRange] +"EmptyRangeExisting: -existingRangeBefore -> -existingRangeValues" + +# Empty ranged computations yield no value: fresh scalar destinations use zero, +# while existing scalar destinations retain their seed. +fresh = emptyRange + 1 +"EmptyComputeFresh: -fresh" + +existing = 91 +existing = emptyRange + 1 +"EmptyComputeExisting: -existing" + +# A copied descriptor remains consumable by functions. +callFinal = useShadow(ascending) +"CallFinal: -callFinal" + +# A Range returned by a function is a descriptor once bound. +returnedRange = makeRange() +returnedValues = [returnedRange] +"ReturnedRange: -returnedValues" + +# A bare Range in a main marker formats the descriptor: the string is one +# value with no drivers, in interpolation exactly as in assignment. +markerRange = 0:3 +formattedFinal = "Marker -markerRange" +"MarkerFinal: -formattedFinal" +"MarkerDescriptor -markerRange" + +# A width or precision operand is consumed as a number, so a named Range there +# is still an iteration driver. +widthRange = 1:4 +widthValue = 7 +formattedWidth = "|-widthValue%(-widthRange)d|" +"MarkerWidthFinal: -formattedWidth" +"MarkerWidthEach: |-widthValue%(-widthRange)d|" + +# A range literal in print position is a descriptor value too. +"LiteralDescriptor", 0:3 + +# A sibling computation binds the marker's name, so the marker formats that +# iteration's I64 yield and an explicit %d applies to it. +"DrivenMarker i=-markerRange%d", markerRange + 0 + +# Descriptors print their exact bounds, including empty and stepped forms. +emptyDescriptor = 3:3 +"EmptyDescriptor:", emptyDescriptor +steppedDescriptor = 6:0:-2 +"SteppedDescriptor:", steppedDescriptor + +# Ranged computations preserve simultaneous-assignment reads. +simRange = 0:3 +first = 77 +first, second = simRange + 0, first +"SimultaneousRange: -first -second" + +simArray = [10 20 30] +first = 88 +first, second = simArray[simRange], first +"SimultaneousArray: -first -second" + +# A bare-marker string is a single value, so this write is unconditional; the +# sibling still reads text's pre-statement value. +text = "old" +text, otherText = "Marker -markerRange", text +"SimultaneousString: -text -otherText" diff --git a/tests/range_shadow.pt b/tests/range_shadow.pt index 724278a9..6248129f 100644 --- a/tests/range_shadow.pt +++ b/tests/range_shadow.pt @@ -4,4 +4,7 @@ res = useShadow(x) res = AddMul(x, y) i = 10 - res = i * x + y \ No newline at end of file + res = i * x + y + +res = makeRange() + res = 2:5 diff --git a/tests/struct/struct.exp b/tests/struct/struct.exp index 4ac97b36..ed356933 100644 --- a/tests/struct/struct.exp +++ b/tests/struct/struct.exp @@ -2,18 +2,18 @@ Tejas 35 184.5 Ada 28 0 0 0 Person - :name age height + : name age height Tejas 35 184.5 Person - :name age height + : name age height Tejas 35 184.5 Person - :name age height + : name age height Tejas 35 184.5 99 x=Person - :name age height + : name age height Tejas 35 184.5 y=99 diff --git a/tests/struct/struct.pt b/tests/struct/struct.pt index 2812ac81..3319a740 100644 --- a/tests/struct/struct.pt +++ b/tests/struct/struct.pt @@ -1,9 +1,9 @@ p = Person - :name age height + : name age height "Tejas" 35 184.5 q = Person - :age name + : age name 28 "Ada" r = Person diff --git a/token/token.go b/token/token.go index 99e997f8..5f333863 100644 --- a/token/token.go +++ b/token/token.go @@ -50,7 +50,7 @@ const ( LEQ // <= GEQ // >= - COLON // : is if we want to loop from 0:n. For eg: y += 0:n x + COLON // : separates range bounds, as in 0:n or start:stop:step comparison_end // Other tokens.