diff --git a/README.md b/README.md index 9dd0a759..9ab0d8ff 100644 --- a/README.md +++ b/README.md @@ -31,7 +31,7 @@ Intended for performance-sensitive scripting, numerical work, simulation, and sy **Purity:** Functions have read-only inputs and writable outputs; structs are raw data with no hidden pointers. -Range-driven auto-vectorization, safe arrays and slices. +Range-driven auto-vectorization and safe arrays. Scope-based memory (no nulls, no out-of-bounds, no GC), and concurrency by construction. @@ -42,7 +42,7 @@ Scope-based memory (no nulls, no out-of-bounds, no GC), and concurrency by const - Go front-end, LLVM back-end; emits native binaries - Template functions in `.pt`: specialized per argument types (generics by use) - Range literals with auto-vectorized execution -- First-class arrays (safe slicing) and link semantics +- 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 - Cross-platform (Linux/macOS/Windows) @@ -197,6 +197,47 @@ 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. +The same bracket syntax infers higher-rank arrays and tables without a type keyword: + +```python +matrix = [ + 1 2 + 3 4 +] + +cube = [ + [1 2] [3 4] + [5 6] [7 8] +] + +scores = [ + : Name Score + "Ada" 10 + "Lin" 12 +] +``` + +Inline literals such as `[1 2 3]` contribute one array axis. A block literal, +where `[` is followed by a newline, contributes row and column axes even when +it contains one row. Thus the matrix above is equivalent to +`[[1 2] [3 4]]`. Use `\` to continue a long inline array across physical +lines without starting block layout. Array-valued cells stack recursively +while storage remains flat and row-major. Ragged literals are compile errors. + +A header row produces a columnar table, with named columns projected as arrays +such as `scores.Score`. The shown hanging `:` is the preferred layout because +the first header aligns with the first value; spacing within header and data +rows is otherwise non-semantic. + +The empty literal `[]` has type `[Empty]`: it prints as `[]`, can be passed to +functions, and adopts an element type when concatenated with a concrete array. +Once a variable has a concrete array type, assigning `[]` empties its value but +preserves that element type. Operations such as indexing or arithmetic still +require a concrete element type. + +See [Pluto Array Semantics](docs/Pluto%20Array%20Semantics.md) for complete +inference, shape, indexing, empty-value, and table rules. + String-array elements print quoted so boundaries stay unambiguous: ```python diff --git a/ast/ast.go b/ast/ast.go index 82381f04..5afd37c6 100644 --- a/ast/ast.go +++ b/ast/ast.go @@ -278,7 +278,7 @@ type StringLiteral struct { func (sl *StringLiteral) expressionNode() {} func (sl *StringLiteral) Tok() token.Token { return sl.Token } -func (sl *StringLiteral) String() string { return sl.Token.Literal } +func (sl *StringLiteral) String() string { return `"` + sl.Token.Literal + `"` } // RangeLiteral represents start:stop[:step] type RangeLiteral struct { @@ -300,7 +300,8 @@ func (rl *RangeLiteral) String() string { type ArrayLiteral struct { Token token.Token // the '[' token - Headers []string // column headers (empty for matrices) + Block bool // '[' is followed by a newline; source rows form two layout axes + Headers []string // column names; empty for arrays and unnamed tables Rows [][]Expression // row data Indices map[string][]int // named row indices like "books": [2,3] } @@ -310,43 +311,51 @@ func (al *ArrayLiteral) Tok() token.Token { return al.Token } func (al *ArrayLiteral) String() string { var out bytes.Buffer out.WriteString("[") + if len(al.Headers) == 0 && !al.Block { + if len(al.Rows) == 1 { + writeArrayRow(&out, al.Rows[0]) + } + out.WriteString("]") + return out.String() + } // Print headers if present if len(al.Headers) > 0 { - out.WriteString("\n : ") // 2 spaces after : + out.WriteString("\n : ") for j, header := range al.Headers { if j > 0 { out.WriteString(" ") } out.WriteString(header) } - out.WriteString("\n") } // Print rows for _, row := range al.Rows { - if len(al.Headers) > 0 { - out.WriteString(" ") // 5 spaces for header tables - } else { - out.WriteString(" ") // 4 spaces for matrices - } - for j, expr := range row { - if j > 0 { - out.WriteString(" ") - } - if expr != nil { - out.WriteString(expr.String()) - } else { - out.WriteString("") - } - } - out.WriteString("\n") + out.WriteString("\n ") + writeArrayRow(&out, row) } + if al.Block || len(al.Headers) > 0 || len(al.Rows) > 0 { + out.WriteString("\n") + } out.WriteString("]") return out.String() } +func writeArrayRow(out *bytes.Buffer, row []Expression) { + for i, expr := range row { + if i > 0 { + out.WriteString(" ") + } + if expr == nil { + out.WriteString("") + continue + } + out.WriteString(expr.String()) + } +} + // StructLiteral represents a struct value declared in .pt code mode. // Example: // p = Person @@ -627,6 +636,7 @@ func RewriteExpr(expr Expression, rewrite func(Expression) Expression) Expressio } return &ArrayLiteral{ Token: e.Token, + Block: e.Block, Headers: append([]string(nil), e.Headers...), Rows: rows, Indices: maps.Clone(e.Indices), diff --git a/compiler/array.go b/compiler/array.go index bfd4d694..cd947103 100644 --- a/compiler/array.go +++ b/compiler/array.go @@ -63,13 +63,20 @@ var ArrayInfos = map[Kind]ArrayInfo{ }, } +// Values must match PtElementKind in runtime/array.h. +var runtimeElementKinds = map[Kind]uint64{ + IntKind: 0, + FloatKind: 1, + StrKind: 2, +} + func (c *Compiler) NewArrayAccumulator(arr Array) *ArrayAccumulator { - info := ArrayInfos[arr.ColTypes[0].Kind()] + info := ArrayInfos[arr.ElemType.Kind()] fnTy, fn := c.GetCFunc(info.NewName) vec := c.builder.CreateCall(fnTy, fn, nil, "range_arr_new") return &ArrayAccumulator{ Vec: vec, - ElemType: arr.ColTypes[0], + ElemType: arr.ElemType, ArrayType: arr, Info: info, } @@ -174,7 +181,6 @@ func (c *Compiler) CreateArrayForType(elem Type, length llvm.Value) llvm.Value { func (c *Compiler) trapOnRuntimeError(status llvm.Value, name string) { zero := llvm.ConstInt(status.Type(), 0, false) ok := c.builder.CreateICmp(llvm.IntEQ, status, zero, name+"_ok") - fn := c.builder.GetInsertBlock().Parent() failBlock := c.Context.AddBasicBlock(fn, name+"_fail") contBlock := c.Context.AddBasicBlock(fn, name+"_cont") @@ -229,17 +235,20 @@ func (c *Compiler) ArraySetOwnForType(elem Type, vec llvm.Value, idx llvm.Value, } func (c *Compiler) ArrayLen(arr *Symbol, elem Type) llvm.Value { + if !hasConcreteArrayElemType(elem) { + return c.ConstI64(0) + } info := ArrayInfos[elem.Kind()] - - cast := c.ArrayBitCast(arr.Val, info, "arrp") + arrayType := arr.Type.(Array) + cast := c.ArrayBitCast(c.arrayDataValue(arr.Val, arrayType), info, "arrp") fnTy, fn := c.GetCFunc(info.LenName) return c.builder.CreateCall(fnTy, fn, []llvm.Value{cast}, "len") } func (c *Compiler) ArrayGet(arr *Symbol, elem Type, idx llvm.Value) llvm.Value { info := ArrayInfos[elem.Kind()] - - cast := c.ArrayBitCast(arr.Val, info, "arrp") + arrayType := arr.Type.(Array) + cast := c.ArrayBitCast(c.arrayDataValue(arr.Val, arrayType), info, "arrp") fnTy, fn := c.GetCFunc(info.GetName) return c.builder.CreateCall(fnTy, fn, []llvm.Value{cast, idx}, "get") } @@ -250,7 +259,8 @@ func (c *Compiler) ArrayGet(arr *Symbol, elem Type, idx llvm.Value) llvm.Value { // Use this for internal operations where the value is immediately passed to set/push. func (c *Compiler) ArrayGetBorrowed(arr *Symbol, elem Type, idx llvm.Value) llvm.Value { info := ArrayInfos[elem.Kind()] - cast := c.ArrayBitCast(arr.Val, info, "arrp") + arrayType := arr.Type.(Array) + cast := c.ArrayBitCast(c.arrayDataValue(arr.Val, arrayType), info, "arrp") // For strings, use the borrow function to avoid unnecessary strdup if elem.Kind() == StrKind { @@ -265,11 +275,59 @@ func (c *Compiler) ArrayGetBorrowed(arr *Symbol, elem Type, idx llvm.Value) llvm // Array compilation functions -// compileArrayExpression materializes simple array literals into runtime vectors. -// Currently supports only a single row with no headers, e.g. [1 2 3 4]. -func (c *Compiler) compileArrayExpression(e *ast.ArrayLiteral, _ []*ast.Identifier) (res []*Symbol) { +// compileArrayExpression materializes a bracket literal according to its solved +// Array or Table type. +func (c *Compiler) compileArrayExpression(e *ast.ArrayLiteral, _ []*ast.Identifier) []*Symbol { lit, info := c.resolveArrayLiteralRewrite(e) - return []*Symbol{c.compileArrayLiteralInDomain(lit, info, nil, nil)} + switch typ := info.OutTypes[0].(type) { + case Array: + return []*Symbol{c.compileArray(lit, info, typ, nil, nil)} + case Table: + return []*Symbol{c.compileTable(lit, typ)} + default: + c.Errors = append(c.Errors, &token.CompileError{ + Token: lit.Tok(), + Msg: fmt.Sprintf("unsupported bracket literal type %s", typ.String()), + }) + return nil + } +} + +func (c *Compiler) compileArray( + lit *ast.ArrayLiteral, + info *ExprInfo, + arrayType Array, + gateRanges []*RangeInfo, + condExprs []ast.Expression, +) *Symbol { + ranges := mergeUses(gateRanges, info.CollectRanges) + if !arrayLiteralHasArrayCells(lit, arrayType) { + return c.compileScalarArray(lit, info, ranges, condExprs) + } + return c.compileStackedArray(lit, arrayType, ranges, condExprs) +} + +func isInlineArrayCollector(lit *ast.ArrayLiteral) bool { + return !lit.Block && len(lit.Headers) == 0 && len(lit.Rows) == 1 +} + +func arrayLiteralLayoutShape(lit *ast.ArrayLiteral) []uint64 { + if !lit.Block { + if len(lit.Rows) == 0 { + return []uint64{0} + } + return []uint64{uint64(len(lit.Rows[0]))} + } + + columns := uint64(0) + if len(lit.Rows) > 0 { + columns = uint64(len(lit.Rows[0])) + } + return []uint64{uint64(len(lit.Rows)), columns} +} + +func arrayLiteralHasArrayCells(lit *ast.ArrayLiteral, arrayType Array) bool { + return arrayType.Rank > len(arrayLiteralLayoutShape(lit)) } // resolveArrayLiteralRewrite retrieves the potentially rewritten array literal and its ExprInfo. @@ -291,83 +349,48 @@ func (c *Compiler) resolveArrayLiteralRewrite(e *ast.ArrayLiteral) (*ast.ArrayLi return lit, info } -func (c *Compiler) compileArrayLiteralImmediate(lit *ast.ArrayLiteral, info *ExprInfo) (res []*Symbol) { - s := &Symbol{} - - if !(len(lit.Headers) == 0 && (len(lit.Rows) == 0 || len(lit.Rows) == 1)) { - c.Errors = append(c.Errors, &token.CompileError{Token: lit.Tok(), Msg: "2D arrays/tables not implemented yet"}) - return nil +func (c *Compiler) compileFixedArrayLiteral(lit *ast.ArrayLiteral, arrayType Array, dimensions []llvm.Value) *Symbol { + cellCount := 0 + for _, row := range lit.Rows { + cellCount += len(row) } - - // Handle empty array literal: [] - if len(lit.Rows) == 0 { - arr := info.OutTypes[0].(Array) - elemType := arr.ColTypes[0] - - // If element type is unresolved, create a null pointer - // The actual array will be created when the variable is used with a concrete type - if elemType.Kind() == UnresolvedKind { - s.Type = arr - s.Val = llvm.ConstPointerNull(llvm.PointerType(c.Context.Int8Type(), 0)) - return []*Symbol{s} - } - - nConst := c.ConstI64(0) - arrVal := c.CreateArrayForType(elemType, nConst) - - s.Type = arr - s.Val = c.builder.CreateBitCast(arrVal, llvm.PointerType(c.Context.Int8Type(), 0), "arr_i8p") - return []*Symbol{s} + if cellCount == 0 || !hasConcreteArrayElemType(arrayType.ElemType) { + return &Symbol{Type: arrayType, Val: c.createArrayValue(llvm.Value{}, dimensions, arrayType)} } - arr := info.OutTypes[0].(Array) - elemType := arr.ColTypes[0] - - row := lit.Rows[0] - nConst := c.ConstI64(uint64(len(row))) - arrVal := c.CreateArrayForType(elemType, nConst) - - for i, cell := range row { - idx := c.ConstI64(uint64(i)) - c.compileArrayLiteralCell(cell, elemType, func(cellSlot *Symbol) bool { - return c.setArrayCellSlot(arrVal, idx, cellSlot, elemType) - }) + data := c.CreateArrayForType(arrayType.ElemType, c.ConstI64(uint64(cellCount))) + index := 0 + for _, row := range lit.Rows { + for _, cell := range row { + cellIndex := c.ConstI64(uint64(index)) + c.compileArrayLiteralCell(cell, arrayType.ElemType, func(cellSlot *Symbol) bool { + return c.setArrayCellSlot(data, cellIndex, cellSlot, arrayType.ElemType) + }) + index++ + } } - s.Type = arr - s.Val = c.builder.CreateBitCast(arrVal, llvm.PointerType(c.Context.Int8Type(), 0), "arr_i8p") - return []*Symbol{s} + return &Symbol{Type: arrayType, Val: c.createArrayValue(data, dimensions, arrayType)} } -func (c *Compiler) withCollectorLoopNest(ranges []*RangeInfo, probe ast.Expression, condExprs []ast.Expression, body func()) { - probes := make([]ast.Expression, 0, len(condExprs)+1) - probes = append(probes, probe) - probes = append(probes, condExprs...) - c.withLoopNestVersioned(ranges, probes, body) +func (c *Compiler) withCollectorDomain(ranges []*RangeInfo, probe ast.Expression, condExprs []ast.Expression, body func()) { + c.withCondRangeLoop( + ranges, + condExprs, + []ast.Expression{probe}, + "collect_cond_guard", + "collect_cond_if", + "collect_cond_cont", + body, + ) } func (c *Compiler) compileArrayLiteralWithLoops(lit *ast.ArrayLiteral, info *ExprInfo, ranges []*RangeInfo, condExprs []ast.Expression) *Symbol { arr := info.OutTypes[0].(Array) - elemType := arr.ColTypes[0] + elemType := arr.ElemType acc := c.NewArrayAccumulator(arr) - c.withCollectorLoopNest(ranges, lit, condExprs, func() { - if len(condExprs) > 0 { - guardPtr := c.pushBoundsGuard("collect_cond_guard") - cond := c.evalConditions(condExprs, guardPtr) - c.popBoundsGuard() - - ifBlock, contBlock := c.createIfCont(cond, "collect_cond_if", "collect_cond_cont") - - c.builder.SetInsertPointAtEnd(ifBlock) - for _, cell := range lit.Rows[0] { - c.appendArrayLiteralCell(acc, cell, elemType) - } - c.builder.CreateBr(contBlock) - c.builder.SetInsertPointAtEnd(contBlock) - return - } - + c.withCollectorDomain(ranges, lit, condExprs, func() { for _, cell := range lit.Rows[0] { c.appendArrayLiteralCell(acc, cell, elemType) } @@ -376,12 +399,19 @@ func (c *Compiler) compileArrayLiteralWithLoops(lit *ast.ArrayLiteral, info *Exp return c.ArrayAccResult(acc) } -func (c *Compiler) compileArrayLiteralInDomain(lit *ast.ArrayLiteral, info *ExprInfo, gateRanges []*RangeInfo, condExprs []ast.Expression) *Symbol { - collectRanges := mergeUses(gateRanges, info.CollectRanges) - if len(collectRanges) == 0 && len(condExprs) == 0 { - return c.compileArrayLiteralImmediate(lit, info)[0] +func (c *Compiler) compileScalarArray(lit *ast.ArrayLiteral, info *ExprInfo, ranges []*RangeInfo, condExprs []ast.Expression) *Symbol { + if len(ranges) == 0 && len(condExprs) == 0 { + arrayType := info.OutTypes[0].(Array) + var dimensions []llvm.Value + if arrayType.Rank > 1 { + dimensions = make([]llvm.Value, 0, len(info.ArrayShape)) + for _, dimension := range info.ArrayShape { + dimensions = append(dimensions, c.ConstI64(dimension)) + } + } + return c.compileFixedArrayLiteral(lit, arrayType, dimensions) } - return c.compileArrayLiteralWithLoops(lit, info, collectRanges, condExprs) + return c.compileArrayLiteralWithLoops(lit, info, ranges, condExprs) } // withPendingLiteralRanges resolves the solver rewrite on a literal, then @@ -463,16 +493,6 @@ func (c *Compiler) appendArrayLiteral(acc *ArrayAccumulator, lit *ast.ArrayLiter }) } -// appendArrayLiterals dispatches to the appropriate accumulation strategy for -// top-level 1D array-literal outputs from ranged conditional lowering. -// Each collector owns its own local value-range domain and appends -// independently of sibling array outputs. -func (c *Compiler) appendArrayLiterals(accs []*ArrayAccumulator, values []*ast.ArrayLiteral) { - for i, lit := range values { - c.appendArrayLiteral(accs[i], lit) - } -} - func (c *Compiler) storeArrayCellSlotWhenInBounds( cellSlot *Symbol, vals []*Symbol, @@ -569,41 +589,94 @@ func (c *Compiler) pushAccumCellValue(acc *ArrayAccumulator, valSym *Symbol, isT // Array operation functions -// arrayPairMinLen returns min(len(left), len(right)) for two arrays. -func (c *Compiler) arrayPairMinLen(left *Symbol, right *Symbol) llvm.Value { - leftElem := left.Type.(Array).ColTypes[0] - rightElem := right.Type.(Array).ColTypes[0] - leftLen := c.ArrayLen(left, leftElem) - rightLen := c.ArrayLen(right, rightElem) - return c.builder.CreateSelect( - c.builder.CreateICmp(llvm.IntULT, leftLen, rightLen, "cmp_len"), - leftLen, rightLen, "min_len", - ) +// arrayPairShape returns the flat iteration length and dimensions for a zipped +// pair. Every dimension is limited to the shorter corresponding dimension. +func (c *Compiler) arrayPairShape(left *Symbol, right *Symbol) (llvm.Value, []llvm.Value) { + leftDims := c.arrayDimensions(left) + rightDims := c.arrayDimensions(right) + dimensions := make([]llvm.Value, len(leftDims)) + for i := range leftDims { + dimensions[i] = c.builder.CreateSelect( + c.builder.CreateICmp(llvm.IntULT, leftDims[i], rightDims[i], "cmp_array_dimension"), + leftDims[i], rightDims[i], "min_array_dimension", + ) + } + + length := dimensions[0] + for _, dimension := range dimensions[1:] { + length = c.builder.CreateMul(length, dimension, "array_zip_len") + } + return length, dimensions +} + +func (c *Compiler) arrayStrides(dimensions []llvm.Value) []llvm.Value { + strides := make([]llvm.Value, len(dimensions)) + stride := c.ConstI64(1) + for i := len(dimensions) - 1; i >= 0; i-- { + strides[i] = stride + if i > 0 { + stride = c.builder.CreateMul(stride, dimensions[i], "array_stride") + } + } + return strides +} + +func (c *Compiler) arrayPairIndices(iter llvm.Value, resultStrides, leftStrides, rightStrides []llvm.Value) (llvm.Value, llvm.Value) { + remaining := iter + leftIndex := c.ConstI64(0) + rightIndex := c.ConstI64(0) + for i, resultStride := range resultStrides { + coordinate := remaining + if i < len(resultStrides)-1 { + coordinate = c.builder.CreateUDiv(remaining, resultStride, "array_zip_coordinate") + remaining = c.builder.CreateURem(remaining, resultStride, "array_zip_remainder") + } + + leftOffset := c.builder.CreateMul(coordinate, leftStrides[i], "array_zip_left_offset") + rightOffset := c.builder.CreateMul(coordinate, rightStrides[i], "array_zip_right_offset") + leftIndex = c.builder.CreateAdd(leftIndex, leftOffset, "array_zip_left_index") + rightIndex = c.builder.CreateAdd(rightIndex, rightOffset, "array_zip_right_index") + } + return leftIndex, rightIndex } // forEachArrayPair iterates element-wise over an array LHS and either an array -// or scalar RHS, using loopLen as the iteration count. +// or scalar RHS. zipDimensions maps a rank-N array pair's result coordinates +// into each source buffer; it is nil when the RHS is scalar. func (c *Compiler) forEachArrayPair( left *Symbol, right *Symbol, loopLen llvm.Value, + zipDimensions []llvm.Value, body func(iter llvm.Value, leftSym *Symbol, rightSym *Symbol), ) { - leftElem := left.Type.(Array).ColTypes[0] + leftElem := left.Type.(Array).ElemType rightIsArray := right.Type.Kind() == ArrayKind var rightElem Type + var resultStrides, leftStrides, rightStrides []llvm.Value if rightIsArray { - rightElem = right.Type.(Array).ColTypes[0] + rightElem = right.Type.(Array).ElemType + if len(zipDimensions) > 1 { + resultStrides = c.arrayStrides(zipDimensions) + leftStrides = c.arrayStrides(c.arrayDimensions(left)) + rightStrides = c.arrayStrides(c.arrayDimensions(right)) + } } r := c.rangeZeroToN(loopLen) c.createLoop(r, func(iter llvm.Value) { - leftVal := c.ArrayGetBorrowed(left, leftElem, iter) + leftIndex := iter + rightIndex := iter + if len(resultStrides) > 0 { + leftIndex, rightIndex = c.arrayPairIndices(iter, resultStrides, leftStrides, rightStrides) + } + + leftVal := c.ArrayGetBorrowed(left, leftElem, leftIndex) leftSym := &Symbol{Val: leftVal, Type: leftElem} currentRight := right if rightIsArray { - rightVal := c.ArrayGetBorrowed(right, rightElem, iter) + rightVal := c.ArrayGetBorrowed(right, rightElem, rightIndex) currentRight = &Symbol{Val: rightVal, Type: rightElem} } @@ -611,13 +684,10 @@ func (c *Compiler) forEachArrayPair( }) } -// arraySym wraps an array vector and element type as a Symbol, bitcast to the -// opaque i8* array handle used for array values. -func (c *Compiler) arraySym(array llvm.Value, elemType Type) *Symbol { - i8p := llvm.PointerType(c.Context.Int8Type(), 0) +func (c *Compiler) arrayValueSymbol(data llvm.Value, arrayType Array, dimensions []llvm.Value) *Symbol { return &Symbol{ - Type: Array{Headers: nil, ColTypes: []Type{elemType}, Length: 0}, - Val: c.builder.CreateBitCast(array, i8p, "arr_i8p"), + Type: arrayType, + Val: c.createArrayValue(data, dimensions, arrayType), } } @@ -625,20 +695,19 @@ func (c *Compiler) compileArrayArrayInfix(op string, left *Symbol, right *Symbol leftArrType := left.Type.(Array) rightArrType := right.Type.(Array) - leftElem := leftArrType.ColTypes[0] - rightElem := rightArrType.ColTypes[0] + leftElem := leftArrType.ElemType + rightElem := rightArrType.ElemType // Array concatenation: arr1 ⊕ arr2 if op == token.SYM_CONCAT { return c.compileArrayConcat(left, right, leftElem, rightElem, resElem) } - // Element-wise array operation: arr1 op arr2 - // Strategy: iterate to min(len(arr1), len(arr2)) - no implicit padding. - // This mirrors vector-style zip semantics and avoids silently inventing data. - loopLen := c.arrayPairMinLen(left, right) + // Element-wise array operation: arr1 op arr2. Zip every dimension to + // its minimum without implicit padding or invented data. + loopLen, dimensions := c.arrayPairShape(left, right) resVec := c.CreateArrayForType(resElem, loopLen) - c.forEachArrayPair(left, right, loopLen, func(iter llvm.Value, leftSym *Symbol, rightSym *Symbol) { + c.forEachArrayPair(left, right, loopLen, dimensions, func(iter llvm.Value, leftSym *Symbol, rightSym *Symbol) { // compileInfix reads values and produces a new result computed := c.compileInfix(op, leftSym, rightSym, resElem) @@ -653,12 +722,39 @@ func (c *Compiler) compileArrayArrayInfix(op string, left *Symbol, right *Symbol }) // Return result array - return c.arraySym(resVec, resElem) + return c.arrayValueSymbol(resVec, Array{ElemType: resElem, Rank: leftArrType.Rank}, dimensions) } func (c *Compiler) compileArrayConcat(left *Symbol, right *Symbol, leftElem Type, rightElem Type, resElem Type) *Symbol { // Array concatenation: arr1 ⊕ arr2 // Result is [arr1..., arr2...] + arrayType := Array{ElemType: resElem, Rank: left.Type.(Array).Rank} + var dimensions []llvm.Value + if arrayType.Rank > 1 { + leftDims := c.arrayDimensions(left) + rightDims := c.arrayDimensions(right) + zero := c.ConstI64(0) + leftNonEmpty := c.builder.CreateICmp(llvm.IntNE, leftDims[0], zero, "concat_left_nonempty") + rightNonEmpty := c.builder.CreateICmp(llvm.IntNE, rightDims[0], zero, "concat_right_nonempty") + c.requireSameArrayShapeWhen( + c.builder.CreateAnd(leftNonEmpty, rightNonEmpty, "concat_both_nonempty"), + leftDims[1:], + rightDims[1:], + "concat_inner_shape", + ) + + useRightShape := c.builder.CreateAnd(c.builder.CreateNot(leftNonEmpty, "concat_left_empty"), rightNonEmpty, "concat_use_right_shape") + dimensions = []llvm.Value{c.builder.CreateAdd(leftDims[0], rightDims[0], "concat_outer_len")} + for i := 1; i < len(leftDims); i++ { + dimensions = append(dimensions, c.builder.CreateSelect(useRightShape, rightDims[i], leftDims[i], "concat_inner_dimension")) + } + } + if !hasConcreteArrayElemType(resElem) { + if arrayType.Rank == 1 { + return c.makeZeroValue(arrayType) + } + return c.arrayValueSymbol(llvm.Value{}, arrayType, dimensions) + } // Get lengths of both arrays leftLen := c.ArrayLen(left, leftElem) @@ -671,20 +767,25 @@ func (c *Compiler) compileArrayConcat(left *Symbol, right *Symbol, leftElem Type resVec := c.CreateArrayForType(resElem, totalLen) // Copy left array elements - c.CopyArrayInto(resVec, left, leftElem, resElem, llvm.Value{}, false) + if hasConcreteArrayElemType(leftElem) { + c.CopyArrayInto(resVec, left, leftElem, resElem, llvm.Value{}, false) + } // Copy right array elements with offset - c.CopyArrayInto(resVec, right, rightElem, resElem, leftLen, true) + if hasConcreteArrayElemType(rightElem) { + c.CopyArrayInto(resVec, right, rightElem, resElem, leftLen, true) + } - // Return concatenated array - return c.arraySym(resVec, resElem) + return c.arrayValueSymbol(resVec, arrayType, dimensions) } func (c *Compiler) compileArrayScalarInfix(op string, arr *Symbol, scalar *Symbol, resElem Type, arrayOnLeft bool) *Symbol { - arrElem := arr.Type.(Array).ColTypes[0] + arrType := arr.Type.(Array) + arrElem := arrType.ElemType loopLen := c.ArrayLen(arr, arrElem) + dimensions := c.arrayValueDimensions(arr) resVec := c.CreateArrayForType(resElem, loopLen) - c.forEachArrayPair(arr, scalar, loopLen, func(iter llvm.Value, elemSym *Symbol, scalarSym *Symbol) { + c.forEachArrayPair(arr, scalar, loopLen, nil, func(iter llvm.Value, elemSym *Symbol, scalarSym *Symbol) { // Respect the original operand order for non-commutative operations var computed *Symbol if arrayOnLeft { @@ -703,19 +804,19 @@ func (c *Compiler) compileArrayScalarInfix(op string, arr *Symbol, scalar *Symbo c.ArraySetOwnForType(resElem, resVec, iter, resultVal) }) - return c.arraySym(resVec, resElem) + return c.arrayValueSymbol(resVec, Array{ElemType: resElem, Rank: arrType.Rank}, dimensions) } // compileArrayMask dispatches value-position comparison masking when at least one // operand is an array. Each position yields the LHS element where the comparison // holds, otherwise 0 — preserving length and position (a 0 marks a failed compare, // never a dropped element). This is Pluto's scalar "comparison yields LHS-or-0" -// applied per element. Array-array zips to min length; array-scalar uses the -// array's length and broadcasts the scalar. +// applied per element. Array-array zips every dimension to its minimum; +// array-scalar uses the array's shape and broadcasts the scalar. func (c *Compiler) compileArrayMask(op string, left *Symbol, right *Symbol, expected Type) *Symbol { l := c.derefIfPointer(left, "") r := c.derefIfPointer(right, "") - resElem := expected.(Array).ColTypes[0] + resElem := expected.(Array).ElemType if l.Type.Kind() == ArrayKind && r.Type.Kind() == ArrayKind { return c.compileArrayArrayMask(op, l, r, resElem) @@ -744,21 +845,23 @@ func (c *Compiler) maskStore(resElem Type, resVec llvm.Value, iter llvm.Value, l } func (c *Compiler) compileArrayArrayMask(op string, left *Symbol, right *Symbol, resElem Type) *Symbol { - loopLen := c.arrayPairMinLen(left, right) + loopLen, dimensions := c.arrayPairShape(left, right) resVec := c.CreateArrayForType(resElem, loopLen) - c.forEachArrayPair(left, right, loopLen, func(iter llvm.Value, leftSym *Symbol, rightSym *Symbol) { + c.forEachArrayPair(left, right, loopLen, dimensions, func(iter llvm.Value, leftSym *Symbol, rightSym *Symbol) { lhs, cond := c.compareScalars(op, leftSym, rightSym) c.maskStore(resElem, resVec, iter, lhs, cond) }) - return c.arraySym(resVec, resElem) + return c.arrayValueSymbol(resVec, Array{ElemType: resElem, Rank: left.Type.(Array).Rank}, dimensions) } func (c *Compiler) compileArrayScalarMask(op string, arr *Symbol, scalar *Symbol, resElem Type, arrayOnLeft bool) *Symbol { - arrElem := arr.Type.(Array).ColTypes[0] + arrType := arr.Type.(Array) + arrElem := arrType.ElemType loopLen := c.ArrayLen(arr, arrElem) + dimensions := c.arrayValueDimensions(arr) resVec := c.CreateArrayForType(resElem, loopLen) - c.forEachArrayPair(arr, scalar, loopLen, func(iter llvm.Value, elemSym *Symbol, scalarSym *Symbol) { + c.forEachArrayPair(arr, scalar, loopLen, nil, func(iter llvm.Value, elemSym *Symbol, scalarSym *Symbol) { // Preserve operand order for non-commutative comparisons. The masked value // is always the comparison's LHS (the array element, or the scalar when it // is on the left), which compareScalars returns alongside the result. @@ -770,14 +873,15 @@ func (c *Compiler) compileArrayScalarMask(op string, arr *Symbol, scalar *Symbol c.maskStore(resElem, resVec, iter, lhs, cond) }) - return c.arraySym(resVec, resElem) + return c.arrayValueSymbol(resVec, Array{ElemType: resElem, Rank: arrType.Rank}, dimensions) } func (c *Compiler) compileArrayUnaryPrefix(op string, arr *Symbol, result Array) *Symbol { arrType := arr.Type.(Array) - elem := arrType.ColTypes[0] - resElem := result.ColTypes[0] + elem := arrType.ElemType + resElem := result.ElemType n := c.ArrayLen(arr, elem) + dimensions := c.arrayValueDimensions(arr) resVec := c.CreateArrayForType(resElem, n) r := c.rangeZeroToN(n) @@ -798,20 +902,23 @@ func (c *Compiler) compileArrayUnaryPrefix(op string, arr *Symbol, result Array) c.ArraySetOwnForType(resElem, resVec, idx, resultVal) }) - return c.arraySym(resVec, resElem) + return c.arrayValueSymbol(resVec, result, dimensions) } // Array string conversion function func (c *Compiler) arrayStrArg(s *Symbol) llvm.Value { arr := s.Type.(Array) - elemType := arr.ColTypes[0] + if arr.Rank > 1 { + return c.arrayNDStrArg(s) + } + elemType := arr.ElemType info, ok := ArrayInfos[elemType.Kind()] if !ok { panic("internal: unsupported array element kind for printing") } - cast := c.ArrayBitCast(s.Val, info, "arr_cast") + cast := c.ArrayBitCast(c.arrayDataValue(s.Val, arr), info, "arr_cast") fnTy, fn := c.GetCFunc(info.StrName) return c.builder.CreateCall(fnTy, fn, []llvm.Value{cast}, "arr_str") } @@ -820,7 +927,7 @@ func (c *Compiler) arrayFormatArg(s *Symbol, info ArrayInfo, elementFormat strin i32 := c.Context.Int32Type() zero := llvm.ConstInt(i32, 0, false) formatArgs := []llvm.Value{ - c.ArrayBitCast(s.Val, info, "arr_format_cast"), + c.ArrayBitCast(c.arrayDataValue(s.Val, s.Type.(Array)), info, "arr_format_cast"), c.constCString(elementFormat), llvm.ConstInt(i32, uint64(len(dynamicArgs)), false), zero, @@ -847,14 +954,6 @@ func (c *Compiler) arrayRangeStrArgs(s *Symbol) (arrayStr llvm.Value, rangeStr l return } -func arrayAccessResultType(arrElemType Type) Type { - if arrElemType.Kind() == StrKind { - // String array gets return owned heap strings. - return StrH{} - } - return arrElemType -} - func (c *Compiler) compileArrayRangeOperands(expr *ast.ArrayRangeExpression) (*Symbol, *Symbol, Array) { arrayLoadName := "" if arrayIdent, ok := expr.Array.(*ast.Identifier); ok { @@ -888,6 +987,56 @@ func (c *Compiler) storeArrayRangeOutput(output *Symbol, value llvm.Value, value c.createStore(value, output.Val, valueType) } +func (c *Compiler) compileArraySubarray(array *Symbol, index llvm.Value) *Symbol { + arrayType := array.Type.(Array) + resultType := arrayIndexResultType(arrayType).(Array) + dimensions := c.arrayDimensions(array)[1:] + subarrayLen := dimensions[0] + for _, dimension := range dimensions[1:] { + subarrayLen = c.builder.CreateMul(subarrayLen, dimension, "array_subarray_len") + } + + data := c.CreateArrayForType(arrayType.ElemType, subarrayLen) + start := c.builder.CreateMul(index, subarrayLen, "array_subarray_start") + c.createLoop(c.rangeZeroToN(subarrayLen), func(iter llvm.Value) { + sourceIndex := c.builder.CreateAdd(start, iter, "array_subarray_index") + value := c.ArrayGetBorrowed(array, arrayType.ElemType, sourceIndex) + c.ArraySetForType(arrayType.ElemType, data, iter, value) + }) + return c.arrayValueSymbol(data, resultType, dimensions) +} + +func (c *Compiler) zeroArraySubarray(array *Symbol) *Symbol { + arrayType := array.Type.(Array) + resultType := arrayIndexResultType(arrayType).(Array) + dimensions := c.arrayDimensions(array)[1:] + length := dimensions[0] + for _, dimension := range dimensions[1:] { + length = c.builder.CreateMul(length, dimension, "array_zero_subarray_len") + } + data := c.CreateArrayForType(arrayType.ElemType, length) + return c.arrayValueSymbol(data, resultType, dimensions) +} + +func (c *Compiler) checkedArraySubarray(array *Symbol, index llvm.Value, inBounds llvm.Value) llvm.Value { + resultType := arrayIndexResultType(array.Type.(Array)).(Array) + resultSlot := c.createEntryBlockAlloca(c.mapToLLVMType(resultType), "array_subarray_checked_mem") + getBlock, missBlock, contBlock := c.createIfElseCont(inBounds, "array_subarray_in_bounds", "array_subarray_oob", "array_subarray_cont") + + c.builder.SetInsertPointAtEnd(getBlock) + subarray := c.compileArraySubarray(array, index) + c.createStore(subarray.Val, resultSlot, resultType) + c.builder.CreateBr(contBlock) + + c.builder.SetInsertPointAtEnd(missBlock) + zero := c.zeroArraySubarray(array) + c.createStore(zero.Val, resultSlot, resultType) + c.builder.CreateBr(contBlock) + + c.builder.SetInsertPointAtEnd(contBlock) + 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. @@ -931,14 +1080,17 @@ func (c *Compiler) compileArrayRangeBasic(expr *ast.ArrayRangeExpression) []*Sym defer c.freeConsumedTemporary(expr.Array, []*Symbol{arraySym}) // Scalar index - element access - arrElemType := arrType.ColTypes[0] - resultType := arrayAccessResultType(arrElemType) + arrElemType := arrType.ElemType + resultType := arrayIndexResultType(arrType) idxVal := c.normalizeArrayIndex(idxSym) // Bounds are checked in IR before get. OOB reads materialize a zero value // for expression evaluation; assignment/condition guards decide whether the // enclosing statement commits. if c.currentLoopBoundsMode() == loopBoundsModeAffineFast && c.isFastAffineAccess(expr) { + if arrType.Rank > 1 { + return []*Symbol{c.compileArraySubarray(arraySym, idxVal)} + } elemVal := c.ArrayGet(arraySym, arrElemType, idxVal) return []*Symbol{{Type: resultType, Val: elemVal}} } @@ -948,6 +1100,9 @@ func (c *Compiler) compileArrayRangeBasic(expr *ast.ArrayRangeExpression) []*Sym if ctx != nil && len(ctx.boundsStack) > 0 && !c.inArrayLiteralCellMode() { c.recordStmtBoundsCheck(inBounds) } + if arrType.Rank > 1 { + return []*Symbol{{Type: resultType, Val: c.checkedArraySubarray(arraySym, idxVal, inBounds)}} + } elemVal := c.checkedArrayGet(arraySym, arrElemType, resultType, idxVal, inBounds) return []*Symbol{{Type: resultType, Val: elemVal}} @@ -971,11 +1126,16 @@ func (c *Compiler) compileArrayRangeRanges(info *ExprInfo, dest []*ast.Identifie panic("internal: range-lowered array access requires scalar index") } - arrElemType := arrType.ColTypes[0] - resultType := arrayAccessResultType(arrElemType) + arrElemType := arrType.ElemType + resultType := arrayIndexResultType(arrType) idxVal := c.normalizeArrayIndex(idxSym) if c.currentLoopBoundsMode() == loopBoundsModeAffineFast && c.isFastAffineAccess(rew) { + if arrType.Rank > 1 { + subarray := c.compileArraySubarray(arraySym, idxVal) + c.storeArrayRangeOutput(output, subarray.Val, resultType) + return + } elemVal := c.ArrayGet(arraySym, arrElemType, idxVal) c.storeArrayRangeOutput(output, elemVal, resultType) return @@ -986,12 +1146,20 @@ func (c *Compiler) compileArrayRangeRanges(info *ExprInfo, dest []*ast.Identifie storeBlock, missBlock, contBlock := c.createIfElseCont(inBounds, "arr_range_store", "arr_range_zero", "arr_range_cont") c.builder.SetInsertPointAtEnd(storeBlock) - elemVal := c.ArrayGet(arraySym, arrElemType, idxVal) - c.storeArrayRangeOutput(output, elemVal, resultType) + if arrType.Rank > 1 { + subarray := c.compileArraySubarray(arraySym, idxVal) + c.storeArrayRangeOutput(output, subarray.Val, resultType) + } else { + elemVal := c.ArrayGet(arraySym, arrElemType, idxVal) + c.storeArrayRangeOutput(output, elemVal, resultType) + } c.builder.CreateBr(contBlock) c.builder.SetInsertPointAtEnd(missBlock) zeroVal := c.makeZeroValue(resultType) + if arrType.Rank > 1 { + zeroVal = c.zeroArraySubarray(arraySym) + } c.storeArrayRangeOutput(output, zeroVal.Val, zeroVal.Type) c.builder.CreateBr(contBlock) @@ -1002,8 +1170,13 @@ func (c *Compiler) compileArrayRangeRanges(info *ExprInfo, dest []*ast.Identifie storeBlock, contBlock := c.createIfCont(inBounds, "arr_range_store", "arr_range_cont") c.builder.SetInsertPointAtEnd(storeBlock) - elemVal := c.ArrayGet(arraySym, arrElemType, idxVal) - c.storeArrayRangeOutput(output, elemVal, resultType) + if arrType.Rank > 1 { + subarray := c.compileArraySubarray(arraySym, idxVal) + c.storeArrayRangeOutput(output, subarray.Val, resultType) + } else { + elemVal := c.ArrayGet(arraySym, arrElemType, idxVal) + c.storeArrayRangeOutput(output, elemVal, resultType) + } c.builder.CreateBr(contBlock) c.builder.SetInsertPointAtEnd(contBlock) diff --git a/compiler/array_nd.go b/compiler/array_nd.go new file mode 100644 index 00000000..91c8afb4 --- /dev/null +++ b/compiler/array_nd.go @@ -0,0 +1,281 @@ +package compiler + +import ( + "github.com/thiremani/pluto/ast" + "tinygo.org/x/go-llvm" +) + +type stackedArrayAccumulator struct { + array *ArrayAccumulator + arrayType Array + outerLenSlot llvm.Value + childDimSlots []llvm.Value +} + +func (c *Compiler) compileFixedStackedArray(lit *ast.ArrayLiteral, arrayType Array) *Symbol { + children := make([]ast.Expression, 0) + for _, row := range lit.Rows { + children = append(children, row...) + } + + childSymbols := make([]*Symbol, 0, len(children)) + for _, child := range children { + childSymbols = append(childSymbols, c.compileArrayValuedCell(child)) + } + + childType := childSymbols[0].Type.(Array) + childDims := c.arrayDimensions(childSymbols[0]) + for i := 1; i < len(childSymbols); i++ { + c.requireSameArrayShape(childDims, c.arrayDimensions(childSymbols[i]), "stacked_array_shape") + } + + dimensions := make([]llvm.Value, 0, arrayType.Rank) + for _, dimension := range arrayLiteralLayoutShape(lit) { + dimensions = append(dimensions, c.ConstI64(dimension)) + } + dimensions = append(dimensions, childDims...) + if !hasConcreteArrayElemType(arrayType.ElemType) { + return &Symbol{Type: arrayType, Val: c.createArrayValue(llvm.Value{}, dimensions, arrayType)} + } + + childLen := c.ArrayLen(childSymbols[0], childType.ElemType) + totalLen := c.builder.CreateMul(c.ConstI64(uint64(len(childSymbols))), childLen, "stacked_array_len") + data := c.CreateArrayForType(arrayType.ElemType, totalLen) + for i, child := range childSymbols { + childElemType := child.Type.(Array).ElemType + if !hasConcreteArrayElemType(childElemType) { + continue + } + offset := llvm.Value{} + applyOffset := i > 0 + if applyOffset { + offset = c.builder.CreateMul(c.ConstI64(uint64(i)), childLen, "stacked_array_offset") + } + c.CopyArrayInto(data, child, childElemType, arrayType.ElemType, offset, applyOffset) + c.freeConsumedTemporary(children[i], []*Symbol{child}) + } + + return &Symbol{Type: arrayType, Val: c.createArrayValue(data, dimensions, arrayType)} +} + +func (c *Compiler) compileArrayValuedCell(child ast.Expression) *Symbol { + var compiled *Symbol + c.withArrayLiteralCellMode(func() { + compiled = c.compileExpression(child, nil)[0] + }) + return c.derefIfPointer(compiled, "stacked_array_child") +} + +func (c *Compiler) compileStackedArray( + lit *ast.ArrayLiteral, + arrayType Array, + ranges []*RangeInfo, + condExprs []ast.Expression, +) *Symbol { + if len(ranges) == 0 && len(condExprs) == 0 { + return c.compileFixedStackedArray(lit, arrayType) + } + + acc := c.newStackedArrayAccumulator(arrayType) + c.withCollectorDomain(ranges, lit, condExprs, func() { + for _, child := range ast.ExprChildren(lit) { + c.appendStackedArrayChild(acc, child) + } + }) + return c.stackedArrayAccumulatorResult(acc) +} + +func (c *Compiler) newStackedArrayAccumulator(arrayType Array) *stackedArrayAccumulator { + // The runtime accumulator owns the flat leaf buffer. Shape is tracked + // separately while lower-rank child arrays are appended. + outerLenSlot := c.createEntryBlockAlloca(c.mapToLLVMType(I64), "stacked_array_outer_len_mem") + c.createStore(c.ConstI64(0), outerLenSlot, I64) + + childDimSlots := make([]llvm.Value, arrayType.Rank-1) + for i := range childDimSlots { + childDimSlots[i] = c.createEntryBlockAlloca(c.mapToLLVMType(I64), "stacked_array_dimension_mem") + c.createStore(c.ConstI64(0), childDimSlots[i], I64) + } + + var array *ArrayAccumulator + if hasConcreteArrayElemType(arrayType.ElemType) { + array = c.NewArrayAccumulator(Array{ElemType: arrayType.ElemType, Rank: 1}) + } + + return &stackedArrayAccumulator{ + array: array, + arrayType: arrayType, + outerLenSlot: outerLenSlot, + childDimSlots: childDimSlots, + } +} + +func (c *Compiler) appendStackedArrayCollector(acc *stackedArrayAccumulator, lit *ast.ArrayLiteral) { + c.withPendingLiteralRanges(lit, func(resolved *ast.ArrayLiteral) { + for _, child := range ast.ExprChildren(resolved) { + c.appendStackedArrayChild(acc, child) + } + }) +} + +func (c *Compiler) stackedArrayAccumulatorResult(acc *stackedArrayAccumulator) *Symbol { + dimensions := make([]llvm.Value, 0, acc.arrayType.Rank) + dimensions = append(dimensions, c.createLoad(acc.outerLenSlot, I64, "stacked_array_outer_len")) + for _, slot := range acc.childDimSlots { + dimensions = append(dimensions, c.createLoad(slot, I64, "stacked_array_dimension")) + } + + data := llvm.Value{} + if acc.array != nil { + data = acc.array.Vec + } + return &Symbol{Type: acc.arrayType, Val: c.createArrayValue(data, dimensions, acc.arrayType)} +} + +func (c *Compiler) appendStackedArrayChild( + acc *stackedArrayAccumulator, + child ast.Expression, +) { + childSymbol := c.compileArrayValuedCell(child) + childType := childSymbol.Type.(Array) + childDimensions := c.arrayDimensions(childSymbol) + outerLen := c.createLoad(acc.outerLenSlot, I64, "stacked_array_outer_len") + firstChild := c.builder.CreateICmp(llvm.IntEQ, outerLen, c.ConstI64(0), "stacked_array_first_child") + + for i, dimension := range childDimensions { + stored := c.createLoad(acc.childDimSlots[i], I64, "stacked_array_dimension") + equal := c.builder.CreateICmp(llvm.IntEQ, stored, dimension, "stacked_array_shape") + c.failShapeOnFalse(c.builder.CreateOr(firstChild, equal, "stacked_array_shape_compatible"), stored, dimension, "stacked_array_shape") + c.createStore(c.builder.CreateSelect(firstChild, dimension, stored, "stacked_array_dimension_value"), acc.childDimSlots[i], I64) + } + + if acc.array != nil && hasConcreteArrayElemType(childType.ElemType) { + length := c.ArrayLen(childSymbol, childType.ElemType) + c.createLoop(c.rangeZeroToN(length), func(iter llvm.Value) { + value := c.ArrayGetBorrowed(childSymbol, childType.ElemType, iter) + c.pushAccumCellValue(acc.array, &Symbol{Val: value, Type: childType.ElemType}, false, acc.array.ElemType) + }) + } + + c.createStore(c.builder.CreateAdd(outerLen, c.ConstI64(1), "stacked_array_outer_len_next"), acc.outerLenSlot, I64) + c.freeTemporary(child, []*Symbol{childSymbol}) +} + +func (c *Compiler) createArrayValue(data llvm.Value, dimensions []llvm.Value, arrayType Array) llvm.Value { + i8p := llvm.PointerType(c.Context.Int8Type(), 0) + if data.IsNil() { + data = llvm.ConstPointerNull(i8p) + } else { + data = c.builder.CreateBitCast(data, i8p, "array_data") + } + if arrayType.Rank == 1 { + return data + } + + value := llvm.Undef(c.mapToLLVMType(arrayType)) + value = c.builder.CreateInsertValue(value, data, 0, "array_data_value") + for i, dimension := range dimensions { + value = c.builder.CreateInsertValue(value, dimension, i+1, "array_dimension") + } + return value +} + +func (c *Compiler) arrayDataValue(value llvm.Value, arrayType Array) llvm.Value { + if arrayType.Rank == 1 { + return value + } + return c.builder.CreateExtractValue(value, 0, "array_data") +} + +func (c *Compiler) arrayDimensions(symbol *Symbol) []llvm.Value { + arrayType := symbol.Type.(Array) + if arrayType.Rank == 1 { + return []llvm.Value{c.ArrayLen(symbol, arrayType.ElemType)} + } + + dimensions := make([]llvm.Value, arrayType.Rank) + for i := range dimensions { + dimensions[i] = c.builder.CreateExtractValue(symbol.Val, i+1, "array_dimension") + } + return dimensions +} + +// arrayValueDimensions returns dimensions carried directly by an array value. +// Rank-1 length lives in the runtime vector rather than the LLVM array value. +func (c *Compiler) arrayValueDimensions(symbol *Symbol) []llvm.Value { + if symbol.Type.(Array).Rank == 1 { + return nil + } + return c.arrayDimensions(symbol) +} + +func (c *Compiler) requireSameArrayShape(left, right []llvm.Value, name string) { + for i := range left { + equal := c.builder.CreateICmp(llvm.IntEQ, left[i], right[i], name) + c.failShapeOnFalse(equal, left[i], right[i], name) + } +} + +func (c *Compiler) requireSameArrayShapeWhen(condition llvm.Value, left, right []llvm.Value, name string) { + for i := range left { + equal := c.builder.CreateICmp(llvm.IntEQ, left[i], right[i], name) + compatible := c.builder.CreateOr(c.builder.CreateNot(condition, name+"_skip"), equal, name+"_compatible") + c.failShapeOnFalse(compatible, left[i], right[i], name) + } +} + +// failShapeOnFalse aborts with a shape-mismatch diagnostic when ok is false. +// The fail path only executes on mismatch, so expected/got are always the +// offending dimension pair. +func (c *Compiler) failShapeOnFalse(ok llvm.Value, expected, got llvm.Value, name string) { + fn := c.builder.GetInsertBlock().Parent() + failBlock := c.Context.AddBasicBlock(fn, name+"_fail") + contBlock := c.Context.AddBasicBlock(fn, name+"_cont") + c.builder.CreateCondBr(ok, contBlock, failBlock) + + c.builder.SetInsertPointAtEnd(failBlock) + failTy, failFn := c.GetCFunc(ARRAY_SHAPE_FAIL) + c.builder.CreateCall(failTy, failFn, []llvm.Value{expected, got}, "") + c.builder.CreateUnreachable() + + c.builder.SetInsertPointAtEnd(contBlock) +} + +func (c *Compiler) copyArrayValue(value llvm.Value, arrayType Array) llvm.Value { + data := c.copyArray(c.arrayDataValue(value, arrayType), arrayType.ElemType) + if arrayType.Rank == 1 { + return data + } + + dimensions := make([]llvm.Value, arrayType.Rank) + for i := range dimensions { + dimensions[i] = c.builder.CreateExtractValue(value, i+1, "array_dimension") + } + return c.createArrayValue(data, dimensions, arrayType) +} + +func (c *Compiler) arrayNDStrArg(s *Symbol) llvm.Value { + arrayType := s.Type.(Array) + dimensions := c.arrayDimensions(s) + i64 := c.Context.Int64Type() + dimensionsType := llvm.ArrayType(i64, arrayType.Rank) + dimensionsValue := c.createEntryBlockAlloca(dimensionsType, "array_dimensions") + zero := c.ConstI64(0) + for i, dimension := range dimensions { + slot := c.builder.CreateGEP(dimensionsType, dimensionsValue, []llvm.Value{zero, c.ConstI64(uint64(i))}, "array_dimension_slot") + c.builder.CreateStore(dimension, slot) + } + + runtimeKind := runtimeElementKinds[IntKind] + if hasConcreteArrayElemType(arrayType.ElemType) { + runtimeKind = runtimeElementKinds[arrayType.ElemType.Kind()] + } + kind := llvm.ConstInt(c.Context.Int32Type(), runtimeKind, false) + fnType, fn := c.GetCFunc(ARRAY_ND_STR) + return c.builder.CreateCall(fnType, fn, []llvm.Value{ + c.arrayDataValue(s.Val, arrayType), + kind, + c.ConstI64(uint64(arrayType.Rank)), + c.builder.CreateBitCast(dimensionsValue, llvm.PointerType(i64, 0), "array_dimensions_ptr"), + }, "array_nd_str") +} diff --git a/compiler/bounds.go b/compiler/bounds.go index 01f7781b..4ee21fd4 100644 --- a/compiler/bounds.go +++ b/compiler/bounds.go @@ -183,9 +183,18 @@ func (c *Compiler) withActiveBoundsGuard( return true } -// arrayIndexInBounds checks idx against [0, len(arr)). -func (c *Compiler) arrayIndexInBounds(arr *Symbol, elem Type, idx llvm.Value) llvm.Value { +func (c *Compiler) arrayIndexLength(arr *Symbol, elem Type) llvm.Value { + arrayType := arr.Type.(Array) length := c.ArrayLen(arr, elem) + if arrayType.Rank > 1 { + length = c.arrayDimensions(arr)[0] + } + return length +} + +// arrayIndexInBounds checks idx against the array's outer dimension. +func (c *Compiler) arrayIndexInBounds(arr *Symbol, elem Type, idx llvm.Value) llvm.Value { + length := c.arrayIndexLength(arr, elem) // Unsigned compare rejects negative indices as well (two's-complement wrap // makes them larger than any valid length). return c.builder.CreateICmp(llvm.IntULT, idx, length, "idx_in_bounds") @@ -448,7 +457,7 @@ func (c *Compiler) affineLoopBoundsForComponents( minIdx := c.affineIndexAt(form.coeff, form.bias, minSrc, "idx_affine_min") maxIdx := c.affineIndexAt(form.coeff, form.bias, maxSrc, "idx_affine_max") - length := c.ArrayLen(arr, arrElem) + length := c.arrayIndexLength(arr, arrElem) zero := c.ConstI64(0) lowOK := c.builder.CreateICmp(llvm.IntSGE, minIdx, zero, "idx_affine_low_ok") highOK := c.builder.CreateICmp(llvm.IntSLT, maxIdx, length, "idx_affine_high_ok") @@ -491,10 +500,10 @@ func (c *Compiler) arraySymbolForAffineGuard(arrayExpr ast.Expression) (*Symbol, } arraySym := c.derefIfPointer(raw, ident.Value+"_affine_arr") arrType, ok := arraySym.Type.(Array) - if !ok || len(arrType.ColTypes) == 0 { + if !ok || arrType.ElemType == nil { return nil, nil, false } - return arraySym, arrType.ColTypes[0], true + return arraySym, arrType.ElemType, true } func (c *Compiler) affineVersioningGuard(exprs []ast.Expression, ranges []*RangeInfo) (llvm.Value, map[*ast.ArrayRangeExpression]struct{}, bool) { diff --git a/compiler/cfuncs.go b/compiler/cfuncs.go index 41e2e842..20c19aca 100644 --- a/compiler/cfuncs.go +++ b/compiler/cfuncs.go @@ -18,6 +18,9 @@ const ( STR_QUOTE = "str_quote" STR_QUOTE_PREFIX = "str_quote_prefix" STR_HEX = "str_hex" + ARRAY_ND_STR = "array_nd_str" + ARRAY_SHAPE_FAIL = "array_shape_fail" + TABLE_STR = "table_str" // Array I64 functions ARR_I64_NEW = "arr_i64_new" @@ -94,6 +97,12 @@ func (c *Compiler) GetFnType(name string) llvm.Type { return llvm.FunctionType(charPtr, []llvm.Type{charPtr, i64}, false) case STR_HEX: return llvm.FunctionType(charPtr, []llvm.Type{charPtr, i64, c.Context.Int32Type(), c.Context.Int32Type(), c.Context.Int32Type()}, false) + case ARRAY_ND_STR: + return llvm.FunctionType(charPtr, []llvm.Type{charPtr, i32, i64, llvm.PointerType(i64, 0)}, false) + case ARRAY_SHAPE_FAIL: + return llvm.FunctionType(c.Context.VoidType(), []llvm.Type{i64, i64}, false) + case TABLE_STR: + return llvm.FunctionType(charPtr, []llvm.Type{i64, i64, charPtr, charPtr, charPtr}, false) // Array I64 functions case ARR_I64_NEW: diff --git a/compiler/collect.go b/compiler/collect.go index dd6ffea8..719f77af 100644 --- a/compiler/collect.go +++ b/compiler/collect.go @@ -63,13 +63,14 @@ func (c *Compiler) prepareCollectorTreeFor(expr ast.Expression, gateRanges []*Ra func (c *Compiler) materializeCollectorLiteral(lit *ast.ArrayLiteral, gateRanges []*RangeInfo, condExprs []ast.Expression) (ast.Expression, []string) { resolved, info := c.resolveArrayLiteralRewrite(lit) - sym := c.compileArrayLiteralInDomain(resolved, info, gateRanges, condExprs) + arrayType := info.OutTypes[0].(Array) + sym := c.compileArray(resolved, info, arrayType, gateRanges, condExprs) ident, temp := c.newMaterializedCollectorTemp(sym) return ident, []string{temp} } func (c *Compiler) prepareCollectorExpr(expr ast.Expression, gateRanges []*RangeInfo, condExprs []ast.Expression) (ast.Expression, []string) { - if lit, ok := expr.(*ast.ArrayLiteral); ok { + if lit, ok := expr.(*ast.ArrayLiteral); ok && isInlineArrayCollector(lit) { return c.materializeCollectorLiteral(lit, gateRanges, condExprs) } diff --git a/compiler/compiler.go b/compiler/compiler.go index bd5ad3d5..63cc8ab5 100644 --- a/compiler/compiler.go +++ b/compiler/compiler.go @@ -493,9 +493,27 @@ func (c *Compiler) mapToLLVMType(t Type) llvm.Type { elemLLVM := c.mapToLLVMType(ptrType.Elem) return llvm.PointerType(elemLLVM, 0) case ArrayKind: - // Arrays are backed by runtime dynamic vectors (opaque C structs). - // Model them as opaque pointers here to interop cleanly with the C runtime. - return llvm.PointerType(c.Context.Int8Type(), 0) + arrayType := t.(Array) + arrayPtr := llvm.PointerType(c.Context.Int8Type(), 0) + if arrayType.Rank == 1 { + return arrayPtr + } + i64 := c.Context.Int64Type() + fields := make([]llvm.Type, arrayType.Rank+1) + fields[0] = arrayPtr + for i := 1; i < len(fields); i++ { + fields[i] = i64 + } + return llvm.StructType(fields, false) + case TableKind: + table := t.(Table) + fields := make([]llvm.Type, len(table.Columns)+1) + fields[0] = c.Context.Int64Type() + arrayPtr := llvm.PointerType(c.Context.Int8Type(), 0) + for i := range table.Columns { + fields[i+1] = arrayPtr + } + return llvm.StructType(fields, false) case StructKind: return c.getOrCreateStructLLVMType(t.(Struct)) default: @@ -784,10 +802,19 @@ func (c *Compiler) makeZeroValue(symType Type) *Symbol { s.Val = c.copyString(s.Val) } case ArrayKind: - // Zero value for arrays is null pointer (similar to static empty string for Str). - // Runtime functions handle null gracefully: free(null) is no-op, len(null) returns 0. - // This avoids heap allocation for zero values that may be immediately overwritten. - s.Val = llvm.ConstPointerNull(llvm.PointerType(c.Context.Int8Type(), 0)) + arrayType := symType.(Array) + dimensions := make([]llvm.Value, arrayType.Rank) + for i := range dimensions { + dimensions[i] = c.ConstI64(0) + } + s.Val = c.createArrayValue(llvm.Value{}, dimensions, arrayType) + case TableKind: + tableType := symType.(Table) + columns := make([]llvm.Value, len(tableType.Columns)) + for i := range columns { + columns[i] = llvm.ConstPointerNull(llvm.PointerType(c.Context.Int8Type(), 0)) + } + 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: @@ -885,6 +912,28 @@ func (c *Compiler) coerceSymbolForType(sym *Symbol, target Type, loadName string } } + targetArray, targetIsArray := target.(Array) + sourceArray, sourceIsArray := derefed.Type.(Array) + if targetIsArray && sourceIsArray && sourceArray.ElemType.Kind() == EmptyKind && sourceArray.Rank == targetArray.Rank { + return &Symbol{ + Val: derefed.Val, + Type: targetArray, + FuncArg: derefed.FuncArg, + Borrowed: derefed.Borrowed, + ReadOnly: derefed.ReadOnly, + } + } + if targetIsArray && sourceIsArray && sourceArray.Rank == 1 && sourceArray.ElemType.Kind() == EmptyKind { + zero := c.makeZeroValue(targetArray) + return &Symbol{ + Val: zero.Val, + Type: targetArray, + FuncArg: derefed.FuncArg, + Borrowed: derefed.Borrowed, + ReadOnly: derefed.ReadOnly, + } + } + return derefed } @@ -896,7 +945,25 @@ func (c *Compiler) storeSymbolToSlot(dst *Symbol, src *Symbol, target Type, load if target.Kind() != ptrType.Elem.Kind() { target = ptrType.Elem } - coerced := c.coerceSymbolForType(src, target, loadName) + + source := c.derefIfPointer(src, loadName) + targetArray, targetIsArray := target.(Array) + sourceArray, sourceIsArray := source.Type.(Array) + if targetIsArray && sourceIsArray && sourceArray.Rank == 1 && sourceArray.ElemType.Kind() == EmptyKind && targetArray.Rank > 1 { + currentValue := c.createLoad(dst.Val, targetArray, "array_reset_shape") + current := &Symbol{Val: currentValue, Type: targetArray} + dimensions := c.arrayDimensions(current) + dimensions[0] = c.ConstI64(0) + source = &Symbol{ + Val: c.createArrayValue(llvm.Value{}, dimensions, targetArray), + Type: targetArray, + FuncArg: source.FuncArg, + Borrowed: source.Borrowed, + ReadOnly: source.ReadOnly, + } + } + + coerced := c.coerceSymbolForType(source, target, "") c.createStore(coerced.Val, dst.Val, coerced.Type) return coerced } @@ -939,8 +1006,14 @@ func (c *Compiler) freeValue(val llvm.Value, typ Type) { case StrG: // Static strings live forever, no free needed case Array: - if len(t.ColTypes) > 0 && t.ColTypes[0].Kind() != UnresolvedKind { - c.freeArray(val, t.ColTypes[0]) + if hasConcreteArrayElemType(t.ElemType) { + c.freeArray(c.arrayDataValue(val, t), t.ElemType) + } + case Table: + for i, column := range t.Columns { + if column.ElemType.Kind() != UnresolvedKind { + c.freeArray(c.tableColumnValue(val, i), column.ElemType) + } } case ArrayRange: // Release the backing array payload. Borrowed views are skipped by callers. @@ -956,6 +1029,8 @@ func typeNeedsCleanup(typ Type) bool { case StrH: return true case Array: + return hasConcreteArrayElemType(t.ElemType) + case Table: return true case ArrayRange: return true @@ -1478,8 +1553,7 @@ func setInstAlignment(inst llvm.Value, t Type) { inst.SetAlignment(8) case Range: setInstAlignment(inst, typ.Iter) - case Array: - // Arrays are represented as opaque pointers to runtime vectors + case Array, Table: inst.SetAlignment(8) case ArrayRange: // ArrayRange is a struct of { i8*, Range }, so align to the largest member, which is i8* @@ -1667,39 +1741,57 @@ func (c *Compiler) compileIdentifier(ident *ast.Identifier) *Symbol { func (c *Compiler) compileDotExpression(expr *ast.DotExpression) []*Symbol { leftSym := c.compileExpression(expr.Left, nil)[0] + leftSym = c.derefIfPointer(leftSym, "dot_left") - structType, ok := leftSym.Type.(Struct) - if !ok { + switch leftType := leftSym.Type.(type) { + case Struct: + for i, field := range leftType.Fields { + if field.Name == expr.Field { + return []*Symbol{{ + Type: field.Type, + Val: c.builder.CreateExtractValue(leftSym.Val, i, expr.Field), + }} + } + } c.Errors = append(c.Errors, &token.CompileError{ Token: expr.Token, - Msg: fmt.Sprintf("field access expects a struct value, got %s", leftSym.Type.String()), + Msg: fmt.Sprintf("unknown struct field %q on %s", expr.Field, leftType.Name), }) - return []*Symbol{{Type: I64, Val: c.ConstI64(0)}} - } + case Table: + for i, column := range leftType.Columns { + if column.Name != expr.Field { + continue + } - fieldIndex := -1 - var fieldType Type = Unresolved{} - for i, field := range structType.Fields { - if field.Name == expr.Field { - fieldIndex = i - fieldType = field.Type - break - } - } + columnType := Array{ElemType: column.ElemType, Rank: 1} + if info := c.ExprCache[key(c.FuncNameMangled, expr)]; info != nil && len(info.OutTypes) > 0 { + if resolved, ok := info.OutTypes[0].(Array); ok { + columnType = resolved + } + } + + columnValue := c.tableColumnValue(leftSym.Val, i) + if column.ElemType.Kind() != UnresolvedKind { + columnValue = c.copyArray(columnValue, column.ElemType) + } + c.freeConsumedTemporary(expr.Left, []*Symbol{leftSym}) - if fieldIndex < 0 { + return []*Symbol{{ + Type: columnType, + Val: columnValue, + }} + } c.Errors = append(c.Errors, &token.CompileError{ Token: expr.Token, - Msg: fmt.Sprintf("unknown struct field %q on %s", expr.Field, structType.Name), + Msg: fmt.Sprintf("unknown table column %q on %s", expr.Field, leftType.String()), + }) + default: + c.Errors = append(c.Errors, &token.CompileError{ + Token: expr.Token, + Msg: fmt.Sprintf("field access expects a struct or table value, got %s", leftSym.Type.String()), }) - return []*Symbol{{Type: I64, Val: c.ConstI64(0)}} } - - fieldVal := c.builder.CreateExtractValue(leftSym.Val, fieldIndex, expr.Field) - return []*Symbol{{ - Type: fieldType, - Val: fieldVal, - }} + return []*Symbol{{Type: I64, Val: c.ConstI64(0)}} } // getRawSymbol looks up a symbol by name without dereferencing pointers. @@ -1743,12 +1835,12 @@ func (c *Compiler) compileInfix(op string, left *Symbol, right *Symbol, expected // Determine element type preference: use expected if it's an array, otherwise // use the available array operand's column type. var elem Type - if expArr, ok := expected.(Array); ok && len(expArr.ColTypes) > 0 { - elem = expArr.ColTypes[0] + if expArr, ok := expected.(Array); ok { + elem = expArr.ElemType } else if l.Type.Kind() == ArrayKind { - elem = l.Type.(Array).ColTypes[0] + elem = l.Type.(Array).ElemType } else { - elem = r.Type.(Array).ColTypes[0] + elem = r.Type.(Array).ElemType } if l.Type.Kind() == ArrayKind && r.Type.Kind() == ArrayKind { @@ -2113,12 +2205,17 @@ func (c *Compiler) cleanupRangeInfixTemps( func (c *Compiler) updateUnresolvedType(name string, sym *Symbol, resolved Type) { switch t := sym.Type.(type) { case Array: - if t.ColTypes[0].Kind() == UnresolvedKind { + 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.ColTypes[0].Kind() == UnresolvedKind { + if t.Array.ElemType.Kind() == UnresolvedKind { sym.Type = resolved Put(c.Scopes, name, sym) } @@ -2617,7 +2714,7 @@ func (c *Compiler) iterOverArrayRangeState(arrRangeSym *Symbol, currentOutput *S arrPtr := c.builder.CreateExtractValue(arrRangeSym.Val, 0, "array_range_ptr") rangeVal := c.builder.CreateExtractValue(arrRangeSym.Val, 1, "array_range_bounds") arraySym := &Symbol{Val: arrPtr, Type: arrRangeType.Array} - elemType := arrRangeType.Array.ColTypes[0] + elemType := arrRangeType.Array.ElemType hasState := currentOutput != nil stateType := llvm.Type{} seed := llvm.Value{} @@ -3210,22 +3307,29 @@ func (c *Compiler) deepCopyIfNeeded(sym *Symbol) *Symbol { ReadOnly: false, } case ArrayKind: - // Deep copy the array arrayType := sym.Type.(Array) - if len(arrayType.ColTypes) > 0 { - // Skip copying if the element type is unresolved (will be resolved later) - if arrayType.ColTypes[0].Kind() == UnresolvedKind { + if arrayType.ElemType != nil { + if !hasConcreteArrayElemType(arrayType.ElemType) { return sym } - copiedArr := c.copyArray(sym.Val, arrayType.ColTypes[0]) return &Symbol{ - Val: copiedArr, - Type: sym.Type, // Arrays don't have Static flag + Val: c.copyArrayValue(sym.Val, arrayType), + Type: sym.Type, FuncArg: false, Borrowed: false, ReadOnly: false, } } + case TableKind: + tableType := sym.Type.(Table) + if !IsFullyResolvedType(tableType) { + return sym + } + return &Symbol{ + Val: c.copyTableValue(sym.Val, tableType), + Type: tableType, + Borrowed: false, + } } // For other types (int, float, range), just return as-is (they're value types) return sym @@ -3412,13 +3516,17 @@ func (c *Compiler) appendPrintSymbol(s *Symbol, expr ast.Expression, formatStr * *toFree = append(*toFree, strPtr) case ArrayKind: arrType := s.Type.(Array) - if len(arrType.ColTypes) == 0 || arrType.ColTypes[0].Kind() == UnresolvedKind { + if arrType.Rank == 1 && !hasConcreteArrayElemType(arrType.ElemType) { *args = append(*args, c.constCString("[]")) return } strPtr := c.arrayStrArg(s) *args = append(*args, strPtr) *toFree = append(*toFree, strPtr) + case TableKind: + strPtr := c.tableStrArg(s) + *args = append(*args, strPtr) + *toFree = append(*toFree, strPtr) case StrKind: *args = append(*args, s.Val) // Heap string temporaries must survive until printf executes. diff --git a/compiler/compiler_test.go b/compiler/compiler_test.go index a20e45d6..04342293 100644 --- a/compiler/compiler_test.go +++ b/compiler/compiler_test.go @@ -773,30 +773,30 @@ func TestCanUseCondSelectWhitelist(t *testing.T) { require.True(t, canUseCondSelect(StrG{})) require.False(t, canUseCondSelect(StrH{})) - require.False(t, canUseCondSelect(Array{ColTypes: []Type{I64}})) + require.False(t, canUseCondSelect(Array{ElemType: I64, Rank: 1})) } -func TestCollectorArrayBindingCannotBeNestedAsCell(t *testing.T) { - script := `i = 0:5 -y = [i + 1] -arr = y + [y] -arr` +func TestArrayOperatorRejectsDifferentRanks(t *testing.T) { + script := `row = [1 2] +matrix = [row] +result = row + matrix +result` ctx := llvm.NewContext() defer ctx.Dispose() - cc := NewCodeCompiler(ctx, "collector_array_binding_nested", "", ast.NewCode()) + cc := NewCodeCompiler(ctx, "array_rank_mismatch", "", ast.NewCode()) program := mustParseScript(t, script) sc := NewScriptCompiler(ctx, program, cc, make(map[string]*Func), cc.Compiler.ExprCache) errs := sc.Compile() - require.NotEmpty(t, errs, "expected nested array cell compilation to fail") + require.NotEmpty(t, errs, "expected different array ranks to fail") messages := make([]string, len(errs)) for i, err := range errs { messages[i] = err.Error() } - require.Contains(t, strings.Join(messages, "\n"), "unsupported array element type [I64] in column 0") + require.Contains(t, strings.Join(messages, "\n"), "operator \"+\" requires arrays with the same rank, got 1 and 2") } func TestAffineArrayIndexExprUsesVersionedLoop(t *testing.T) { @@ -996,7 +996,7 @@ func TestPushValOwnNullStrHEmitsTrapPath(t *testing.T) { entry := ctx.AddBasicBlock(fn, "entry") c.builder.SetInsertPointAtEnd(entry) - acc := c.NewArrayAccumulator(Array{ColTypes: []Type{StrH{}}}) + acc := c.NewArrayAccumulator(Array{ElemType: StrH{}, Rank: 1}) nullStr := llvm.ConstPointerNull(llvm.PointerType(c.Context.Int8Type(), 0)) c.PushValOwn(acc, &Symbol{Val: nullStr, Type: StrH{}}) c.builder.CreateRetVoid() diff --git a/compiler/cond.go b/compiler/cond.go index 7e65dbb8..332e3a66 100644 --- a/compiler/cond.go +++ b/compiler/cond.go @@ -951,10 +951,50 @@ func (c *Compiler) withCondRangeLoop(allRanges []*RangeInfo, condExprs []ast.Exp }) } +type statementArrayCollector struct { + literal *ast.ArrayLiteral + destination *ast.Identifier + oldValue *Symbol + scalar *ArrayAccumulator + stacked *stackedArrayAccumulator +} + +func (c *Compiler) newStatementArrayCollector(lit *ast.ArrayLiteral, destination *ast.Identifier, arrayType Array) *statementArrayCollector { + collector := &statementArrayCollector{ + literal: lit, + destination: destination, + oldValue: c.captureOldValues([]*ast.Identifier{destination})[0], + } + if arrayLiteralHasArrayCells(lit, arrayType) { + collector.stacked = c.newStackedArrayAccumulator(arrayType) + } else { + collector.scalar = c.NewArrayAccumulator(arrayType) + } + return collector +} + +func (c *Compiler) appendStatementArrayCollector(collector *statementArrayCollector) { + if collector.stacked != nil { + c.appendStackedArrayCollector(collector.stacked, collector.literal) + return + } + 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) + } + c.storeAccumulatedArray(collector.destination, result, collector.oldValue) +} + // compileCondRangedStatement lowers ranged statement conditions. // Statement conditions are shared across the whole assignment. They determine // the outer admitted iteration domain, while each RHS expression keeps any -// extra local drivers to itself inside that shared gate. Top-level 1D array +// extra local drivers to itself inside that shared gate. Top-level inline array // literals accumulate across admitted iterations; all other outputs use normal // conditional iteration (last value wins). func (c *Compiler) compileCondRangedStatement(stmt *ast.LetStatement, condRanges []*RangeInfo, condExprs []ast.Expression) { @@ -969,22 +1009,17 @@ func (c *Compiler) compileCondRangedStatement(stmt *ast.LetStatement, condRanges // region safe up front instead of only individual RHS shapes. loopProbes := append([]ast.Expression{}, condExprs...) - accumLits := []*ast.ArrayLiteral{} - accumAccs := []*ArrayAccumulator{} - accumDests := []*ast.Identifier{} - accumOldValues := []*Symbol{} + collectors := []*statementArrayCollector{} targetIdx := 0 for _, expr := range stmt.Value { info := c.ExprCache[key(c.FuncNameMangled, expr)] numOutputs := len(info.OutTypes) - if lit, ok := expr.(*ast.ArrayLiteral); ok && len(lit.Headers) == 0 && len(lit.Rows) == 1 { - accumDest := stmt.Name[targetIdx] - accumLits = append(accumLits, lit) - accumAccs = append(accumAccs, c.NewArrayAccumulator(info.OutTypes[0].(Array))) - accumDests = append(accumDests, accumDest) - accumOldValues = append(accumOldValues, c.captureOldValues([]*ast.Identifier{accumDest})[0]) + if lit, ok := expr.(*ast.ArrayLiteral); ok && isInlineArrayCollector(lit) { + arrayType := info.OutTypes[0].(Array) + collectors = append(collectors, c.newStatementArrayCollector(lit, stmt.Name[targetIdx], arrayType)) + resolvedLit, _ := c.resolveArrayLiteralRewrite(lit) loopProbes = append(loopProbes, resolvedLit) targetIdx += numOutputs @@ -1011,37 +1046,48 @@ func (c *Compiler) compileCondRangedStatement(stmt *ast.LetStatement, condRanges assignSlots = c.createConditionalTempOutputsFor(assignDests, assignOutTypes) } + appendCollectors := func() { + for _, collector := range collectors { + c.appendStatementArrayCollector(collector) + } + } + + // 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() { - c.compileCondRangedIteration(assignExprs, assignSlots, accumAccs, accumLits) + c.compileCondRangedIteration(assignExprs, assignSlots, appendCollectors) }) + c.restoreCondDests(aliases) if hasAssigns { c.commitConditionalOutputs(assignSlots) DeleteBulk(c.Scopes, slotTempStrings(assignSlots)) } - for i, acc := range accumAccs { - result := c.ArrayAccResult(acc) - c.storeValue(accumDests[i].Value, result, false) - if accumOldValues[i] == nil || c.skipBorrowedOldValueFree(accumOldValues[i]) { - continue - } - c.freeSymbolValue(accumOldValues[i], "old_accum") + 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 + } + c.freeSymbolValue(oldValue, "old_accum") +} + // compileCondRangedIteration runs inside the per-iteration body of // compileCondRangedStatement. It stages scalar assignments independently // and appends array literal cells to accumulators. func (c *Compiler) compileCondRangedIteration( assignExprs []ast.Expression, assignSlots []OutputSlot, - accumAccs []*ArrayAccumulator, - accumLits []*ast.ArrayLiteral, + appendCollectors func(), ) { // Accum-only: no assigns, just push cells. if len(assignSlots) == 0 { - c.appendArrayLiterals(accumAccs, accumLits) + appendCollectors() return } @@ -1051,11 +1097,7 @@ func (c *Compiler) compileCondRangedIteration( // leaves that stage temp seeded with the prior value without suppressing // sibling RHS writes. stage := c.stageCondRangedAssignments(assignExprs, assignSlots) - - if len(accumLits) > 0 { - c.appendArrayLiterals(accumAccs, accumLits) - } - + appendCollectors() c.commitCondRangedStages(assignSlots, stage) } diff --git a/compiler/format.go b/compiler/format.go index a014481f..337025b6 100644 --- a/compiler/format.go +++ b/compiler/format.go @@ -53,6 +53,8 @@ func defaultSpecifier(t Type) (string, error) { 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: @@ -464,7 +466,10 @@ func (c *Compiler) tryFormatArrayElements(tok token.Token, value string, mainSym } arr := mainSym.Type.(Array) - elementType := arr.ColTypes[0] + if arr.Rank != 1 { + return formattedMarker{}, false, nil + } + elementType := arr.ElemType conversion := rune(spec.text[len(spec.text)-1]) if !arrayElementSupportsSpecifier(elementType, conversion) { return formattedMarker{}, false, nil @@ -594,9 +599,18 @@ func (c *Compiler) formatAsString(mainSym *Symbol, result *formattedMarker) bool 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) { + result.args = append(result.args, c.constCString("[]")) + break + } strPtr := c.arrayStrArg(mainSym) result.args = append(result.args, strPtr) result.toFree = append(result.toFree, strPtr) + case TableKind: + strPtr := c.tableStrArg(mainSym) + result.args = append(result.args, strPtr) + result.toFree = append(result.toFree, strPtr) case StructKind: fmtStr, fmtArgs, fmtFree := c.structFormatArgs(mainSym) result.text = fmtStr diff --git a/compiler/mangle_test.go b/compiler/mangle_test.go index 13d64c55..fb81bf38 100644 --- a/compiler/mangle_test.go +++ b/compiler/mangle_test.go @@ -295,9 +295,28 @@ func TestMangle(t *testing.T) { modName: "arr", relPath: "", funcName: "len", - args: []Type{Array{ColTypes: []Type{I64}}}, + args: []Type{Array{ElemType: I64, Rank: 1}}, expected: "Pt_3arr_p_3len_f1_Array_t1_I64", }, + { + name: "with rank-2 array type", + modName: "matrix", + relPath: "", + funcName: "shape", + args: []Type{Array{ElemType: F64, Rank: 2}}, + expected: "Pt_6matrix_p_5shape_f1_Array_t1_Array_t1_F64", + }, + { + name: "with table type", + modName: "table", + relPath: "", + funcName: "rows", + args: []Type{Table{Columns: []TableColumn{ + {Name: "Name", ElemType: StrH{}}, + {Name: "Score", ElemType: I64}, + }}}, + expected: "Pt_5table_p_4rows_f1_Table_t4_5nName_StrH_6nScore_I64", + }, { name: "with func type", modName: "hof", diff --git a/compiler/solver.go b/compiler/solver.go index 9911ea7a..8ba81f89 100644 --- a/compiler/solver.go +++ b/compiler/solver.go @@ -2,6 +2,7 @@ package compiler import ( "fmt" + "slices" "github.com/thiremani/pluto/ast" "github.com/thiremani/pluto/token" @@ -37,6 +38,7 @@ type ExprInfo struct { CallParamTypes []Type // Solver-selected call params for the original expression shape 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 } // HasCondScalar returns true if any slot is a scalar conditional expression. @@ -177,60 +179,46 @@ func (ts *TypeSolver) resolveBindingSlotType(name string, currentType, newType T return slotType } -func cloneArrayHeaders(src []string) []string { - if len(src) == 0 { - return nil +func (ts *TypeSolver) concatArrayTypes(leftArr, rightArr Array, tok token.Token) Type { + if leftArr.Rank != rightArr.Rank { + ts.Errors = append(ts.Errors, &token.CompileError{ + Token: tok, + Msg: fmt.Sprintf("cannot concatenate arrays with different ranks: %d and %d", leftArr.Rank, rightArr.Rank), + }) + return Unresolved{} } - return append([]string(nil), src...) -} -func concatWithMetadata(leftArr, rightArr Array, elem Type) Array { - headers := cloneArrayHeaders(leftArr.Headers) - if len(headers) == 0 { - headers = cloneArrayHeaders(rightArr.Headers) - } + leftElemType := leftArr.ElemType + rightElemType := rightArr.ElemType - length := 0 - switch { - case leftArr.Length > 0 && rightArr.Length > 0: - length = leftArr.Length + rightArr.Length - case leftArr.Length > 0: - length = leftArr.Length - case rightArr.Length > 0: - length = rightArr.Length + if leftElemType.Kind() == EmptyKind { + return Array{ElemType: rightElemType, Rank: leftArr.Rank} } - return Array{ - Headers: headers, - ColTypes: []Type{elem}, - Length: length, + if rightElemType.Kind() == EmptyKind { + return Array{ElemType: leftElemType, Rank: leftArr.Rank} } -} - -func (ts *TypeSolver) concatArrayTypes(leftArr, rightArr Array, tok token.Token) Type { - leftElemType := leftArr.ColTypes[0] - rightElemType := rightArr.ColTypes[0] if leftElemType.Kind() == UnresolvedKind { - return concatWithMetadata(leftArr, rightArr, rightElemType) + return Array{ElemType: rightElemType, Rank: leftArr.Rank} } if rightElemType.Kind() == UnresolvedKind { - return concatWithMetadata(leftArr, rightArr, leftElemType) + return Array{ElemType: leftElemType, Rank: leftArr.Rank} } if leftElemType.Kind() == rightElemType.Kind() { // For string arrays, normalize to StrH if either side is StrH. // This ensures consistent ownership semantics (runtime always heap-copies). if leftElemType.Kind() == StrKind && (IsStrH(leftElemType) || IsStrH(rightElemType)) { - return concatWithMetadata(leftArr, rightArr, StrH{}) + return Array{ElemType: StrH{}, Rank: leftArr.Rank} } - return concatWithMetadata(leftArr, rightArr, leftElemType) + return Array{ElemType: leftElemType, Rank: leftArr.Rank} } if (leftElemType.Kind() == IntKind && rightElemType.Kind() == FloatKind) || (leftElemType.Kind() == FloatKind && rightElemType.Kind() == IntKind) { - return concatWithMetadata(leftArr, rightArr, Float{Width: 64}) + return Array{ElemType: Float{Width: 64}, Rank: leftArr.Rank} } ce := &token.CompileError{ @@ -242,7 +230,7 @@ func (ts *TypeSolver) concatArrayTypes(leftArr, rightArr Array, tok token.Token) } func (ts *TypeSolver) bindArrayOperand(expr ast.Expression, arr Array) { - // Update the ExprCache with refined array metadata (headers, length). + // Update the ExprCache with the refined array element type. // Note: handleInfixArrays only calls this when expr is already array-typed, // so we can unconditionally update. info := ts.ExprCache[key(ts.FuncNameMangled, expr)] @@ -269,7 +257,7 @@ func (ts *TypeSolver) bindArrayOperand(expr ast.Expression, arr Array) { ts.bindArrayOperand(e.Right, arr) } case *ast.ArrayRangeExpression: - // For array slicing like arr[1:3], the array itself must be array-typed + // For array-range access like arr[1:3], the source must be array-typed. ts.bindArrayOperand(e.Array, arr) } } @@ -436,8 +424,9 @@ func cloneArrayIndices(indices map[string][]int) map[string][]int { func (ts *TypeSolver) HandleArrayLiteralRanges(al *ast.ArrayLiteral) ([]*RangeInfo, ast.Expression) { info := ts.ExprCache[key(ts.FuncNameMangled, al)] - // Only 1D array literals are currently supported by the compiler. - if !(len(al.Headers) == 0 && len(al.Rows) == 1) { + // Only inline literals act as collectors. Block rows describe a statically + // rectangular array or table even when the block contains a single row. + if !isInlineArrayCollector(al) { info.Ranges = nil info.CollectRanges = nil info.Rewrite = al @@ -462,6 +451,7 @@ func (ts *TypeSolver) HandleArrayLiteralRanges(al *ast.ArrayLiteral) ([]*RangeIn if changed { newLit := &ast.ArrayLiteral{ Token: al.Token, + Block: al.Block, Headers: append([]string(nil), al.Headers...), Rows: [][]ast.Expression{newRow}, Indices: cloneArrayIndices(al.Indices), @@ -677,6 +667,10 @@ func (ts *TypeSolver) Solve() { continue } for _, pending := range entries { + info := ts.ExprCache[key(binding.FuncNameMangled, pending.expr)] + if info != nil && pending.outTypeIdx < len(info.OutTypes) && isUntypedEmptyCollection(info.OutTypes[pending.outTypeIdx]) { + continue + } ts.Errors = append(ts.Errors, &token.CompileError{ Token: pending.expr.Tok(), Msg: fmt.Sprintf("type for %q could not be resolved", binding.Name), @@ -721,7 +715,7 @@ func (ts *TypeSolver) ensureScalarCallVariant(ce *ast.CallExpression) { case RangeKind: innerType = t.(Range).Iter case ArrayRangeKind: - innerType = t.(ArrayRange).Array.ColTypes[0] + innerType = t.(ArrayRange).Array.ElemType } scalarArgs = append(scalarArgs, innerType) } @@ -875,7 +869,7 @@ func (ts *TypeSolver) mergeCondRangesIntoValue(expr ast.Expression, exprTypes [] exprTypes[0] = iterType info.OutTypes[0] = iterType case ArrayRangeKind: - elemType := arrayAccessResultType(exprTypes[0].(ArrayRange).Array.ColTypes[0]) + elemType := arrayIndexResultType(exprTypes[0].(ArrayRange).Array) exprTypes[0] = elemType info.OutTypes[0] = elemType } @@ -957,76 +951,277 @@ func (ts *TypeSolver) TypeLetStatement(stmt *ast.LetStatement) { PutBulk(ts.Scopes, trueValues) } -// TypeArrayExpression infers the type of an Array literal. Columns must be -// homogeneous and of primitive types: I64, F64 (promotion from I64 allowed), or Str. -// The returned type slice contains a single Array type value. +// TypeArrayExpression classifies bracket literals as rectangular arrays or +// tables. Inline literals contribute one array axis; block literals contribute +// row and column axes; nested array-valued cells contribute their own axes. func (ts *TypeSolver) TypeArrayExpression(al *ast.ArrayLiteral) []Type { - // Empty array literal: [] (no rows) if len(al.Headers) == 0 && len(al.Rows) == 0 { - arr := Array{Headers: nil, ColTypes: []Type{Unresolved{}}, Length: 0} - ts.ExprCache[key(ts.FuncNameMangled, al)] = &ExprInfo{OutTypes: []Type{arr}, ExprLen: 1, HasRanges: false} - return []Type{arr} - } - - // Vector special-case: single row, no headers → treat as 1-column vector - if len(al.Headers) == 0 && len(al.Rows) == 1 { - colTypes := []Type{Unresolved{}} - row := al.Rows[0] - for col := 0; col < len(row); col++ { - cellT, ok := ts.typeCell(row[col], al.Tok()) - if !ok { - continue + shape := arrayLiteralLayoutShape(al) + return ts.cacheArrayLiteralType(al, Array{ElemType: Empty{}, Rank: len(shape)}, shape) + } + + if len(al.Headers) > 0 { + return ts.typeTableLiteral(al) + } + + cellTypes, valid := ts.typeBracketLiteralCells(al) + if !valid { + return ts.cacheInvalidBracketLiteral(al) + } + + hasArrayCells := false + hasScalarCells := false + for rowIndex, row := range cellTypes { + for colIndex, cellType := range row { + cell := al.Rows[rowIndex][colIndex] + if al.Block && ts.ExprCache[key(ts.FuncNameMangled, cell)].HasRanges { + ts.Errors = append(ts.Errors, &token.CompileError{ + Token: cell.Tok(), + Msg: "multidimensional array rows require statically sized cells", + }) + } + if cellType.Kind() == ArrayKind { + hasArrayCells = true + } else { + hasScalarCells = true } - colTypes[0] = ts.mergeColType(colTypes[0], cellT, 0, al.Tok()) } - // Length = number of elements in the row (for vectors) - arr := Array{Headers: nil, ColTypes: colTypes, Length: len(row)} + } - // Array literals materialize their own internal ranges instead of - // propagating them upward into the parent expression's loop shape. - ts.ExprCache[key(ts.FuncNameMangled, al)] = &ExprInfo{OutTypes: []Type{arr}, ExprLen: 1, HasRanges: false} - return []Type{arr} + if hasArrayCells && hasScalarCells { + ts.Errors = append(ts.Errors, &token.CompileError{ + Token: al.Tok(), + Msg: "cannot mix scalar and array-valued cells in the same array literal", + }) + return ts.cacheInvalidBracketLiteral(al) } - // unsupported as of now + if hasArrayCells { + return ts.typeStackedArrayLiteral(al, cellTypes) + } + return ts.typeScalarBracketLiteral(al, cellTypes) +} + +func (ts *TypeSolver) cacheArrayLiteralType(al *ast.ArrayLiteral, arr Array, shape []uint64) []Type { + types := []Type{arr} + ts.ExprCache[key(ts.FuncNameMangled, al)] = &ExprInfo{ + OutTypes: types, + ExprLen: 1, + HasRanges: false, + ArrayShape: slices.Clone(shape), + } + return types +} + +func (ts *TypeSolver) cacheInvalidBracketLiteral(al *ast.ArrayLiteral) []Type { types := []Type{Unresolved{}} ts.ExprCache[key(ts.FuncNameMangled, al)] = &ExprInfo{OutTypes: types, ExprLen: 1} return types - /* - // 1. Determine number of columns - numCols := ts.arrayNumCols(al) - - if numCols == 0 { - if iterate { - return []Type{Unresolved{}} - } - return []Type{Array{Headers: nil, ColTypes: nil, Length: 0}} - } - - // 2. Initialize column types - colTypes := ts.initColTypes(numCols) - - // 3. Walk rows/cells and merge types per column - - for _, row := range al.Rows { - for col := 0; col < numCols; col++ { - if col >= len(row) { - break // ragged row; remaining cells are missing - } - cellT, ok := ts.typeCell(row[col], al.Tok()) - if !ok { - continue - } - colTypes[col] = ts.mergeColType(colTypes[col], cellT, col, al.Tok()) - } - } - - // 4. Build final Array type (no defaulting of unresolved columns) - arr := Array{Headers: append([]string(nil), al.Headers...), ColTypes: colTypes, Length: len(al.Rows)} - - // Cache this expression's resolved type for the compiler - ts.ExprCache[al] = &ExprInfo{OutTypes: []Type{arr}, ExprLen: 1} - return []Type{arr} - */ +} + +func (ts *TypeSolver) typeBracketLiteralCells(al *ast.ArrayLiteral) ([][]Type, bool) { + cellTypes := make([][]Type, len(al.Rows)) + valid := true + for rowIndex, row := range al.Rows { + cellTypes[rowIndex] = make([]Type, len(row)) + for colIndex, cell := range row { + cellType, ok := ts.typeCell(cell, al.Tok()) + cellTypes[rowIndex][colIndex] = cellType + valid = valid && ok + } + } + return cellTypes, valid +} + +func (ts *TypeSolver) typeScalarBracketLiteral(al *ast.ArrayLiteral, cellTypes [][]Type) []Type { + if !al.Block { + elemType := Type(Unresolved{}) + layoutShape := arrayLiteralLayoutShape(al) + shape := layoutShape + for col, cellType := range cellTypes[0] { + elemType = ts.mergeColType(elemType, cellType, col, al.Tok()) + if ts.ExprCache[key(ts.FuncNameMangled, al.Rows[0][col])].HasRanges { + shape = nil + } + } + return ts.cacheArrayLiteralType(al, Array{ElemType: elemType, Rank: len(layoutShape)}, shape) + } + + numCols := len(al.Rows[0]) + if !ts.validateBracketLiteralShape(al, numCols) { + return ts.cacheInvalidBracketLiteral(al) + } + + colTypes := ts.initColTypes(numCols) + for _, row := range cellTypes { + for col, cellType := range row { + colTypes[col] = ts.mergeColType(colTypes[col], cellType, col, al.Tok()) + } + } + + if elemType, ok := commonArrayElemType(colTypes); ok { + shape := arrayLiteralLayoutShape(al) + return ts.cacheArrayLiteralType(al, Array{ElemType: elemType, Rank: len(shape)}, shape) + } + + return ts.cacheTableLiteralType(al, colTypes) +} + +func (ts *TypeSolver) typeStackedArrayLiteral(al *ast.ArrayLiteral, cellTypes [][]Type) []Type { + numCols := len(al.Rows[0]) + if !ts.validateBracketLiteralShape(al, numCols) { + return ts.cacheInvalidBracketLiteral(al) + } + + children := make([]ast.Expression, 0) + childTypes := make([]Array, 0) + for rowIndex, row := range cellTypes { + for colIndex, cellType := range row { + child := al.Rows[rowIndex][colIndex] + children = append(children, child) + childTypes = append(childTypes, cellType.(Array)) + } + } + + first := childTypes[0] + elemType := Type(Unresolved{}) + var childShape []uint64 + shapeKnown := true + for i, childType := range childTypes { + if childType.Rank != first.Rank { + ts.Errors = append(ts.Errors, &token.CompileError{ + Token: children[i].Tok(), + Msg: fmt.Sprintf("cannot stack rank-%d and rank-%d arrays", first.Rank, childType.Rank), + }) + continue + } + + elemType = ts.mergeArrayLeafType(elemType, childType.ElemType, al.Tok()) + shape := ts.ExprCache[key(ts.FuncNameMangled, children[i])].ArrayShape + if shape == nil { + shapeKnown = false + continue + } + if childShape == nil { + childShape = slices.Clone(shape) + continue + } + if !slices.Equal(childShape, shape) { + ts.Errors = append(ts.Errors, &token.CompileError{ + Token: children[i].Tok(), + Msg: fmt.Sprintf("cannot stack arrays with shapes %v and %v", childShape, shape), + }) + } + } + + layoutShape := arrayLiteralLayoutShape(al) + + var shape []uint64 + if shapeKnown && childShape != nil { + shape = append(layoutShape, childShape...) + } + return ts.cacheArrayLiteralType(al, Array{ElemType: elemType, Rank: first.Rank + len(layoutShape)}, shape) +} + +func (ts *TypeSolver) mergeArrayLeafType(current, next Type, tok token.Token) Type { + if next.Kind() == EmptyKind { + if current.Kind() == UnresolvedKind { + return next + } + return current + } + if current.Kind() == EmptyKind { + current = Unresolved{} + } + return ts.mergeColType(current, next, 0, tok) +} + +func (ts *TypeSolver) typeTableLiteral(al *ast.ArrayLiteral) []Type { + numCols := ts.arrayNumCols(al) + if !ts.validateBracketLiteralShape(al, numCols) { + return ts.cacheInvalidBracketLiteral(al) + } + + colTypes := ts.initColTypes(numCols) + for _, row := range al.Rows { + for col, cell := range row { + cellType, ok := ts.typeCell(cell, al.Tok()) + if ok { + if ts.ExprCache[key(ts.FuncNameMangled, cell)].HasRanges { + ts.Errors = append(ts.Errors, &token.CompileError{ + Token: cell.Tok(), + Msg: "table rows require statically sized cells", + }) + } + colTypes[col] = ts.mergeColType(colTypes[col], cellType, col, al.Tok()) + } + } + } + return ts.cacheTableLiteralType(al, colTypes) +} + +func (ts *TypeSolver) cacheTableLiteralType(al *ast.ArrayLiteral, colTypes []Type) []Type { + columns := make([]TableColumn, len(colTypes)) + for i, elemType := range colTypes { + name := "" + if i < len(al.Headers) { + name = al.Headers[i] + } + columns[i] = TableColumn{Name: name, ElemType: elemType} + } + + table := Table{Columns: columns} + ts.ExprCache[key(ts.FuncNameMangled, al)] = &ExprInfo{OutTypes: []Type{table}, ExprLen: 1} + return []Type{table} +} + +func (ts *TypeSolver) validateBracketLiteralShape(al *ast.ArrayLiteral, numCols int) bool { + valid := true + if len(al.Headers) > 0 { + seen := make(map[string]struct{}, len(al.Headers)) + for _, header := range al.Headers { + if _, exists := seen[header]; exists { + ts.Errors = append(ts.Errors, &token.CompileError{ + Token: al.Tok(), + Msg: fmt.Sprintf("duplicate table column %q", header), + }) + valid = false + } + seen[header] = struct{}{} + } + } + + for i, row := range al.Rows { + if len(row) == numCols { + continue + } + ts.Errors = append(ts.Errors, &token.CompileError{ + Token: al.Tok(), + Msg: fmt.Sprintf("bracket literal row %d has %d cells, expected %d", i+1, len(row), numCols), + }) + valid = false + } + return valid +} + +func commonArrayElemType(colTypes []Type) (Type, bool) { + elemType := Type(Unresolved{}) + for _, colType := range colTypes { + switch { + case elemType.Kind() == UnresolvedKind: + elemType = colType + case colType.Kind() == UnresolvedKind: + continue + case TypeEqual(elemType, colType): + continue + case elemType.Kind() == IntKind && colType.Kind() == FloatKind: + elemType = colType + case elemType.Kind() == FloatKind && colType.Kind() == IntKind: + continue + default: + return nil, false + } + } + return elemType, true } func (ts *TypeSolver) TypeStructLiteral(sl *ast.StructLiteral) []Type { @@ -1109,29 +1304,41 @@ func (ts *TypeSolver) TypeDotExpression(expr *ast.DotExpression) []Type { return types } - structType, ok := leftTypes[0].(Struct) - if !ok { + leftInfo := ts.ExprCache[key(ts.FuncNameMangled, expr.Left)] + switch leftType := leftTypes[0].(type) { + case Struct: + for _, field := range leftType.Fields { + if field.Name == expr.Field { + info.OutTypes = []Type{field.Type} + info.HasRanges = leftInfo.HasRanges + return info.OutTypes + } + } ts.Errors = append(ts.Errors, &token.CompileError{ Token: expr.Tok(), - Msg: fmt.Sprintf("field access expects a struct value, got %s", leftTypes[0].String()), + Msg: fmt.Sprintf("unknown field %q on struct %s", expr.Field, leftType.Name), }) return types - } - - for _, field := range structType.Fields { - if field.Name == expr.Field { - info.OutTypes = []Type{field.Type} - leftInfo := ts.ExprCache[key(ts.FuncNameMangled, expr.Left)] - info.HasRanges = leftInfo.HasRanges - return info.OutTypes + case Table: + for _, column := range leftType.Columns { + if column.Name == expr.Field { + info.OutTypes = []Type{Array{ElemType: column.ElemType, Rank: 1}} + info.HasRanges = leftInfo.HasRanges + return info.OutTypes + } } + ts.Errors = append(ts.Errors, &token.CompileError{ + Token: expr.Tok(), + Msg: fmt.Sprintf("unknown column %q on %s", expr.Field, leftType.String()), + }) + return types + default: + ts.Errors = append(ts.Errors, &token.CompileError{ + Token: expr.Tok(), + Msg: fmt.Sprintf("field access expects a struct or table value, got %s", leftTypes[0].String()), + }) + return types } - - ts.Errors = append(ts.Errors, &token.CompileError{ - Token: expr.Tok(), - Msg: fmt.Sprintf("unknown field %q on struct %s", expr.Field, structType.Name), - }) - return types } // arrayNumCols returns column count based on headers or max row width. @@ -1139,13 +1346,10 @@ func (ts *TypeSolver) arrayNumCols(al *ast.ArrayLiteral) int { if len(al.Headers) > 0 { return len(al.Headers) } - maxW := 0 - for _, row := range al.Rows { - if l := len(row); l > maxW { - maxW = l - } + if len(al.Rows) > 0 { + return len(al.Rows[0]) } - return maxW + return 0 } func (ts *TypeSolver) initColTypes(n int) []Type { @@ -1162,13 +1366,13 @@ func (ts *TypeSolver) typeCell(expr ast.Expression, tok token.Token) (Type, bool if len(tps) != 1 { ts.Errors = append(ts.Errors, &token.CompileError{ Token: tok, - Msg: fmt.Sprintf("array cell produced %d types; expected 1", len(tps)), + Msg: fmt.Sprintf("bracket literal cell produced %d types; expected 1", len(tps)), }) return Unresolved{}, false } cellType := tps[0] if cellType.Kind() == UnresolvedKind { - ts.Errors = append(ts.Errors, &token.CompileError{Token: tok, Msg: "array cell type could not be resolved"}) + ts.Errors = append(ts.Errors, &token.CompileError{Token: tok, Msg: "bracket literal cell type could not be resolved"}) return Unresolved{}, false } return cellType, true @@ -1188,8 +1392,8 @@ func (ts *TypeSolver) mergeColType(cur Type, newT Type, colIdx int, tok token.To } // Only allow I64, F64, or Str as array elements. - if !AllowedArrayElem(newT) { - ts.Errors = append(ts.Errors, &token.CompileError{Token: tok, Msg: fmt.Sprintf("unsupported array element type %s in column %d", newT.String(), colIdx)}) + if !allowedCollectionElement(newT) { + ts.Errors = append(ts.Errors, &token.CompileError{Token: tok, Msg: fmt.Sprintf("unsupported bracket literal element type %s in column %d", newT.String(), colIdx)}) return cur } @@ -1216,8 +1420,9 @@ func (ts *TypeSolver) mergeColType(cur Type, newT Type, colIdx int, tok token.To return cur } -// AllowedArrayElem returns true only for I64, F64, and Str cells. -func AllowedArrayElem(t Type) bool { +// allowedCollectionElement reports whether a value can be stored in the +// runtime backing arrays used by arrays and tables. +func allowedCollectionElement(t Type) bool { switch v := t.(type) { case Int: return v.Width == 64 @@ -1319,19 +1524,14 @@ func (ts *TypeSolver) TypeArrayRangeExpression(ax *ast.ArrayRangeExpression, isR if !ok { return info.OutTypes } - if len(arrType.ColTypes) == 0 { + if !hasConcreteArrayElemType(arrType.ElemType) { ts.Errors = append(ts.Errors, &token.CompileError{ Token: ax.Tok(), - Msg: "array type has no element columns", + Msg: "cannot index an empty array without an element type", }) return info.OutTypes } - - elemType := arrType.ColTypes[0] - // String array element access does strdup at runtime, so result is heap-allocated - if elemType.Kind() == StrKind { - elemType = StrH{} - } + resultType := arrayIndexResultType(arrType) idxTypes := ts.TypeExpression(ax.Range, isRoot) info.HasRanges = ts.ExprCache[key(ts.FuncNameMangled, ax.Array)].HasRanges || ts.ExprCache[key(ts.FuncNameMangled, ax.Range)].HasRanges @@ -1364,6 +1564,13 @@ 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{ @@ -1375,15 +1582,15 @@ func (ts *TypeSolver) TypeArrayRangeExpression(ax *ast.ArrayRangeExpression, isR } if !isRoot { - // nested: return element type (iterating) - info.OutTypes = []Type{elemType} + // 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 { - // single index access returns the element type - info.OutTypes = []Type{elemType} + info.OutTypes = []Type{resultType} info.ExprLen = 1 return info.OutTypes } @@ -1506,10 +1713,10 @@ func (ts *TypeSolver) typeInfixArrayMask(leftType, rightType Type, op string, to cmpLeft := leftType cmpRight := rightType if leftType.Kind() == ArrayKind { - cmpLeft = leftType.(Array).ColTypes[0] + cmpLeft = leftType.(Array).ElemType } if rightType.Kind() == ArrayKind { - cmpRight = rightType.(Array).ColTypes[0] + cmpRight = rightType.(Array).ElemType } ts.TypeInfixOp(cmpLeft, cmpRight, op, tok) } @@ -1537,7 +1744,7 @@ func (ts *TypeSolver) typeInfixSlot(expr *ast.InfixExpression, leftType, rightTy if leftType.Kind() == ArrayKind { return leftType, CondArray } - return Array{Headers: nil, ColTypes: []Type{leftType}, Length: 0}, CondArray + return Array{ElemType: leftType, Rank: rightType.(Array).Rank}, CondArray } // Handle any expression involving arrays @@ -1798,28 +2005,54 @@ func (ts *TypeSolver) typeArrayArrayInfix(leftArr, rightArr Array, op string, to if op == token.SYM_CONCAT { return ts.concatArrayTypes(leftArr, rightArr, tok) } + if leftArr.Rank != rightArr.Rank { + ts.Errors = append(ts.Errors, &token.CompileError{ + Token: tok, + Msg: fmt.Sprintf("operator %q requires arrays with the same rank, got %d and %d", op, leftArr.Rank, rightArr.Rank), + }) + return Unresolved{} + } // Element-wise operations - resolve element types - leftElem := leftArr.ColTypes[0] - rightElem := rightArr.ColTypes[0] + leftElem := leftArr.ElemType + rightElem := rightArr.ElemType resultElem := ts.resolveArrayElemTypes(leftElem, rightElem, op, tok) // Return array type with dynamic length (determined at runtime) - return Array{ - Headers: nil, - ColTypes: []Type{resultElem}, - Length: 0, - } + return Array{ElemType: resultElem, Rank: leftArr.Rank} } -// resolveArrayElemTypes handles unresolved element types and type inference for array operations +// resolveArrayElemTypes handles empty/unresolved element types and type inference for array operations. func (ts *TypeSolver) resolveArrayElemTypes(leftElem, rightElem Type, op string, tok token.Token) Type { + leftEmpty := leftElem.Kind() == EmptyKind + rightEmpty := rightElem.Kind() == EmptyKind + + if leftEmpty && rightEmpty { + ts.Errors = append(ts.Errors, &token.CompileError{ + Token: tok, + Msg: fmt.Sprintf("operator %q on empty arrays requires an element type", op), + }) + return Unresolved{} + } + + if leftEmpty { + return rightElem + } + if rightEmpty { + return leftElem + } + leftUnresolved := leftElem.Kind() == UnresolvedKind rightUnresolved := rightElem.Kind() == UnresolvedKind - // Both unresolved - result is unresolved + // Element-wise operations need an element type. Concatenation is handled + // before this function and may preserve an untyped empty result. if leftUnresolved && rightUnresolved { + ts.Errors = append(ts.Errors, &token.CompileError{ + Token: tok, + Msg: fmt.Sprintf("operator %q on unresolved arrays requires an element type", op), + }) return Unresolved{} } @@ -1853,23 +2086,19 @@ func (ts *TypeSolver) typeArrayScalarInfix(left, right Type, leftIsArr bool, op if leftIsArr { arrType = left.(Array) - leftType = arrType.ColTypes[0] + leftType = arrType.ElemType rightType = right } else { arrType = right.(Array) leftType = left - rightType = arrType.ColTypes[0] + rightType = arrType.ElemType } // Compute result element type elemType := ts.TypeInfixOp(leftType, rightType, op, tok) // Return array with computed element type - return Array{ - Headers: cloneArrayHeaders(arrType.Headers), - ColTypes: []Type{elemType}, - Length: arrType.Length, - } + return Array{ElemType: elemType, Rank: arrType.Rank} } func (ts *TypeSolver) TypeInfixOp(left, right Type, op string, tok token.Token) Type { @@ -1925,7 +2154,7 @@ func (ts *TypeSolver) TypePrefixExpression(expr *ast.PrefixExpression) (types [] if opType.Kind() == ArrayKind { isArrayExpr = true origArrayType = opType.(Array) - opType = origArrayType.ColTypes[0] + opType = origArrayType.ElemType } opKey := unaryOpKey{ @@ -1954,11 +2183,7 @@ func (ts *TypeSolver) TypePrefixExpression(expr *ast.PrefixExpression) (types [] resultType := fn(ts.ScriptCompiler.Compiler, opSym, false).Type if isArrayExpr { - finalType := Array{ - Headers: origArrayType.Headers, - ColTypes: []Type{resultType}, - Length: origArrayType.Length, - } + finalType := Array{ElemType: resultType, Rank: origArrayType.Rank} types = append(types, finalType) continue } @@ -2071,7 +2296,7 @@ func (ts *TypeSolver) collectCallArgs(ce *ast.CallExpression, isRoot bool) (args case RangeKind: innerType = outerType.(Range).Iter case ArrayRangeKind: - innerType = outerType.(ArrayRange).Array.ColTypes[0] + innerType = outerType.(ArrayRange).Array.ElemType } innerArgs = append(innerArgs, innerType) @@ -2094,7 +2319,7 @@ func (ts *TypeSolver) collectCallArgs(ce *ast.CallExpression, isRoot bool) (args case RangeKind: return t.(Range).Iter case ArrayRangeKind: - return t.(ArrayRange).Array.ColTypes[0] + return t.(ArrayRange).Array.ElemType default: return t } @@ -2203,7 +2428,7 @@ func (ts *TypeSolver) InferFuncTypes(ce *ast.CallExpression, args []Type, mangle // At script level, all arg types must be resolved before typing for i, arg := range args { - if arg.Kind() != UnresolvedKind { + if IsFullyResolvedType(arg) { continue } ts.Errors = append(ts.Errors, &token.CompileError{ diff --git a/compiler/solver_test.go b/compiler/solver_test.go index 429379b3..00c978fe 100644 --- a/compiler/solver_test.go +++ b/compiler/solver_test.go @@ -287,6 +287,117 @@ func TestTypeStructLiteralWidensStringFieldsFromValues(t *testing.T) { require.True(t, IsStrH(dotTypes[0]), "dot access should reflect widened field flavor") } +func TestCollectionTypeErrors(t *testing.T) { + const identity = `out = Identity(x) + out = x` + cases := []struct { + name string + code string + script string + expectError string + }{ + { + name: "DuplicateTableHeader", + script: "table = [\n :Name Name\n \"Ada\" \"A\"\n]", + expectError: `duplicate table column "Name"`, + }, + { + name: "RaggedTableRow", + script: "table = [\n :Name Score\n \"Ada\"\n]", + expectError: "bracket literal row 1 has 1 cells, expected 2", + }, + { + name: "RangedTableCell", + script: "i = 0:3\ntable = [\n :Value\n i\n]", + expectError: "table rows require statically sized cells", + }, + { + name: "RaggedArrayRow", + script: "arr = [\n 1 0\n 0\n]", + expectError: "bracket literal row 2 has 1 cells, expected 2", + }, + { + name: "IndexEmptyArray", + script: "empty = []\nempty[0]", + expectError: "cannot index an empty array without an element type", + }, + { + name: "EmptyArrayOperator", + script: "result = [] + []", + expectError: `operator "+" on empty arrays requires an element type`, + }, + { + name: "ArrayTypeStaysLockedAfterEmptyReset", + script: "arr = [1]\narr = []\narr = [1.5]", + expectError: `cannot reassign type to identifier. Old Type: [I64]. New Type: [F64]. Identifier "arr"`, + }, + { + name: "Rank2EmptyIsNotRank1Reset", + script: "arr = [1]\narr = [[]]", + expectError: `cannot reassign type to identifier. Old Type: [I64]. New Type: [[Empty]]. Identifier "arr"`, + }, + { + name: "UntypedEmptyTableArgument", + code: identity, + script: `Identity([ + :Name Score +])`, + expectError: "called with unknown argument type", + }, + { + name: "StackRankMismatch", + script: "m = [[1 2] [[3 4]]]\nm", + expectError: "cannot stack rank-1 and rank-2 arrays", + }, + { + name: "StackShapeMismatch", + script: "m = [[1 2] [3 4 5]]\nm", + expectError: "cannot stack arrays with shapes [2] and [3]", + }, + { + name: "MixedScalarAndArrayCells", + script: "m = [[1 2] 3]\nm", + expectError: "cannot mix scalar and array-valued cells in the same array literal", + }, + { + name: "ConcatRankMismatch", + 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 { + t.Run(tc.name, func(t *testing.T) { + ctx := llvm.NewContext() + defer ctx.Dispose() + + code := ast.NewCode() + if tc.code != "" { + code = mustParseCode(t, tc.code) + } + cc := NewCodeCompiler(ctx, tc.name, "", code) + require.Empty(t, cc.Compile()) + + sl := lexer.New(tc.name+".spt", tc.script) + sp := parser.NewScriptParser(sl) + program := sp.Parse() + require.Empty(t, sp.Errors()) + + sc := NewScriptCompiler(ctx, program, cc, make(map[string]*Func), make(map[ExprKey]*ExprInfo)) + ts := NewTypeSolver(sc) + ts.Solve() + + require.Len(t, ts.Errors, 1) + require.Contains(t, ts.Errors[0].Msg, tc.expectError) + }) + } +} + func TestArrayConcatTypeErrors(t *testing.T) { ctx := llvm.NewContext() cc := NewCodeCompiler(ctx, "arrayConcatErrors", "", ast.NewCode()) @@ -347,11 +458,8 @@ func TestArrayConcatTypeErrors(t *testing.T) { if !ok { t.Fatalf("expected array type, got %T", resType) } - if len(arrType.ColTypes) != 1 { - t.Fatalf("expected single-column array type") - } - if arrType.ColTypes[0].Kind() != FloatKind { - t.Fatalf("expected float array result, got %s", arrType.ColTypes[0].String()) + if arrType.ElemType.Kind() != FloatKind { + t.Fatalf("expected float array result, got %s", arrType.ElemType.String()) } } @@ -851,7 +959,7 @@ func TestScalarArrayComparisonInValuePositionIsMask(t *testing.T) { outArr, ok := info.OutTypes[0].(Array) require.True(t, ok, "expected scalar-array mask output type to be array") - require.Equal(t, IntKind, outArr.ColTypes[0].Kind(), "scalar-array mask should keep scalar LHS element type") + require.Equal(t, IntKind, outArr.ElemType.Kind(), "scalar-array mask should keep scalar LHS element type") } func TestArrayLiteralRangesRecording(t *testing.T) { @@ -909,7 +1017,7 @@ func TestArrayRangeTyping(t *testing.T) { 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.ColTypes[0], Int{Width: 64}) + require.EqualValues(t, value.Array.ElemType, Int{Width: 64}) require.EqualValues(t, value.Range, Range{Iter: Int{Width: 64}}) sumType, ok := ts.GetIdentifier("sum") diff --git a/compiler/table.go b/compiler/table.go new file mode 100644 index 00000000..d4b44892 --- /dev/null +++ b/compiler/table.go @@ -0,0 +1,107 @@ +package compiler + +import ( + "github.com/thiremani/pluto/ast" + "tinygo.org/x/go-llvm" +) + +func (c *Compiler) compileTable(lit *ast.ArrayLiteral, tableType Table) *Symbol { + if len(lit.Rows) == 0 { + return c.makeZeroValue(tableType) + } + + rowCount := c.ConstI64(uint64(len(lit.Rows))) + columns := make([]llvm.Value, len(tableType.Columns)) + for i, column := range tableType.Columns { + columns[i] = c.CreateArrayForType(column.ElemType, rowCount) + } + + // Preserve source evaluation order even though storage is columnar. + for rowIndex, row := range lit.Rows { + for colIndex, cell := range row { + column := tableType.Columns[colIndex] + index := c.ConstI64(uint64(rowIndex)) + c.compileArrayLiteralCell(cell, column.ElemType, func(cellSlot *Symbol) bool { + return c.setArrayCellSlot(columns[colIndex], index, cellSlot, column.ElemType) + }) + } + } + + i8p := llvm.PointerType(c.Context.Int8Type(), 0) + for i := range columns { + columns[i] = c.builder.CreateBitCast(columns[i], i8p, "table_column") + } + return &Symbol{ + Type: tableType, + Val: c.createTableValue(rowCount, columns, tableType), + } +} + +func (c *Compiler) createTableValue(rowCount llvm.Value, columns []llvm.Value, tableType Table) llvm.Value { + value := llvm.Undef(c.mapToLLVMType(tableType)) + value = c.builder.CreateInsertValue(value, rowCount, 0, "table_rows") + for i, column := range columns { + value = c.builder.CreateInsertValue(value, column, i+1, "table_column") + } + return value +} + +func (c *Compiler) tableColumnValue(table llvm.Value, column int) llvm.Value { + return c.builder.CreateExtractValue(table, column+1, "table_column") +} + +func (c *Compiler) copyTableValue(value llvm.Value, tableType Table) llvm.Value { + rows := c.builder.CreateExtractValue(value, 0, "table_rows") + columns := make([]llvm.Value, len(tableType.Columns)) + for i, column := range tableType.Columns { + columns[i] = c.copyArray(c.tableColumnValue(value, i), column.ElemType) + } + return c.createTableValue(rows, columns, tableType) +} + +func (c *Compiler) tableStrArg(s *Symbol) llvm.Value { + tableType := s.Type.(Table) + rows := c.builder.CreateExtractValue(s.Val, 0, "table_rows") + columnCount := len(tableType.Columns) + if columnCount == 0 { + null := llvm.ConstPointerNull(llvm.PointerType(c.Context.Int8Type(), 0)) + fnType, fn := c.GetCFunc(TABLE_STR) + return c.builder.CreateCall(fnType, fn, []llvm.Value{rows, c.ConstI64(0), null, null, null}, "table_str") + } + + i8p := llvm.PointerType(c.Context.Int8Type(), 0) + i32 := c.Context.Int32Type() + namesType := llvm.ArrayType(i8p, columnCount) + kindsType := llvm.ArrayType(i32, columnCount) + columnsType := llvm.ArrayType(i8p, columnCount) + names := c.createEntryBlockAlloca(namesType, "table_names") + kinds := c.createEntryBlockAlloca(kindsType, "table_kinds") + columns := c.createEntryBlockAlloca(columnsType, "table_columns") + zero := c.ConstI64(0) + + for i, column := range tableType.Columns { + index := c.ConstI64(uint64(i)) + indices := []llvm.Value{zero, index} + nameSlot := c.builder.CreateGEP(namesType, names, indices, "table_name_slot") + kindSlot := c.builder.CreateGEP(kindsType, kinds, indices, "table_kind_slot") + columnSlot := c.builder.CreateGEP(columnsType, columns, indices, "table_column_slot") + c.builder.CreateStore(c.constCString(column.Name), nameSlot) + // An unresolved column is an untyped empty array, so rows is zero and the + // runtime never reads this placeholder element kind. + runtimeKind := runtimeElementKinds[IntKind] + if column.ElemType.Kind() != UnresolvedKind { + runtimeKind = runtimeElementKinds[column.ElemType.Kind()] + } + c.builder.CreateStore(llvm.ConstInt(i32, runtimeKind, false), kindSlot) + c.builder.CreateStore(c.tableColumnValue(s.Val, i), columnSlot) + } + + fnType, fn := c.GetCFunc(TABLE_STR) + return c.builder.CreateCall(fnType, fn, []llvm.Value{ + rows, + c.ConstI64(uint64(columnCount)), + c.builder.CreateBitCast(names, i8p, "table_names_ptr"), + c.builder.CreateBitCast(kinds, i8p, "table_kinds_ptr"), + c.builder.CreateBitCast(columns, i8p, "table_columns_ptr"), + }, "table_str") +} diff --git a/compiler/types.go b/compiler/types.go index 6f4a0801..c5f5e81f 100644 --- a/compiler/types.go +++ b/compiler/types.go @@ -13,6 +13,7 @@ type Kind int const ( UnresolvedKind Kind = iota + EmptyKind IntKind UintKind FloatKind @@ -21,6 +22,7 @@ const ( RangeKind FuncKind ArrayKind + TableKind ArrayRangeKind StructKind ) @@ -50,6 +52,7 @@ var PrimitiveTypeNames = []string{ "U64", "U32", "U16", "U8", "F64", "F32", "StrG", "StrH", // StrG = global/static (.rodata), StrH = heap + "Empty", "X", // Unresolved placeholder } @@ -59,6 +62,7 @@ var PrimitiveTypeNames = []string{ // Sorted by descending length in init() for correct prefix matching. var CompoundTypePrefixes = []string{ "ArrayRange", + "Table", "Array", "Range", "Ptr", @@ -84,6 +88,15 @@ func (u Unresolved) String() string { return "?" } // human-friendly func (u Unresolved) Mangle() string { return "X" } // placeholder for unresolved func (u Unresolved) Key() Type { return u } +// Empty is the element type of an empty array whose concrete element type has +// not been established. Unlike Unresolved, it is a valid, fully resolved type. +type Empty struct{} + +func (e Empty) Kind() Kind { return EmptyKind } +func (e Empty) String() string { return "Empty" } +func (e Empty) Mangle() string { return "Empty" } +func (e Empty) Key() Type { return e } + // Int represents an integer type with a given bit width. type Int struct { Width uint32 // e.g. 8, 16, 32, 64 @@ -255,18 +268,17 @@ func IsFullyResolvedType(t Type) bool { switch tt := t.(type) { case Unresolved: return false - case Int, Float, StrG, StrH: + case Empty, Int, Float, StrG, StrH: return true case Ptr: return IsFullyResolvedType(tt.Elem) case Range: return IsFullyResolvedType(tt.Iter) case Array: - if len(tt.ColTypes) == 0 { - return false - } - for _, col := range tt.ColTypes { - if !IsFullyResolvedType(col) { + return tt.Rank > 0 && tt.ElemType != nil && IsFullyResolvedType(tt.ElemType) + case Table: + for _, column := range tt.Columns { + if column.ElemType == nil || !IsFullyResolvedType(column.ElemType) { return false } } @@ -287,42 +299,116 @@ func IsFullyResolvedType(t Type) bool { } } -// Array represents a tabular array with optional headers and typed columns. -// Each column has a primitive element type (I64, F64, or Str). Length is the -// number of rows. Headers may be empty (for matrices without named columns). +// isUntypedEmptyCollection reports whether t is a header-only table, or a +// column projected from one, whose element types have not been established. +func isUntypedEmptyCollection(t Type) bool { + switch tt := t.(type) { + case Array: + return tt.ElemType != nil && tt.ElemType.Kind() == UnresolvedKind + case Table: + if len(tt.Columns) == 0 { + return false + } + for _, column := range tt.Columns { + if column.ElemType == nil || column.ElemType.Kind() != UnresolvedKind { + return false + } + } + return true + default: + return false + } +} + +// Array is a homogeneous rectangular value. Rank is part of the type while +// dimension lengths are runtime properties. type Array struct { - Headers []string // column headers (may be empty) - ColTypes []Type // element type per column (must be Int{64}, Float{64}, or Str) - Length int // number of rows + ElemType Type + Rank int } func (a Array) String() string { - // Type identity ignores headers and length; show schema only. - if len(a.ColTypes) == 0 { + if a.ElemType == nil || a.Rank < 1 { return "[]" } - var cols []string - for _, ct := range a.ColTypes { - cols = append(cols, ct.String()) + result := a.ElemType.String() + for range a.Rank { + result = "[" + result + "]" } - return "[" + strings.Join(cols, " ") + "]" + return result } func (a Array) Kind() Kind { return ArrayKind } func (a Array) Mangle() string { - s := "Array" + SEP + T + strconv.Itoa(len(a.ColTypes)) - for _, ct := range a.ColTypes { - s += SEP + ct.Mangle() + result := a.ElemType.Mangle() + for range a.Rank { + result = "Array" + SEP + T + "1" + SEP + result } - return s + return result } func (a Array) Key() Type { - // Array keys ignore headers and length, using only column types - keyColTypes := make([]Type, len(a.ColTypes)) - for i, ct := range a.ColTypes { - keyColTypes[i] = ct.Key() + return Array{ElemType: a.ElemType.Key(), Rank: a.Rank} +} + +func hasConcreteArrayElemType(elem Type) bool { + return elem != nil && elem.Kind() != EmptyKind && elem.Kind() != UnresolvedKind +} + +func arrayIndexResultType(array Array) Type { + if array.Rank > 1 { + return Array{ElemType: array.ElemType, Rank: array.Rank - 1} + } + if array.ElemType.Kind() == StrKind { + return StrH{} + } + return array.ElemType +} + +// TableColumn pairs an optional source-level name with a homogeneous column +// element type. Name is empty for positional, unnamed columns. +type TableColumn struct { + Name string + ElemType Type +} + +// Table is an ordered, columnar schema. Row count is a runtime property. +type Table struct { + Columns []TableColumn +} + +func (t Table) String() string { + columns := make([]string, len(t.Columns)) + for i, column := range t.Columns { + columns[i] = column.ElemType.String() + if column.Name != "" { + columns[i] = column.Name + ":" + columns[i] + } + } + return "Table[" + strings.Join(columns, " ") + "]" +} + +func (t Table) Kind() Kind { return TableKind } +func (t Table) Mangle() string { + parts := make([]string, 0, len(t.Columns)*2) + for _, column := range t.Columns { + encodedName := "u" + if column.Name != "" { + encodedName = "n" + column.Name + } + parts = append(parts, MangleIdent(encodedName), column.ElemType.Mangle()) + } + mangled := "Table" + SEP + T + strconv.Itoa(len(parts)) + if len(parts) > 0 { + mangled += SEP + strings.Join(parts, SEP) + } + return mangled +} +func (t Table) Key() Type { + columns := make([]TableColumn, len(t.Columns)) + for i, column := range t.Columns { + columns[i] = TableColumn{Name: column.Name, ElemType: column.ElemType.Key()} } - return Array{ColTypes: keyColTypes} + return Table{Columns: columns} } // ArrayRange represents an iteration over a range of an array. @@ -340,11 +426,7 @@ func (ar ArrayRange) String() string { func (ar ArrayRange) Kind() Kind { return ArrayRangeKind } func (ar ArrayRange) Mangle() string { - s := "ArrayRange" + SEP + T + strconv.Itoa(len(ar.Array.ColTypes)) - for _, ct := range ar.Array.ColTypes { - s += SEP + ct.Mangle() - } - return s + return "ArrayRange" + SEP + T + "1" + SEP + ar.Array.ElemType.Mangle() } func (ar ArrayRange) Key() Type { return ArrayRange{ @@ -505,7 +587,7 @@ func TypeEqual(a, b Type) bool { // Composite/container types are checked recursively. func CanRefineType(oldType, newType Type) bool { // Completely unresolved type can be refined to anything - if oldType.Kind() == UnresolvedKind { + if oldType.Kind() == UnresolvedKind || oldType.Kind() == EmptyKind { return true } @@ -518,6 +600,9 @@ func CanRefineType(oldType, newType Type) bool { case Array: newArr, ok := newType.(Array) return ok && canRefineArray(old, newArr) + case Table: + newTable, ok := newType.(Table) + return ok && canRefineTable(old, newTable) case ArrayRange: newSlice, ok := newType.(ArrayRange) return ok && canRefineArrayRange(old, newSlice) @@ -543,6 +628,18 @@ func bindingSlotCompatible(oldType, newType Type) bool { if oldType.Kind() == StrKind && newType.Kind() == StrKind { return true } + oldArray, oldIsArray := oldType.(Array) + newArray, newIsArray := newType.(Array) + if oldIsArray && newIsArray { + // Empty arrays retain an established leaf type. Rank-1 [] is also the + // shape-polymorphic reset spelling for an established higher-rank array. + if newArray.ElemType.Kind() == EmptyKind { + return newArray.Rank == 1 || oldArray.Rank == newArray.Rank + } + if oldArray.ElemType.Kind() == EmptyKind { + return oldArray.Rank == newArray.Rank + } + } return CanRefineType(oldType, newType) } @@ -553,6 +650,16 @@ func mergeBindingSlotType(oldType, newType Type) Type { if oldType.Kind() == StrKind && newType.Kind() == StrKind { return mergeStringFlavor(oldType, newType) } + oldArray, oldIsArray := oldType.(Array) + newArray, newIsArray := newType.(Array) + if oldIsArray && newIsArray { + if newArray.ElemType.Kind() == EmptyKind { + return oldType + } + if oldArray.ElemType.Kind() == EmptyKind { + return newType + } + } return newType } @@ -569,7 +676,20 @@ func canRefineTypes(oldTypes, newTypes []Type) bool { } func canRefineArray(oldArr, newArr Array) bool { - return canRefineTypes(oldArr.ColTypes, newArr.ColTypes) + return oldArr.Rank == newArr.Rank && CanRefineType(oldArr.ElemType, newArr.ElemType) +} + +func canRefineTable(oldTable, newTable Table) bool { + if len(oldTable.Columns) != len(newTable.Columns) { + return false + } + for i, oldColumn := range oldTable.Columns { + newColumn := newTable.Columns[i] + if oldColumn.Name != newColumn.Name || !CanRefineType(oldColumn.ElemType, newColumn.ElemType) { + return false + } + } + return true } func canRefineArrayRange(oldSlice, newSlice ArrayRange) bool { @@ -590,6 +710,8 @@ func typeComparer(k Kind) func(a, b Type) bool { switch k { case UnresolvedKind: return eqUnresolved + case EmptyKind: + return eqEmpty case IntKind: return eqInt case FloatKind: @@ -604,6 +726,8 @@ func typeComparer(k Kind) func(a, b Type) bool { return eqFunc case ArrayKind: return eqArray + case TableKind: + return eqTable case ArrayRangeKind: return eqArrayRange case StructKind: @@ -614,6 +738,7 @@ func typeComparer(k Kind) func(a, b Type) bool { } func eqUnresolved(a, b Type) bool { return true } +func eqEmpty(a, b Type) bool { return true } func eqInt(a, b Type) bool { ai := a.(Int) @@ -659,10 +784,22 @@ func eqFunc(a, b Type) bool { func eqArray(a, b Type) bool { aa := a.(Array) ba := b.(Array) - if len(aa.ColTypes) != len(ba.ColTypes) { + return aa.Rank == ba.Rank && TypeEqual(aa.ElemType, ba.ElemType) +} + +func eqTable(a, b Type) bool { + at := a.(Table) + bt := b.(Table) + if len(at.Columns) != len(bt.Columns) { return false } - return EqualTypes(aa.ColTypes, ba.ColTypes) + for i, column := range at.Columns { + other := bt.Columns[i] + if column.Name != other.Name || !TypeEqual(column.ElemType, other.ElemType) { + return false + } + } + return true } func eqArrayRange(a, b Type) bool { @@ -694,10 +831,10 @@ func eqStruct(a, b Type) bool { } var reservedTypeNames = map[string]struct{}{ - "Int": {}, "Float": {}, "Str": {}, "StrG": {}, "StrH": {}, "StrS": {}, + "Empty": {}, "Int": {}, "Float": {}, "Str": {}, "StrG": {}, "StrH": {}, "StrS": {}, "I1": {}, "I8": {}, "I16": {}, "I32": {}, "I64": {}, "U8": {}, "U16": {}, "U32": {}, "U64": {}, "F32": {}, "F64": {}, - "Ptr": {}, "Range": {}, "Array": {}, "ArrayRange": {}, + "Ptr": {}, "Range": {}, "Array": {}, "Table": {}, "ArrayRange": {}, "Func": {}, "Struct": {}, } diff --git a/docs/Pluto Array Semantics.md b/docs/Pluto Array Semantics.md new file mode 100644 index 00000000..a303d56c --- /dev/null +++ b/docs/Pluto Array Semantics.md @@ -0,0 +1,213 @@ +# Pluto Array Semantics + +## Type and representation + +An array type consists of a scalar leaf type and a rank. `[I64]` is rank 1, +`[[I64]]` is rank 2, and nesting continues for higher ranks. Dimension lengths +are runtime values, not part of type identity. + +All ranks use one flat, row-major element buffer. Higher ranks carry their +dimension lengths beside that buffer; rows are not separately allocated. + +## Literal inference + +- `[]` is a rank-1 `[Empty]` value. +- An empty block is a rank-2 `[Empty]` value with shape `[0 0]`. +- `[1 2 3]` is a rank-1 `[I64]` value. +- An inline literal contributes one array axis. +- A block literal, where `[` is followed by a newline, contributes row and + column axes even when it contains only one row. +- Equal-shaped array-valued cells stack recursively into higher ranks. + +A long rank-1 literal remains inline by escaping its physical newline: + +```pluto +values = [1 2 3 4 5 \ + 6 7 8 9 10] +``` + +Without the `\`, an inline literal cannot start another logical row. Put a +newline immediately after `[` to select block layout instead. + +These two literals therefore have the same rank-2 type and value: + +```pluto +a = [ + 1 2 + 3 4 +] + +b = [[1 2] [3 4]] +``` + +Nesting composes for higher ranks: + +```pluto +cube = [ + [1 2] [3 4] + [5 6] [7 8] +] +``` + +The cube is equivalent to +`[[[1 2] [3 4]] [[5 6] [7 8]]]`: the block contributes dimensions `[2 2]` +and each rank-1 cell contributes the final dimension, giving shape `[2 2 2]`. + +The distinction does not depend on the number of rows. These literals have +different ranks: + +```pluto +vector = [1 2 3] # shape [3] + +oneRowMatrix = [ + 1 2 3 +] # shape [1 3] +``` + +A block always contributes both layout axes, even when each row contains one +array-valued cell. Thus the following has shape `[2 1 2]`, not `[2 2]`: + +```pluto +rows = [ + [1 2] + [3 4] +] +``` + +Use `[[1 2] [3 4]]`, or the equivalent multiline scalar matrix above, for +shape `[2 2]`. + +Arrays are rectangular. Every scalar row must have the same number of cells, +and every stacked child must have the same shape. Pluto reports a shape error; +it never inserts default values for omitted cells. For example, this is invalid: + +```pluto +arr = [ + 1 0 + 0 +] +``` + +Ranges inside an inline literal remain collectors and may determine its runtime +length. Block cells must be statically sized. Array values are nested rather +than flattened when used as cells. + +### Tables + +Multiple scalar rows with homogeneous but different column types infer an +unnamed table. A header always produces a table and must contain at least one +column name. Headerless literals start directly with their first data row. + +The preferred layout outdents the `:` marker so the first header and first +value begin in the same column. Spacing within header and data rows is +otherwise non-semantic: + +```pluto +scores = [ + : Name Score + "Ada" 10 + "Lin" 12 +] +``` + +Named columns are arrays, so `scores.Score` is `[10 12]`. A header may have no +data rows; the resulting table retains its header when printed, while each +projected column is an untyped empty array that prints as `[]`. Header-only +tables can be printed and projected, but cannot be passed to functions until +their column types are established. + +## Indexing and operations + +`array[i]` indexes the outer dimension. Rank-1 indexing returns a scalar; +higher-rank indexing returns an owned array with one fewer dimension. Chained +indexing therefore works naturally. + +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: + +```pluto +i = 0:2 +selected = [matrix[i]] +``` + +Array-scalar operations preserve shape. Array-array element-wise operations +require equal rank and zip every dimension to the shorter corresponding +dimension, without padding. For example, shapes `[2 3]` and `[3 2]` produce +shape `[2 2]`. Concatenation joins the outer dimension and requires equal inner +dimensions when both operands are nonempty. An empty operand contributes no +cells and imposes no inner-shape constraint; concatenation uses the nonempty +operand's inner shape. Literal-construction mismatches are compile errors; +concatenation shapes that depend on runtime values are checked before +proceeding. + +Assigning `[]` to a concrete array empties it without changing its established +leaf type or rank. Untyped empty arrays can specialize functions and refine to +a concrete leaf type through concatenation. + +## Planned rank-N range features + +### Grouped multi-axis indexing + +Shape-preserving selection across multiple indexed dimensions is not yet +implemented. It is deferred until ranged collectors are represented in PIR. +The planned syntax is grouped indexing: + +```pluto +i = 0:3 +j = 0:3 +k = 0:3 + +submatrix = [matrix[i j]] +plane = [cube[1 j k]] +column = [cube[i 1 1]] +``` + +A grouped access containing at least one range is a lazy selection view. Its +rank is: + +```text +source rank - number of scalar indices +``` + +Unspecified trailing axes are retained. If a grouped access contains a range, +the leftmost range drives iteration. Each yield has one less rank than the +selection; the surrounding collector applies its ordinary rule and stacks the +yields, restoring the selection rank. It does not need a special +"materialize without adding a dimension" case. + +For example, a rank-3 `cube` follows this ladder: + +```pluto +cube[1] # rank 2 +cube[1 2] # rank 1 +cube[1 2 0] # scalar +[cube[i j k]] # rank 3 +[cube[1 j k]] # rank 2 +[cube[1 2 k]] # rank 1 +``` + +Grouped indexing preserves axes. Chained range indexing, such as +`[matrix[i][j]]`, keeps the existing flattened range-domain behavior. Mixed +grouped and chained indexing should initially be rejected. + +### Nested range construction + +Grouped indexing selects from an existing array. Computed rectangular values +need a separate construction rule: + +```pluto +i = 0:3 +j = 0:3 +submatrix = [i && [matrix[i][j]]] +``` + +Here value-position `&&` binds the outer `i` domain locally, the inner collector +owns `j`, and the outer collector stacks the rows. It never becomes a statement +gate. A skipped array-valued child contributes a zero-filled child of the known +inner shape, while `||` may supply an explicit shape-compatible fallback. + +The domain-binding, collector-placement, and fallback rules are specified in +[Pluto Range Semantics](Pluto%20Range%20Semantics.md#deferred-nested-range-construction). +This construction remains deferred until PIR represents range ownership and +collector boundaries directly. diff --git a/docs/Pluto C ABI Spec.md b/docs/Pluto C ABI Spec.md index 23ca6aac..69054baa 100644 --- a/docs/Pluto C ABI Spec.md +++ b/docs/Pluto C ABI Spec.md @@ -203,12 +203,23 @@ Built-in compound types use the `_tN_` pattern: |------|---------|---------| | Pointer | `Ptr_t1_[Elem]` | `Ptr_t1_I64` | | Range | `Range_t1_[Iter]` | `Range_t1_I64` | -| Array | `Array_tN_[ColTypes...]` | `Array_t2_I64_F64` | -| ArrayRange | `ArrayRange_tN_[ColTypes...]` | `ArrayRange_t1_I64` | +| Array | `Array_t1_[Elem]` | `Array_t1_I64` | +| Rank-N Array | repeated `Array_t1_` | `Array_t1_Array_t1_F64` | +| 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). +Table schemas include column names because named-column access participates in +type identity. Each column contributes two components. A named column encodes +`n` plus its source name; an unnamed column encodes `u`. + +Rank-1 arrays lower as the existing opaque runtime-vector pointer. Higher-rank +arrays lower as `{ data, dim0, ..., dim(N-1) }`, where `data` is one flat row-major +runtime vector. Dimension lengths are runtime values and do not participate in +type mangling. + --- ## 4. Examples diff --git a/docs/Pluto Conditional Value Semantics.md b/docs/Pluto Conditional Value Semantics.md index 2ee66b08..99bb55d7 100644 --- a/docs/Pluto Conditional Value Semantics.md +++ b/docs/Pluto Conditional Value Semantics.md @@ -18,6 +18,34 @@ the old value on failure; conditions inside the value propagate their failure to the nearest resolver — `=` keeps the old value, a collector cell zero-fills, and `||` supplies the fallback.** +Only the first form is a **statement gate**. It admits or rejects a shared +iteration point for every RHS expression and output in the statement. An `&&` +inside a value is a local value operator: it evaluates its right side lazily +when the left yields and propagates failure only through that containing value. +It does not gate the statement's iteration domain or its sibling RHS +expressions. + +### Outcome and circuit model + +A useful semantic model is that each value-producing operation has an abstract +outcome `(value, yielded)`, analogous to a circuit lane carrying `(data, valid)`. +This is not a Pluto tuple or a required runtime representation. The yield state +may be scalar, per output slot, per array element, or per range iteration, +depending on the value's domain. + +The value and yield state are distinct: a successful condition may yield the +value `0`, while a failed condition yields no value. A statement gate consumes +the condition's yield state as a shared execution-enable signal. If it is off, +the iteration transaction does not exist and no RHS or output commit runs. A +value-position condition instead leaves the transaction in place and propagates +its local missing outcome to the nearest resolver. Assignment keeps old, +collectors preserve shape with zero-fill, and `||` supplies alternate data. + +In short: **a statement gate controls whether an iteration transaction exists; +a value condition controls whether one data lane yields within an existing +transaction.** Filtering versus masking is the collection-level consequence of +that distinction. + ## Statement gate: keep-old A condition before the value gates the assignment. If it fails, no assignment @@ -95,7 +123,7 @@ y > 2 # yields y when y > 2, else fails (the value is the left opera y > 2 && 3 # yields 3 when y > 2, else fails ``` -`cond && value` is the value-position gating AND: it yields its **right** side +`cond && value` is the value-position conditional AND: it yields its **right** side when the left yields. Chains are last-wins (`a > 2 && b > 3 && 10` is `10` only when both hold), and the right side is **lazy** — evaluated at most once, only when the left yielded. Propagation composes through arithmetic: @@ -194,6 +222,23 @@ is `[0]` (not `[]`), and `[a > 2 a > 99]` is `[5 0]` (not empty or kept-old). An `&&` cell propagates into the same resolution: `[b > 2 && 5]` zero-fills when the condition fails, and `[b > 2 && 5 || -1]` takes the fallback instead. +For planned nested range construction after PIR migration, the same rule +extends recursively to array-valued cells. A failed child contributes a +zero-filled array of its expected shape; an explicit array fallback overrides +it: + +```pluto +[i > 0 && [F(i, j)]] # failed i positions get a zero row +[i > 0 && [F(i, j)] || [j && -1]] # failed i positions get a -1 row +``` + +The expected child shape must be known or derivable from bound range domains. +When it is not, the source must supply a shape-bearing fallback such as +`|| [j && 0]` for a zero row or `|| [j && -1]` for a different fill value. +Pluto never invents an empty, padded, or flattened child. This is value-position +resolution and preserves the outer domain; a statement gate instead rejects +the complete iteration point. + Spacing separates cells, and operators glue their operands into one cell: ```pluto @@ -205,8 +250,9 @@ A direct array comparison follows the same rule element-wise: `arr > k` is a **mask** — each cell keeps its left value where the comparison holds, else 0, *in place* (length- and position-preserving). It is the scalar "yield the left operand, else 0" applied per element, so it stays consistent with the collector -cell above and with array arithmetic (`+`, `*`): `arr1 > arr2` zips to the -shorter length, `arr > scalar` spans the array and broadcasts the scalar. +cell above and with array arithmetic (`+`, `*`): `arr1 > arr2` zips each +dimension to its minimum, while `arr > scalar` spans the array and broadcasts +the scalar. ```pluto [1 3 5 7] > [0 4 4 8] # [1 0 5 0] (failed cells masked to 0, in place) @@ -310,8 +356,8 @@ documented full-control opt-in. `||` fallback in value and condition position (per slot over multi-return values); value-position comparisons (yield the left operand), resolved per slot through chains, - arithmetic, and `||`/`&&` by one extraction pass; the value-position gating - `&&` (yields its right side per slot, lazy right, last-wins chains, binds + arithmetic, and `||`/`&&` by one extraction pass; the value-position + conditional `&&` (yields its right side per slot, lazy right, last-wins chains, binds tighter than `||`, so `c && v || w` is a per-slot if-else); conditions are value positions, so chained comparisons gate directly (`i > 2 < 8`, leftmost-binding, including chained multi-return gates) in every condition diff --git a/docs/Pluto IR Plan.md b/docs/Pluto IR Plan.md index 350f9dd2..b3c96c2e 100644 --- a/docs/Pluto IR Plan.md +++ b/docs/Pluto IR Plan.md @@ -18,7 +18,7 @@ It answers four questions for each statement: 1. What state or collectors must exist before evaluation? 2. Over which ranges and conditions should the statement execute? 3. Which values yield, skip, accumulate, or advance on each iteration? -4. What final values are set on the LHS after evaluation finishes? +4. Which final outcomes are committed to the LHS after evaluation finishes? The pipeline becomes: @@ -51,7 +51,7 @@ derives each slot's final effect from shared statement conditions, possibly empty ranges, conditional-yield propagation, potentially failing checked accesses, and the nearest resolver. A checked or conditional outcome is `MayWrite` unless a fallback or closing policy resolves every failure path -before the final set; for example, `x = arr[i] > 0 || 0` is `MustWrite`, while +before the final commit; for example, `x = arr[i] > 0 || 0` is `MustWrite`, while `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. @@ -65,7 +65,7 @@ PIR records source-language execution decisions: - Pluto types such as `Int`, `String`, and `Array(Int)` - LHS targets and simultaneous assignment groups - range bindings and nesting order -- statement conditions and lazy value gates +- statement gates and lazy value-position `&&` - fallback and yield/skip behavior - per-value, per-slot, per-element, and per-iteration outcomes - checked accesses and the scope affected by OOB @@ -80,11 +80,20 @@ PIR does not contain: - SSA registers, phi nodes, allocas, pointers, loads, or stores - register-versus-memory decisions - ABI details or concrete cleanup blocks +- generic user-program operations such as arbitrary `if`, `select`, or mutable + assignment Immutable plan nodes and stable IDs are sufficient. LLVM remains responsible for machine-level SSA and storage. A fuller value IR should be introduced only if Pluto later needs substantial cross-statement optimization before LLVM. +PIR is implemented as typed Go nodes. Its text form may use conventional IR +notation (`%result = operation operands : Type`), but that notation is a +deterministic rendering of the plan tree, not a separately authored or parsed +program. Ordinary arithmetic, calls, and indexing stay in solved `eval` +expressions until their range, failure, or ownership behavior requires a +dedicated semantic node. + ## 3. Statement Lifecycle Every assignment plan has four ordered phases: @@ -92,9 +101,9 @@ Every assignment plan has four ordered phases: | Phase | Responsibility | | --- | --- | | `prepare` | Establish carried values, collectors, targets, and range inputs | -| `execute` | Run versioned ranges, conditions, gates, fallbacks, yields, skips, collections, and carried updates | +| `execute` | Run range domains, statement gates, value-position `&&`, fallbacks, yields, skips, collections, and carried updates | | `finish` | Close collectors and select final carried or collected outcomes | -| `set` | Apply final outcomes to all LHS targets simultaneously | +| `commit` | Apply final outcomes to all LHS targets simultaneously | The phases describe semantics, not allocations. For example, `carry sum` in the prepare phase does not require a stack slot; it means that reads of `sum` @@ -108,34 +117,36 @@ PIR should use structured control plus a small Pluto-specific vocabulary: | --- | --- | | `eval(expr)` | Evaluate a solved Pluto expression or ordinary expression fragment | | `carry` | Declare state that may advance across iterations (prepare phase) | -| `collector` | Declare a logical collection result before its loops (prepare phase) | -| `for` | Iterate one resolved range domain | -| `if` | Apply ordinary structured control | -| `gate` | Lazily evaluate a value region only when its left outcome yields | +| `collector` | Declare a logical collection result before its domains (prepare phase) | +| `domain` | Execute a region once for each point in one resolved range domain | +| `gate` | Admit one shared statement iteration; rejection suppresses every RHS outcome and iteration update | +| `value-and` | Lazily evaluate a local value region only when its left outcome yields | | `fallback` | Lazily evaluate an alternative for missing outcomes | | `map` | Apply ordinary expression work to yielded child outcomes | | `align` | Apply explicit slot, zip-min, or broadcast alignment | | `yield` | Produce a value from the current value or cell region | | `skip` | Produce no value; the failure propagates to the nearest resolving region | | `continue` | Reject the rest of one range iteration | -| `break` | Exit a loop because of source-language break semantics | +| `break` | Exit a range domain because of source-language break semantics | | `collect` | Add a yielded cell according to the collector policy | | `advance` | Replace loop-carried state at the end of an iteration | | `drop` | Derived at region exit: free an owned outcome no consumer took (printed in expanded PIR, never authored by the builder) | | `finish` | Close a carry or collector into a final outcome | -| `set` | Assign final outcomes to semantic LHS targets | +| `commit` | Apply one simultaneous mapping from final outcomes to semantic LHS targets | Every operation corresponds to a documented language rule in the semantics docs; a new operation requires its rule to be written there first, so the vocabulary cannot grow ahead of the language. -`for`, `if`, `break`, and `continue` alone are not enough. A `skip` is -distinct from `continue`: one RHS may fail while sibling RHS expressions still -update during the same iteration. A `skip` names no scope of its own — it propagates outward to -the nearest resolving region (a `fallback`, a collector cell boundary, an -`advance`, or the final `set`), mirroring the language rule that a failure -propagates to its nearest resolver. It must remain visible to a surrounding -`fallback` before any coarser region resolves it. +Generic loops and branches are intentionally absent. `domain`, `gate`, +`value-and`, `fallback`, and checked accesses record why control exists and what +a rejected outcome means; the lowerer emits ordinary LLVM branches and loops. +A `skip` remains distinct from `continue`: one RHS may fail while sibling RHS +expressions still update during the same iteration. A `skip` names no scope of +its own — it propagates outward to the nearest resolving region (a `fallback`, +a collector cell boundary, an `advance`, or the final `commit`), mirroring the +language rule that a failure propagates to its nearest resolver. It must remain +visible to a surrounding `fallback` before any coarser region resolves it. ## 5. Plan Results @@ -147,9 +158,17 @@ Each value-producing plan node has an abstract outcome: | Domain | scalar, fixed output slots, array elements, range iterations | | Yield shape | always, scalar condition, per-slot bits, element mask, per-iteration | +Conceptually, an outcome is `(value, yielded)`, analogous to a circuit lane's +`(data, valid)`. `yielded` has the node's yield shape rather than necessarily +being one scalar bit. This is plan-level meaning, not a Pluto tuple, SSA pair, +or required runtime layout. + Zero is never a missing-value marker. A successful comparison may yield zero, -so value and yield information remain conceptually separate even though PIR does -not expose machine bits. +so value and yield information remain conceptually separate. A `gate` consumes +the relevant yield state as the shared statement-iteration enable. Local +`value-and`, `map`, and `align` operations propagate yield state with their data; +`fallback`, collector closure, `advance`, and final `commit` resolve it according +to their documented policies. `eval` leaves may retain references to typed AST nodes. The builder splits out operations that affect evaluation strategy, including ranges, lazy `&&`/`||`, @@ -157,7 +176,7 @@ conditional propagation, and collectors. Ordinary arithmetic and calls can stay inside `eval` or `map` regions and continue to use the existing expression compiler. -## 6. LHS Targets and Final Set +## 6. LHS Targets and Final Commit PIR calls LHS locations **targets**, not places or memory addresses: @@ -173,9 +192,38 @@ Targets are evaluated exactly once at the phase required by Pluto's eventual assignment semantics. The PIR-to-LLVM lowerer chooses pointers, copies, moves, and cleanup paths. -All RHS expressions in one assignment group are evaluated before final `set`. -The targets are then updated simultaneously. This preserves swaps, sibling -self-references, and ownership safety without exposing temporary storage in PIR. +All RHS expressions in one assignment group are evaluated before the final +`commit`. The target mappings are then applied simultaneously. This preserves +swaps, sibling self-references, and ownership safety without exposing temporary +storage in PIR. + +Every target slot and outcome has a stable plan ID. The builder records the +exact `target <- outcome` mapping selected by the solved assignment; the lowerer +must not reconstruct that mapping from source names, result order, or LLVM +values. A commit group follows one transfer contract: + +1. Every RHS outcome is produced against the pre-commit binding snapshot. +2. The complete outcome-to-target mapping is known before any target changes. +3. Moves, copies, and retained borrows are planned across the whole group. +4. All target mappings take effect simultaneously in Pluto semantics. +5. Replaced target values are released only after no mapped outcome can still + reference or consume them. + +For example, `a, b = b, a` produces two outcomes from the same pre-commit +snapshot and then commits the crossed mapping: + +```text +%to_a = eval #expr_b : T +%to_b = eval #expr_a : T + +commit simultaneous + @a <- %to_a + @b <- %to_b +``` + +For owned heap values, this may lower to an ownership swap without deep copies. +If one owned source feeds multiple targets, at most one consumer can take it; +the other owning consumers require a derived copy or materialization. ## 7. Loop-Carried State @@ -188,33 +236,33 @@ arr = arr ⊕ [2] ``` must make iteration `n + 1` observe the values produced by iteration `n`, while -the real LHS targets are set only after the statement's range execution ends. +the real LHS targets are committed only after the statement's range execution +ends. PIR models this with `carry` and `advance`: ```text -statement sum, arr +pir.statement @update_sum_arr prepare - carry sum from local(sum) - carry arr from local(arr) + %sum.carry = carry @sum : Int + %arr.carry = carry @arr : Array(Int) execute - for i in range(0, n) - iteration - next sum from eval(carry(sum) + 1) - next arr from eval(carry(arr) ⊕ [2]) + domain %i = range 0, @n + %sum.next = eval %sum.carry + 1 : Int + %arr.next = eval %arr.carry ⊕ [2] : Array(Int) - advance simultaneously - carry sum from next sum on-skip keep-old - carry arr from next arr on-skip keep-old + advance simultaneous + carry %sum.carry from %sum.next [on-skip=keep] + carry %arr.carry from %arr.next [on-skip=keep] finish - final sum from carry(sum) - final arr from carry(arr) + %sum.final = finish %sum.carry : Int + %arr.final = finish %arr.carry : Array(Int) - set simultaneously - local(sum) from final sum - local(arr) from final arr + commit simultaneous + @sum <- %sum.final + @arr <- %arr.final ``` The `next` and `final` labels are plan-result names, not SSA registers or storage. @@ -230,7 +278,14 @@ Loop-carried evaluation follows these rules: 7. A statement-wide rejected iteration advances no carries. 8. Nested range points advance carries in their defined lexicographic execution order. -9. `finish` exposes only the final carried values to `set`. +9. `finish` exposes only the final carried values to `commit`. + +Within `execute`, a read of an LHS destination resolves to that destination's +carry, never directly to the unchanged external target. `advance simultaneous` +updates the complete carry group only after every sibling next-outcome has been +evaluated. Consequently the next admitted iteration sees the newly advanced +values, while swaps and sibling self-references still observe one shared +iteration-start snapshot. If a destination is fresh, its seed follows existing Pluto declaration and zero-value rules. PIR must not bypass read-before-definition validation merely @@ -248,25 +303,34 @@ Every value-producing outcome carries an ownership annotation: | Annotation | Meaning | | --- | --- | | `owned` | The outcome holds heap state the plan must consume or release exactly once | -| `borrowed` | The outcome views state owned elsewhere and is never released here | +| `borrowed(owner, region)` | The outcome views state owned elsewhere, records its provenance and valid lifetime region, and is never released here | -Consumers consume ownership: `set` moves an owned outcome into a target (or +Consumers consume ownership: `commit` moves an owned outcome into a target (or copies when the source must survive), `advance` consumes it as the new carry and releases the replaced carry only after the iteration update is safe, and `collect` moves or copies it per collector policy. +Ownership is scheduled for a complete simultaneous group, not one mapping at a +time. This prevents an early target overwrite from releasing a value still used +by another swap outcome, sibling result, or carry update. + Releases are **derived, not authored**. The builder does not place cleanup: structured region exit implicitly discards any owned outcome no consumer -took, on every path — a skip arm, the untaken side of a gate or fallback, a +took, on every path — a skip arm, the untaken side of a `value-and` or fallback, a rejected iteration, or region end. The validator derives the release obligation for each owned outcome on each path and rejects a plan where one is consumed twice or escapes its region unconsumed. Expanded PIR prints the derived `drop` points so ownership regressions surface as plan diffs. The generic lowerer emits the actual frees from those derived obligations. -Borrowed outcomes are never released here; they are copied exactly at the -consumer that needs ownership (a `set` into a longer-lived target), never -earlier. +Borrowed outcomes are never released directly. The validator checks their owner +and lifetime region. Within a simultaneous commit or advance group, a borrow +from a target or carry that is itself being replaced may be promoted to transfer +of that owner's old value when exactly one owning consumer takes it and no +surviving outcome still needs the old owner. This is what permits an array or +string swap without deep copies. Otherwise, a consumer that outlives the borrow +receives a derived copy or materialization. Expanded PIR prints derived +transfers and materialization points alongside derived `drop` points. Leak checks remain necessary: a correct plan can still be lowered incorrectly, so `--leak-check` stays the runtime backstop for the lowerer. @@ -292,14 +356,14 @@ Failure scope must be explicit: | OOB in one ordinary RHS | `skip`, resolved within that RHS only | | Failed value-position comparison | `skip`, available to `fallback` | | OOB in one collector cell | `skip`, resolved at the cell boundary; the closing policy decides omit or zero-fill | -| Failed statement without a range | Final `set` applies keep-old or zero policy | +| Failed statement without a range | Final `commit` applies keep-old or zero policy | The normal per-iteration order is: 1. Enter the range point. 2. Evaluate shared statement conditions. 3. Continue the range if the shared condition rejects the point. -4. Evaluate each RHS outcome, including gates, fallbacks, and local OOB checks. +4. Evaluate each RHS outcome, including value-position `&&`, fallbacks, and local OOB checks. 5. Collect yielded cells and advance yielded carries simultaneously. This ordering prevents an OOB in `a = arr[i]` from suppressing a sibling update @@ -310,18 +374,20 @@ such as `b = i + 1`. Collectors have an explicit lifecycle: ```text -prepare - collector result : Array(Int) +pir.statement @collect_result + prepare + %result.collector = collector : Array(Int) -for i in range(0, n) - collect result - from eval(data[i]) - on-oob skip + execute + domain %i = range 0, @n + %cell = eval @data[%i] : Int [on-oob=skip] + collect %result.collector <- %cell [policy=append-yielded] -finish collector result - close append-yielded + finish + %result.final = finish %result.collector : Array(Int) -set local(result) from collector result + commit simultaneous + @result <- %result.final ``` Supported closing policies initially include: @@ -346,23 +412,24 @@ index: 2*i + 1 domain: range(0, n) ``` -The plan attaches a bounds strategy to the corresponding `for`: +The plan attaches a bounds strategy to the corresponding `domain`: ```text -for i in range(0, n) - bounds versioned - data[2*i + 1] +domain %i = range 0, @n [bounds=versioned] + access @data[2*%i + 1] [affine] ``` -Lowering computes one guard before the loop nest: +Lowering computes one guard before the loop nest. Its eventual LLVM control +flow is conceptually: ```text -if every recorded affine access is safe for the complete domain - run the body with those accesses unchecked -else - run the same body with checked accesses +%all_safe = affine-domain-check ... +br %all_safe, ^fast, ^checked ``` +The fast and checked regions are two lowerings of the same PIR domain; PIR does +not gain a generic `if` operation merely to expose that backend branch. + Affine versioning does not break out of a partially executed fast loop. Switching after some iterations could duplicate side effects, collector appends, or carry updates. If the whole-domain guard is false, the checked version runs from the @@ -374,38 +441,56 @@ non-affine accesses simply remain checked. ## 12. Default Text View -The primary PIR view should be deterministic, indentation-based structured text. -It should resemble the execution plan, not Go structs, JSON, LLVM, or a CFG. -The canonical text format uses indentation to delimit regions: +The primary PIR view should be deterministic, indentation-based structured IR. +It borrows the useful surface conventions of LLVM and MLIR — named outcomes, +operation-first syntax, explicit operands, and Pluto types — without copying +LLVM's machine-level basic blocks, phi nodes, pointers, or storage operations. +The canonical text format uses indentation to delimit semantic regions: - exactly four ASCII spaces per nesting level - no tabs - no braces or `end` markers - a region ends when indentation returns to its level or an outer level - blank lines may separate phases but do not affect structure - -The in-memory plan tree remains authoritative; indentation is its canonical text -representation. If PIR becomes parseable later, its lexer should translate -indentation changes into `INDENT` and `DEDENT` tokens. +- `%name` denotes a plan outcome, binder, or semantic handle, not a machine + register +- `@name` denotes a semantic target or source binding +- operations use `%result = operation operands : PlutoType` where they produce + an outcome +- square brackets contain declarative policies or facts, not executable code + +The in-memory typed plan tree remains authoritative; indentation is only its +canonical diagnostic rendering. PIR v1 has no parser and is never authored by +users. A parser should be added only if a concrete compiler-testing or tooling +need justifies treating the text as an interchange format. + +Using LLVM's exact text model would force PIR to express semantic regions as +basic blocks, branch labels, and phi nodes. That would erase the distinction +between a statement gate, a local skip, and a fallback, then require later code +to reconstruct it. PIR therefore adopts LLVM-like result and operation notation +while keeping Pluto-specific structured regions and Pluto types. LLVM lowering +is where those regions become CFG blocks and SSA values. For example: ```text -statement x +pir.statement @assign_x source "x = a > 0 && data[i] || -1" execute - fallback + %result = fallback : Int primary - gate - condition eval(a > 0) - then eval(data[i]) - on-oob skip - otherwise eval(-1) - - set local(x) - from fallback result - on-skip keep-old + %condition = eval @a > 0 : Int [yield=scalar] + %selected = value-and %condition : Int + %loaded = eval @data[@i] : Int [on-oob=skip] + yield %loaded + yield %selected + otherwise + %default = eval -1 : Int + yield %default + + commit simultaneous + @x <- %result ``` Recommended views: @@ -428,7 +513,7 @@ dispatch, conditional-spine extraction, range preparation, collector rewrites, bounds guards, and affine probing. The LLVM lowerer may still call existing expression and ownership helpers for -`eval`, `set`, collector, and carry operations during migration. It must not: +`eval`, `commit`, collector, and carry operations during migration. It must not: - re-run predicates to choose a different statement strategy - discover new ranges by walking the AST @@ -450,32 +535,44 @@ corresponding LLVM structure. The PIR validator should reject a plan unless: -1. The phases appear in `prepare`, `execute`, `finish`, `set` order. +1. The phases appear in `prepare`, `execute`, `finish`, `commit` order. 2. Every carry and collector is prepared before use and finished at most once. 3. Every range iterator is bound before an expression references it. 4. Every `skip` has an unambiguous nearest resolving region; every `continue` and `break` names its range. -5. Every lazy gate and fallback keeps its RHS in a lazy region. +5. Every lazy `value-and` and fallback keeps its RHS in a lazy region. 6. Outcome arity, Pluto types, domain, and yield shape match their consumers. -7. All sibling RHS expressions read the iteration-start carry snapshot. -8. All carry advances for one iteration are simultaneous. -9. A skipped carry update preserves that carry without suppressing siblings. -10. A rejected shared iteration performs no carry advance or collector append. -11. Final `set` maps each outcome to exactly one compatible target slot. -12. All targets in one assignment group are set simultaneously. -13. Every checked access has an explicit OOB scope. -14. Every unchecked access belongs to a valid whole-domain affine proof. -15. Each source expression and future nontrivial target is evaluated exactly as +7. All sibling RHS expressions in a non-ranged statement read the same + pre-commit binding snapshot. +8. All sibling RHS expressions in a ranged iteration read the same + iteration-start carry snapshot. +9. All carry advances for one iteration are simultaneous. +10. A skipped carry update preserves that carry without suppressing siblings. +11. A rejected shared iteration performs no carry advance or collector append. +12. Final `commit` provides exactly one type-compatible outcome mapping for + every target slot. +13. The lowerer consumes the recorded target-to-outcome mapping without + rematching by name, position, or generated value. +14. All targets in one assignment group are committed simultaneously. +15. Every checked access has an explicit OOB scope. +16. Every unchecked access belongs to a valid whole-domain affine proof. +17. Each source expression and future nontrivial target is evaluated exactly as many times as the plan states. -16. The plan contains no LLVM value, machine type, pointer, register, or storage +18. The plan contains no LLVM value, machine type, pointer, register, or storage decision. -17. Every owned outcome is consumed at most once, and the validator derives +19. Every owned outcome is consumed at most once, and the validator derives exactly one release obligation for every path where it is not consumed — - yield, skip, taken and untaken gate/fallback sides, rejected iterations, + yield, skip, taken and untaken `value-and`/fallback sides, rejected iterations, and region end. -18. No outcome is used after it is moved or after its derived release point, - and borrowed outcomes are copied before any consumer that outlives their - owner. +20. No outcome is used after it is moved or after its derived release point, + and a borrowed outcome that outlives its owner is copied, materialized, or + validly promoted to ownership transfer before that lifetime ends. +21. Replaced target and carry values remain live until every outcome in their + simultaneous group has finished reading or consuming them. +22. A target- or carry-origin borrow is promoted to ownership transfer only + when its owner is replaced in the same simultaneous group, exactly one + owning consumer takes it, and no surviving outcome still depends on the old + owner; all other escaping borrows are copied or materialized. Validator failures are compiler ICEs and should include the source statement and the smallest relevant PIR excerpt. @@ -491,8 +588,8 @@ the smallest relevant PIR excerpt. ### Phase 1: Plan model, printer, validator, and write effects (1-2 weeks) -- Add immutable statement, region, outcome, target, carry, collector, range, and - access plan nodes. +- Add immutable statement, region, outcome, target, carry, collector, domain, + range, and access plan nodes. - Record per-target `WriteEffect` (`MustWrite`/`MayWrite`) in the solver and migrate the CFG to consume it. Derive the effect after applying statement conditions, range execution, checked-access failure, conditional propagation, @@ -505,9 +602,11 @@ the smallest relevant PIR excerpt. **Go/no-go checkpoint:** the dump must explain why `compileLetStatement` selects its current lowering without reading LLVM helper code. -### Phase 2: Conditional values, final set, and ownership (2-3 weeks) +### Phase 2: Conditional values, final commit, and ownership (2-3 weeks) -- Add gate, fallback, map, alignment, per-slot skip, and final-set policies. +- Add value-and, fallback, map, alignment, per-slot skip, and final-commit policies. +- Add stable outcome-to-target mappings and validate simultaneous transfer, + including swaps, duplicate source use, and ownership-safe replacement. - Add owned/borrowed outcome annotations, validator-derived release obligations, and generic cleanup lowering for non-ranged statements. - Lower selected non-ranged statements from PIR using existing backend helpers. @@ -515,8 +614,10 @@ its current lowering without reading LLVM helper code. ### Phase 3: Ranges, carried state, and collectors (2-4 weeks) -- Add prepare/execute/finish/set lowering. +- Add prepare/execute/finish/commit lowering. - Add iteration snapshots and simultaneous carry advance. +- Resolve reads of ranged destinations through their current carries so the + next iteration observes the complete prior advance. - Extend ownership to carries (replaced-carry release after a safe update) and collector cells. - Add collector initialization, cell skip policies, and finalization. @@ -571,7 +672,7 @@ deleted only when its phase proves the plan replaces it: another form. Deleting it is a Phase 2 exit criterion demonstrated by the prototype, not assumed. - Staging and per-expression commit machinery: the specialized semantics are - replaced by carries and simultaneous `set`; the lowerer still needs generic + replaced by carries and simultaneous `commit`; the lowerer still needs generic storage across branches and iterations, so equivalent backend mechanics remain — reorganized, not vanished. - Mask sweeps and consumed-temporary marking: replaced by derived release @@ -625,6 +726,20 @@ long-lived parallel path. - nested ranges carry state in the defined execution order - final LHS values equal the last carried values after the loop finishes +### Commit and transfer tests + +- scalar and heap-value `a, b = b, a` swaps preserve the pre-commit values +- expanded PIR promotes each eligible target- or carry-origin borrow in a + heap-value swap to one ownership transfer rather than two deep copies +- one source mapped to multiple owning targets derives the required copy and is + never moved twice +- each multi-output expression maps to the intended target slot +- one skipped target keeps its old value while yielded siblings commit +- replaced heap targets remain alive until every sibling outcome has finished + using them +- a ranged swap reads one iteration-start snapshot, advances both carries + simultaneously, and exposes the advanced pair to the next iteration + ### Differential and backend tests - compare observable output and diagnostics between old and PIR lowering @@ -637,13 +752,24 @@ long-lived parallel path. The statement plan can grow without becoming a machine IR: -- field, index, column, and cell targets extend `set` +- field, index, column, and cell targets extend `commit` - member calls remain solved expressions inside `eval` or `map` - source `break` and `continue` extend structured range actions - function-result transfer can reuse outcome planning with a different final action - conditional arrays extend domains, alignment, and yield masks +- grouped multi-axis indexing extends borrowed selection domains after PIR migration; + scalar indices drop axes, range and omitted indices retain them, and the + leftmost range drives the sequence of yielded subarrays +- range-left value-position `&&` can later bind an outer local domain for nested + construction such as `[i && [matrix[i][j]]]`; it must remain local to that + value and must not become a statement gate or implicit collector — only an + explicit `[]` closes the bound domain into an array +- a skipped array-valued collector cell closes to a zero-filled child of its + expected shape, while `||` may provide a shape-compatible child; the validator + rejects a plan whose skipped child shape is neither known nor derivable from + its bound domains unless an explicit fallback such as `[j && 0]` supplies it - gated prints become a statement plan whose final action prints yielded - outcomes instead of setting targets + outcomes instead of committing targets - test contexts can become explicit statement inputs/effects PIR should remain statement-focused until a concrete feature requires diff --git a/docs/Pluto Memory Model.md b/docs/Pluto Memory Model.md index 1439d2f2..a5642d01 100644 --- a/docs/Pluto Memory Model.md +++ b/docs/Pluto Memory Model.md @@ -6,7 +6,8 @@ This document describes Pluto's semantic model and compares it with other major 1. **Assignment is Copy:** `a = b` creates a new independent value (Snapshot). 2. **Arrays are Values:** `arr2 = arr1` copies data (COW). -3. **Slices are Views:** `s = arr[i]` (where `i` is a Range) is a reference to `arr`. +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. @@ -23,7 +24,7 @@ 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 | -| **Slicing (`a[:]`)** | **View** | Copy (List) / View (NumPy) | View (Slice) | View (Slice) | Copy (default) / View (`@view`) | View (Slice) | +| **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 | | **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 | @@ -88,7 +89,7 @@ s := arr[0:2] s[0] = 99 // Mutates arr via s ``` -**Pluto:** "Arrays are Values, Slices are Views with Locking." +**Pluto:** "Arrays are Values, Range Selections are Views with Locking." ```python arr = [1 2 3 4 5] @@ -110,9 +111,33 @@ a[1:5] # Copy by default @view a[1:5] # View (explicit) ``` -**Pluto:** Slicing `arr[i]` creates a **View** by default. +**Pluto:** A range-valued `arr[i]` creates an `ArrayRange` view. Wrapping the +access as `[arr[i]]` materializes the selected values. -**Difference:** Pluto favors Views for slicing (like Go/Rust), Julia favors Copies unless explicitly requested. +**Difference:** Pluto separates range views from explicit collection, while +Julia copies a range selection unless a view is requested explicitly. + +### Planned rank-N range selection + +The current implementation supports one index per bracket. After ranged +collectors are represented in PIR, grouped indexing will extend the same view +model to multiple axes: + +```pluto +i = 0:3 +j = 0:3 + +view = matrix[i j] +submatrix = [matrix[i j]] +rowSums = [Sum(matrix[i j])] +``` + +`matrix[i j]` will be a borrowed lazy selection rather than an eagerly allocated +slice; `[matrix[i j]]` will materialize an owned array. The rank, axis, and +driver rules belong to +[Pluto Array Semantics](Pluto%20Array%20Semantics.md#grouped-multi-axis-indexing). +Implementation remains deferred until PIR models range ownership and collector +boundaries explicitly. --- @@ -125,7 +150,8 @@ a[1:5] # Copy by default // No hidden allocations. ``` -**Pluto:** Similar model. `ArrayRange` is essentially a Zig slice. +**Pluto:** `ArrayRange` similarly borrows an array and carries an iteration +range, but Pluto does not expose a separate slice type. **Difference:** Pluto manages memory automatically (scope-based), Zig is manual. @@ -144,7 +170,9 @@ y = i * 2 # Loop at statement: y = 8 (last scalar value) z = (i + 1) / (i + 2) # Single loop: z = 5/6 (last value) ``` -No lazy types - intermediate variables store scalar results. +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. ### IterName Determines Loop Structure @@ -171,6 +199,22 @@ result = x * y # Nested loops: i × j | **Accumulate** | `x += i` | Loop runs, x accumulates | | **Collect** | `arr = [i * 2]` | Loop runs, collects to array | +### Statement gates and value-position `&&` + +A condition before a statement value is a shared statement gate. It admits or +rejects each point of the statement's outer iteration domain for every RHS +expression and output. If a point is rejected, no sibling RHS evaluation, +collector append, carried update, or output commit runs for that point. Any +RHS-local ranges run inside the admitted points. + +An `&&` inside a value has narrower scope. It evaluates its right side lazily +when the left yields and propagates failure only through that value. It does +not gate sibling RHS expressions or the statement's shared iteration domain. +The planned `[i && [matrix[i][j]]]` construction uses this local meaning to bind +a nested range domain. Its collector-placement and fallback rules belong to +[Pluto Range Semantics](Pluto%20Range%20Semantics.md#deferred-nested-range-construction) +and remain deferred until PIR represents those scopes directly. + ### Conditional Assignment Guard expressions with conditions: @@ -294,4 +338,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 newline at end of file +- **View safety**: Compiler/runtime prevents dangling references diff --git a/docs/Pluto Range Semantics.md b/docs/Pluto Range Semantics.md index f4dfee33..c7eeef35 100644 --- a/docs/Pluto Range Semantics.md +++ b/docs/Pluto Range Semantics.md @@ -97,15 +97,21 @@ i > 2 || 0 This yields `i` when the comparison succeeds, otherwise `0`. -`&&` gates a per-iteration value: `i > 2 && i * 10` yields `i * 10` on the -iterations where the comparison yields and skips the rest. At an assignment -root a skipped iteration keeps the destination, so the last **yielded** value -wins (`x = i > 2 && i * 2` over `0:5` ends as `8`). `i > 2 && v || w` resolves -per iteration as an if-else. - -## Array Literals - -`[]` always materializes an array at the point where it appears. +A value-position `&&` conditionally sequences one per-iteration value into the +next: `i > 2 && i * 10` yields `i * 10` on the iterations where the comparison +yields and skips the rest. It is local to the containing value expression; it +is not a statement gate and does not reject sibling RHS expressions. At an +assignment root a skipped iteration keeps the destination, so the last +**yielded** value wins (`x = i > 2 && i * 2` over `0:5` ends as `8`). +`i > 2 && v || w` resolves per iteration as an if-else. + +## One-Dimensional Array Literals + +An inline headerless bracket literal materializes an array at the point where +it appears. A `\` line continuation keeps a physically wrapped literal inline. +A block literal, where `[` is followed by a newline, instead contributes fixed +row and column axes and does not use the collector behavior in this section, +even when it contains one row. Heterogeneous block literals may infer tables. The collector materializes over: @@ -232,13 +238,16 @@ fallback values win over zero-fill. ## Gated Collection Statement conditions outside `[]` gate the active iteration domain. -They do not preserve shape. +They do not preserve shape. The gate is shared by the whole statement: for a +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. Example: ```pluto i = 0:10 -arr = i > 2, i < 8 [i] +arr = i > 2 && i < 8 [i] ``` produces: @@ -249,6 +258,9 @@ produces: The conditions select which outer iterations execute the collector at all. +This is distinct from value-position `&&`, which controls only the value that +contains it and leaves sibling RHS expressions in the same statement alone. + The same admitted domain applies to nested collectors in a non-collector RHS: ```pluto @@ -270,6 +282,69 @@ arr = [i > 2 < 8] keeps the full array shape and zero-fills failed positions. +## Deferred Nested Range Construction + +After PIR owns range and collector scopes, value-position `&&` may also bind a +bare range for a local nested construction: + +```pluto +i = 0:3 +j = 0:3 +result = [i && [matrix[i][j]]] +``` + +The outer collector would iterate `i`; the right side of the value-position +`&&` would run once per `i`; the inner collector would own `j`; and the outer +collector would stack the resulting rows. This is not statement gating. A +statement gate would instead sit before the statement's RHS and would admit or +reject the shared iteration point for every RHS expression. + +The binder is what distinguishes loop levels: + +```pluto +[F(i, j)] # one flat collector over i x j +[i && [F(i, j)]] # outer i collector, inner j collector +[j && [F(i, j)]] # outer j collector, inner i collector +[i && [j && [F(i, j, k)]]] # three explicitly nested domains +[i && 1] # one scalar 1 per i +``` + +The bare range is a domain, not a truth value; the right side also runs for an +iterator value of zero. + +The value-position `&&` establishes the local range domain but never collects +its right side. An explicit collector must surround the scalar yields that +form one array value: + +```pluto +[j && -1] # one row of length len(j) +j && [-1] # one singleton-array yield per j +[j && [-1]] # stacks those arrays into a len(j) x 1 value +``` + +This same placement rule applies to fallbacks. A row fallback is +`[j && -1]`, not `j && [-1]`; the former matches the shape of a row collected +over `j`, while the latter still yields multiple array values. + +A bare range binder never fails, so `i && [matrix[i][j]] || [-1]` has no +reachable fallback: `i` yields every domain point and the inner collector +always resolves to an array. The eventual validator should diagnose that dead +fallback. When alternatives are reachable, every row must have the same shape; +Pluto does not pad, truncate, or flatten mismatched rows. + +When a condition such as `i > 0 && [F(i, j)]` fails in value position, the +enclosing collector retains that `i` position and inserts a zero-filled child +with the expected `j` shape. `|| [j && -1]` replaces that default with an +explicit row. PIR must be able to derive the skipped child's shape; otherwise +the compiler requires an explicit shape-bearing fallback instead of guessing. +`|| [j && 0]` states the default zero row directly; `|| [j && -1]` selects a +different fill value. A statement gate remains the only form that rejects the +complete iteration point for every RHS expression. + +This range-left extension is deliberately not part of the current semantics. +It should be implemented only after PIR can state which collector owns each +range and validate that ownership before LLVM lowering. + ## Statement Conditions And Tuples Statement conditions are shared across the whole assignment. @@ -314,11 +389,11 @@ Inside the RHS, the same name refers to the current scalar iterator value, not to a fresh nested loop. For non-collector tuple outputs, one admitted statement iteration is still one -shared scalar update step. -If one non-collector RHS hits an out-of-bounds failure on that iteration, -sibling non-collector outputs keep their previous values for that same -iteration. -Top-level `[]` collectors still use their own local zero-fill rules for cells. +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; +yielding siblings still update. Only rejection by the shared statement gate +suppresses every sibling. Top-level `[]` collectors use their own local +zero-fill rules for cells. ## Nested Collectors diff --git a/lexer/lexer.go b/lexer/lexer.go index 949481fa..a4fb3290 100644 --- a/lexer/lexer.go +++ b/lexer/lexer.go @@ -10,16 +10,17 @@ import ( ) type Lexer struct { - FileName string - input []rune - position int // current position in input (points to current rune) - readPosition int // current reading position in input (after current rune) - curr rune // current rune under examination - lineOffset int // line number - column int // column number in the line - onNewline bool // at beginning of new line - indentStack []int // indentation level stack - toDeindent int // number of deindent tokens to be emitted before we continue with current token + FileName string + input []rune + position int // current position in input (points to current rune) + readPosition int // current reading position in input (after current rune) + curr rune // current rune under examination + lineOffset int // line number + column int // column number in the line + onNewline bool // at beginning of new line + continuedLine bool // preceding backslash suppresses indentation on the next physical line + indentStack []int // indentation level stack + toDeindent int // number of deindent tokens to be emitted before we continue with current token } const ( @@ -65,9 +66,11 @@ func (l *Lexer) NextToken() (token.Token, *token.CompileError) { case '\n': tok = l.createToken(token.NEWLINE, token.SYM_NEWLINE, hadSpace) l.newLine() - l.onNewline = true + l.onNewline = !l.continuedLine + l.continuedLine = false case '\\': tok = l.createToken(token.BACKSLASH, token.SYM_BACKSLASH, hadSpace) + l.continuedLine = l.peekRune() == '\n' case '"': tok = l.createToken(token.STRING, token.SYM_DQUOTE, hadSpace) l.readRune() diff --git a/parser/codeparser_test.go b/parser/codeparser_test.go index 134ffa3b..0878213c 100644 --- a/parser/codeparser_test.go +++ b/parser/codeparser_test.go @@ -401,9 +401,7 @@ func TestParseStructDefinition(t *testing.T) { _, constExists := code.ConstNames["p"] require.True(t, constExists) - require.Equal(t, `p = Person - :name age height - Tejas 35 184.5`, stmt.String()) + require.Equal(t, "p = Person\n :name age height\n \"Tejas\" 35 184.5", stmt.String()) } func TestStructDefErrors(t *testing.T) { diff --git a/parser/parser.go b/parser/parser.go index 29309061..508931ec 100644 --- a/parser/parser.go +++ b/parser/parser.go @@ -1110,6 +1110,7 @@ func (p *StmtParser) parseArrayLiteral() ast.Expression { } p.nextToken() // consume the '[' token + arr.Block = p.curTokenIs(token.NEWLINE) for p.curTokenIs(token.NEWLINE) { p.nextToken() @@ -1120,7 +1121,15 @@ func (p *StmtParser) parseArrayLiteral() ast.Expression { // Parse headers if present if p.curTokenIs(token.COLON) { + headerToken := p.curToken p.nextToken() // consume ':' + if p.curTokenIs(token.NEWLINE) || p.curTokenIs(token.RBRACK) || p.curTokenIs(token.EOF) { + p.errors = append(p.errors, &token.CompileError{ + Token: headerToken, + Msg: "expected at least one column header after ':'", + }) + return nil + } if !p.parseHeader(arr) { return nil } @@ -1147,6 +1156,13 @@ func (p *StmtParser) parseArrayLiteral() ast.Expression { }) return nil } + if len(arr.Headers) == 0 && !arr.Block && len(arr.Rows) > 1 { + p.errors = append(p.errors, &token.CompileError{ + Token: arr.Rows[1][0].Tok(), + Msg: "inline array literals have one logical row; use '\\' to continue the row or put a newline immediately after '[' for block layout", + }) + return nil + } // Do not consume the closing ']' here. Align with grouped-expression // behavior and leave curToken at the closing token; callers (statement // parsing) will advance past newline/EOF as appropriate. diff --git a/parser/scriptparser_test.go b/parser/scriptparser_test.go index 7a78ed74..e6f2ae53 100644 --- a/parser/scriptparser_test.go +++ b/parser/scriptparser_test.go @@ -197,6 +197,7 @@ func TestStringLiteral(t *testing.T) { lit, ok := printStmt.Expression.Arguments[0].(*ast.StringLiteral) require.Truef(t, ok, "expected *ast.StringLiteral, got %T", printStmt.Expression.Arguments[0]) require.Equal(t, "hello", lit.Token.Literal) + require.Equal(t, input, lit.String()) } func TestParsingPrefixExpressions(t *testing.T) { @@ -574,6 +575,7 @@ func TestConditionThenArrayValue(t *testing.T) { require.Truef(t, testIntegerLiteral(t, arr.Rows[0][0], 1), "first element mismatch") require.Truef(t, testIntegerLiteral(t, arr.Rows[0][1], 2), "second element mismatch") require.Truef(t, testIntegerLiteral(t, arr.Rows[0][2], 3), "third element mismatch") + require.Equal(t, "[1 2 3]", arr.String()) } func TestConditionThenCallValue(t *testing.T) { @@ -1144,6 +1146,7 @@ func TestArrayLiterals(t *testing.T) { checkResult: func(t *testing.T, arr *ast.ArrayLiteral) { require.Empty(t, arr.Headers, "expected no headers") require.Empty(t, arr.Rows, "expected no rows") + require.False(t, arr.Block) }, }, { @@ -1154,9 +1157,11 @@ func TestArrayLiterals(t *testing.T) { ]`, checkResult: func(t *testing.T, arr *ast.ArrayLiteral) { require.Empty(t, arr.Headers, "expected no headers for matrix") + require.True(t, arr.Block) require.Len(t, arr.Rows, 2, "expected 2 rows") require.Len(t, arr.Rows[0], 3, "expected 3 elements in first row") require.Len(t, arr.Rows[1], 3, "expected 3 elements in second row") + require.Equal(t, "[\n 1 2 3\n 4 5 6\n]", arr.String()) // Check first row: 1 2 3 require.True(t, testIntegerLiteral(t, arr.Rows[0][0], 1)) @@ -1169,12 +1174,23 @@ func TestArrayLiterals(t *testing.T) { require.True(t, testIntegerLiteral(t, arr.Rows[1][2], 6)) }, }, + { + name: "one-row block array", + input: `[ + 1 2 3 +]`, + checkResult: func(t *testing.T, arr *ast.ArrayLiteral) { + require.True(t, arr.Block) + require.Len(t, arr.Rows, 1) + require.Equal(t, "[\n 1 2 3\n]", arr.String()) + }, + }, { name: "array with headers", input: `[ - : Day Product Price - "Monday" "Phone" 200 - "Tuesday" "Laptop" 300 + :Day Product Price + "Monday" "Phone" 200 + "Tuesday" "Laptop" 300 ]`, checkResult: func(t *testing.T, arr *ast.ArrayLiteral) { require.Equal(t, []string{"Day", "Product", "Price"}, arr.Headers) @@ -1224,14 +1240,19 @@ func TestArrayLiterals(t *testing.T) { expectError: true, errorMsg: "expected identifier for column header", }, + { + name: "header marker without columns", + input: "[:\n]", + expectError: true, + errorMsg: "expected at least one column header after ':'", + }, { name: "line continuation with unary operators", - input: `[ - a -b \ - -c d -]`, + input: `[a -b \ + -c d]`, checkResult: func(t *testing.T, arr *ast.ArrayLiteral) { require.Empty(t, arr.Headers, "expected no headers") + require.False(t, arr.Block) require.Len(t, arr.Rows, 1, "expected 1 row (line continuation should merge)") require.Len(t, arr.Rows[0], 4, "expected 4 elements: a, -b, -c, d") @@ -1254,6 +1275,12 @@ func TestArrayLiterals(t *testing.T) { require.True(t, testIdentifier(t, arr.Rows[0][3], "d")) }, }, + { + name: "inline array starts a second row", + input: "[1 2\n3 4]", + expectError: true, + errorMsg: "inline array literals have one logical row", + }, { name: "attached unary plus", input: `[a +b]`, @@ -1341,7 +1368,7 @@ val = data[i]`, if len(program.Statements) == 1 { stmt = requireOnlyLetStmt(t, program) } else { - require.Len(t, program.Statements, 2, "expected two statements for identifier slice case") + require.Len(t, program.Statements, 2, "expected two statements for identifier range case") stmt = program.Statements[1].(*ast.LetStatement) } require.Len(t, stmt.Value, 1, "expected single RHS expression") @@ -1354,7 +1381,7 @@ val = data[i]`, }) } - // Ensure slices inside expressions parse correctly. + // Ensure array ranges inside expressions parse correctly. l := lexer.New("TestArrayIndexInfix", "res = data[0:3] + 1") sp := NewScriptParser(l) program := sp.Parse() diff --git a/runtime/array.c b/runtime/array.c index 45d75be5..36de2a6a 100644 --- a/runtime/array.c +++ b/runtime/array.c @@ -328,52 +328,145 @@ static int strbuf_format_str(StrBuf* sb, const char* fmt, int dynamic_arg_count, value ? value : ""); } -const char* arr_i64_str(const PtArrayI64* a) { +static int strbuf_array_cell(StrBuf* sb, const void* values, int32_t kind, size_t index) { + switch (kind) { + case PT_ELEM_I64: + return strbuf_printf(sb, "%lld", (long long)arr_i64_get((const PtArrayI64*)values, index)); + case PT_ELEM_F64: { + double value = arr_f64_get((const PtArrayF64*)values, index); + const char* special = f64_special_str(value); + return special ? strbuf_printf(sb, "%s", special) : strbuf_printf(sb, "%g", value); + } + case PT_ELEM_STR: { + char* quoted = str_quote(arr_str_borrow((const PtArrayStr*)values, index)); + if (!quoted) return -1; + int result = strbuf_printf(sb, "%s", quoted); + free(quoted); + return result; + } + default: + return -1; + } +} + +static int strbuf_indent(StrBuf* sb, size_t depth) { + if (strbuf_printf(sb, "\n") < 0) return -1; + for (size_t i = 0; i < depth; ++i) { + if (strbuf_printf(sb, " ") < 0) return -1; + } + return 0; +} + +static int strbuf_array_nd(StrBuf* sb, const void* values, int32_t kind, size_t rank, + const size_t* dimensions, size_t depth, size_t* value_index) { + if (strbuf_printf(sb, "[") < 0) return -1; + + if (rank == 1) { + for (size_t i = 0; i < dimensions[0]; ++i) { + if (i > 0 && strbuf_printf(sb, " ") < 0) return -1; + if (strbuf_array_cell(sb, values, kind, (*value_index)++) < 0) return -1; + } + } else if (rank == 2 && dimensions[0] > 0 && dimensions[1] == 0) { + /* Empty rows need explicit child arrays because blank block rows carry no shape. */ + for (size_t row = 0; row < dimensions[0]; ++row) { + if (row > 0 && strbuf_printf(sb, " ") < 0) return -1; + if (strbuf_printf(sb, "[]") < 0) return -1; + } + } else { + /* Block layout consumes two dimensions; each cell prints the remaining rank. */ + for (size_t row = 0; row < dimensions[0]; ++row) { + if (strbuf_indent(sb, depth + 1) < 0) return -1; + for (size_t col = 0; col < dimensions[1]; ++col) { + if (col > 0 && strbuf_printf(sb, " ") < 0) return -1; + if (rank == 2) { + if (strbuf_array_cell(sb, values, kind, (*value_index)++) < 0) return -1; + } else if (strbuf_array_nd(sb, values, kind, rank - 2, dimensions + 2, + depth + 1, value_index) < 0) { + return -1; + } + } + } + if (strbuf_indent(sb, depth) < 0) return -1; + } + + return strbuf_printf(sb, "]"); +} + +const char* array_nd_str(const void* values, int32_t kind, size_t rank, + const size_t* dimensions) { StrBuf sb = {malloc(256), 0, 256}; if (!sb.data) return NULL; - if (strbuf_printf(&sb, "[") < 0) { free(sb.data); return NULL; } - for (size_t i = 0; i < arr_i64_len(a); ++i) { - if (i > 0 && strbuf_printf(&sb, " ") < 0) { free(sb.data); return NULL; } - if (strbuf_printf(&sb, "%lld", (long long)arr_i64_get(a, i)) < 0) { free(sb.data); return NULL; } - } - if (strbuf_printf(&sb, "]") < 0) { free(sb.data); return NULL; } + + if (rank == 0 || !dimensions) goto fail; + size_t value_index = 0; + if (strbuf_array_nd(&sb, values, kind, rank, dimensions, 0, &value_index) < 0) goto fail; return sb.data; + +fail: + free(sb.data); + return NULL; } -const char* arr_f64_str(const PtArrayF64* a) { +/* Aborts the process; called from generated code when a runtime shape check + * fails while stacking or concatenating arrays. Static shape mismatches are + * compile errors instead. */ +void array_shape_fail(size_t expected, size_t got) { + fprintf(stderr, "pluto: array shape mismatch: expected dimension %zu, got %zu\n", + expected, got); + fflush(stderr); + abort(); +} + +const char* table_str(size_t rows, size_t cols, const char* const* names, + const int32_t* kinds, const void* const* columns) { StrBuf sb = {malloc(256), 0, 256}; if (!sb.data) return NULL; - if (strbuf_printf(&sb, "[") < 0) { free(sb.data); return NULL; } - for (size_t i = 0; i < arr_f64_len(a); ++i) { - if (i > 0 && strbuf_printf(&sb, " ") < 0) { free(sb.data); return NULL; } - double val = (double)arr_f64_get(a, i); - // Handle special values using common formatting function - const char *special = f64_special_str(val); - if (special) { - if (strbuf_printf(&sb, "%s", special) < 0) { free(sb.data); return NULL; } - } else { - if (strbuf_printf(&sb, "%g", val) < 0) { free(sb.data); return NULL; } + + int has_headers = 0; + for (size_t col = 0; col < cols; ++col) { + if (names[col][0] != '\0') { + has_headers = 1; + break; } } - if (strbuf_printf(&sb, "]") < 0) { free(sb.data); return NULL; } + + if (strbuf_printf(&sb, "[") < 0) goto fail; + if (has_headers) { + if (strbuf_printf(&sb, "\n : ") < 0) goto fail; + for (size_t col = 0; col < cols; ++col) { + if (col > 0 && strbuf_printf(&sb, " ") < 0) goto fail; + if (strbuf_printf(&sb, "%s", names[col]) < 0) goto fail; + } + } + for (size_t row = 0; row < rows; ++row) { + if (strbuf_printf(&sb, "\n ") < 0) goto fail; + for (size_t col = 0; col < cols; ++col) { + if (col > 0 && strbuf_printf(&sb, " ") < 0) goto fail; + if (strbuf_array_cell(&sb, columns[col], kinds[col], row) < 0) goto fail; + } + } + if ((has_headers || rows > 0) && strbuf_printf(&sb, "\n") < 0) goto fail; + if (strbuf_printf(&sb, "]") < 0) goto fail; return sb.data; + +fail: + free(sb.data); + return NULL; +} + +const char* arr_i64_str(const PtArrayI64* a) { + size_t length = arr_i64_len(a); + return array_nd_str(a, PT_ELEM_I64, 1, &length); +} + +const char* arr_f64_str(const PtArrayF64* a) { + size_t length = arr_f64_len(a); + return array_nd_str(a, PT_ELEM_F64, 1, &length); } const char* arr_str_str(const PtArrayStr* a) { - StrBuf sb = {malloc(256), 0, 256}; - if (!sb.data) return NULL; - if (strbuf_printf(&sb, "[") < 0) { free(sb.data); return NULL; } - size_t n = arr_str_len(a); - for (size_t i = 0; i < n; ++i) { - if (i > 0 && strbuf_printf(&sb, " ") < 0) { free(sb.data); return NULL; } - char* quoted = str_quote(arr_str_borrow(a, i)); - if (!quoted) { free(sb.data); return NULL; } - int ok = strbuf_printf(&sb, "%s", quoted); - free(quoted); - if (ok < 0) { free(sb.data); return NULL; } - } - if (strbuf_printf(&sb, "]") < 0) { free(sb.data); return NULL; } - return sb.data; + size_t length = arr_str_len(a); + return array_nd_str(a, PT_ELEM_STR, 1, &length); } const char* arr_i64_format(const PtArrayI64* a, const char* fmt, diff --git a/runtime/array.h b/runtime/array.h index da9ceae5..67f48261 100644 --- a/runtime/array.h +++ b/runtime/array.h @@ -74,6 +74,17 @@ const char* arr_f64_format(const PtArrayF64* a, const char* fmt, const char* arr_str_format(const PtArrayStr* a, const char* fmt, int dynamic_arg_count, int first_arg, int second_arg); +typedef enum { + PT_ELEM_I64, + PT_ELEM_F64, + PT_ELEM_STR +} PtElementKind; + +const char* array_nd_str(const void* values, int32_t kind, size_t rank, const size_t* dimensions); +void array_shape_fail(size_t expected, size_t got); +const char* table_str(size_t rows, size_t cols, const char* const* names, + const int32_t* kinds, const void* const* columns); + #ifdef __cplusplus } /* extern "C" */ #endif diff --git a/tests/array/array.exp b/tests/array/array.exp index eba65bdf..eaae80a5 100644 --- a/tests/array/array.exp +++ b/tests/array/array.exp @@ -15,3 +15,74 @@ quoted arr is ["hello" "hello world" "" "say \"hi\"" "slash\\" "line\nbreak" "ta arr2 is [1 2 3 4] [1.1 2.3 4 2.1] arr3 is [1.1 2.3 4 2.1] +one-row matrix: [ + 1 2 3 +] +matrix: [ + 1 2.5 + 3 4 +] +nested matrix: [ + 1 2 + 3 4 +] +matrix row: [3 4] +matrix cell: 3 +selected rows: [ + 1 2 + 3 4 +] +selected rows OOB: [ + 3 4 + 0 0 + 0 0 +] +stacked row OOB: [ + 1 2 + 0 0 +] +reset matrix: [ + 5 6 +] +rank 3: [ + [1 2] [3 4] + [5 6] [7 8] +] +rank 3 plus: [ + [2 3] [4 5] + [6 7] [8 9] +] +rank 3 cell: 6 +string matrix: [ + "Ada" "" + "Lin" "Mae" +] +string matrix row: ["Ada" ""] +table: [ + : Name Score + "Ada" 10 + "Lin" 12 +] +table scores: [10 12] +replacement table: [ + : Name Score + "Mae" 20 +] +saved table scores: [10 12] +empty array: [] +empty matrix: [ +] +rank 2 empty: [[]] +rank 2 refined: [ + 9 +] +empty concat: [] +empty table: [ + : Name Score +] +empty score column: [] +typed empty scores: [7 8] +inferred table: [ + 1 "a" + 2 "b" +] diff --git a/tests/array/array.spt b/tests/array/array.spt index 7d8a1c44..c63d462e 100644 --- a/tests/array/array.spt +++ b/tests/array/array.spt @@ -1,7 +1,4 @@ -arrInt = -[ - 1 2 3 4 -] +arrInt = [1 2 3 4] arrInt "arr is -arrInt" arrIntWidth = 4 @@ -10,10 +7,7 @@ arrIntWidth = 4 arrChars = [65 0 66 256] "arr chars: |-arrChars%5c|" -arrFloat = -[ - 1.5 -2.5 3 4.25 -] +arrFloat = [1.5 -2.5 3 4.25] arrFloat "arr is -arrFloat" "arr float fixed: |-arrFloat%7.2f|" @@ -21,10 +15,7 @@ arrWidth = 7 arrPrecision = 1 "arr float dynamic: |-arrFloat%(-arrWidth).(-arrPrecision)f|" -arrStr = -[ - "foo" "bar" "baz" -] +arrStr = ["foo" "bar" "baz"] arrStr "arr is -arrStr" "arr str raw: -arrStr%s" @@ -37,9 +28,107 @@ arr2 = [1 2 3 4] arr2 "arr2 is -arr2" -# bracket on same line, then rows on next line(s) -arr3 = [ - 1.1 2.3 4 2.1 -] +# line continuation keeps an inline literal rank 1 +arr3 = [1.1 2.3 \ + 4 2.1] arr3 "arr3 is -arr3" + +oneRowMatrix = [ + 1 2 3 +] +"one-row matrix: -oneRowMatrix" + +matrix = +[ + 1 2.5 + 3 4 +] +"matrix: -matrix" + +firstRow = [1 2] +nestedMatrix = [firstRow [3 4]] +"nested matrix: -nestedMatrix" +matrixRow = nestedMatrix[1] +matrixCell = matrixRow[0] +"matrix row: -matrixRow" +"matrix cell: -matrixCell" + +rowRange = 0:2 +selectedRows = [nestedMatrix[rowRange]] +"selected rows: -selectedRows" + +rowRangeOOB = 1:4 +selectedRowsOOB = [nestedMatrix[rowRangeOOB]] +"selected rows OOB: -selectedRowsOOB" + +stackedRowOOB = [nestedMatrix[0] nestedMatrix[3]] +"stacked row OOB: -stackedRowOOB" + +nestedMatrix = [] +nestedMatrix = nestedMatrix ⊕ [[5 6]] +"reset matrix: -nestedMatrix" + +rank3 = [ + [1 2] [3 4] + [5 6] [7 8] +] +"rank 3: -rank3" +rank3Plus = rank3 + 1 +"rank 3 plus: -rank3Plus" +rank3Cell = rank3[1][0][1] +"rank 3 cell: -rank3Cell" + +stringMatrix = [ + "Ada" "" + "Lin" "Mae" +] +stringMatrixRow = stringMatrix[0] +"string matrix: -stringMatrix" +"string matrix row: -stringMatrixRow" + +scores = +[ + : Name Score + "Ada" 10 + "Lin" 12 +] +"table: -scores" +scoreColumn = scores.Score +"table scores: -scoreColumn" +scores = +[ + : Name Score + "Mae" 20 +] +"replacement table: -scores" +"saved table scores: -scoreColumn" + +emptyArray = [] +"empty array: -emptyArray" +emptyMatrix = [ +] +"empty matrix: -emptyMatrix" +rank2Refined = [[]] +"rank 2 empty: -rank2Refined" +rank2Refined = [[9]] +"rank 2 refined: -rank2Refined" +emptyConcat = [] ⊕ [] +"empty concat: -emptyConcat" + +emptyTable = +[ + : Name Score +] +"empty table: -emptyTable" +emptyScoreColumn = emptyTable.Score +"empty score column: -emptyScoreColumn" +typedEmptyScores = emptyTable.Score ⊕ [7 8] +"typed empty scores: -typedEmptyScores" + +inferredTable = +[ + 1 "a" + 2 "b" +] +"inferred table: -inferredTable" diff --git a/tests/array/array_elementwise.exp b/tests/array/array_elementwise.exp index 3c338834..4ca8762d 100644 --- a/tests/array/array_elementwise.exp +++ b/tests/array/array_elementwise.exp @@ -20,3 +20,10 @@ Polynomial 1 (Float): [-33.6 -103.8] Polynomial 2 (Int): [-76 -366] Poly zip x*y: [141 1261] Poly zip add mix: [221 440] +Rank 2 zip: [ + 11 22 + 34 45 +] +Rank 3 zip: [ + [101 202] [304 405] +] diff --git a/tests/array/array_elementwise.spt b/tests/array/array_elementwise.spt index f3a41d51..9da1b97e 100644 --- a/tests/array/array_elementwise.spt +++ b/tests/array/array_elementwise.spt @@ -1,5 +1,5 @@ # Test element-wise array-array operations -# Result length is min(len(arr1), len(arr2)) (zip semantics) +# Each result dimension is the minimum corresponding input dimension. # No implicit padding; extra elements are ignored. # Same length arrays @@ -99,3 +99,28 @@ polyZip = 1 + 4x * y^2 + 3x^2 * y + x^3 * y^3 # Multi-variable polynomial with mixed powers and addition (zip-to-min) polyAdd = 2x^2 + 3y^3 + 5x * y + 7 "Poly zip add mix: -polyAdd" + +# Rank-N arrays zip every dimension to its minimum. Source strides may differ. +rank2ZipLeft = [ + 1 2 3 + 4 5 6 +] +rank2ZipRight = [ + 10 20 + 30 40 + 50 60 +] +rank2Zip = rank2ZipLeft + rank2ZipRight +"Rank 2 zip: -rank2Zip" + +rank3ZipLeft = [ + [1 2 3] [4 5 6] + [7 8 9] [10 11 12] +] +rank3ZipRight = [[ + 100 200 + 300 400 + 500 600 +]] +rank3Zip = rank3ZipLeft + rank3ZipRight +"Rank 3 zip: -rank3Zip" diff --git a/tests/array/array_func.exp b/tests/array/array_func.exp index e9b959d3..26ee6005 100644 --- a/tests/array/array_func.exp +++ b/tests/array/array_func.exp @@ -9,8 +9,27 @@ ProdReverse: [5 10] SumProductScalar: 5 ProdScalar: 6 SquareScalar: 49 +MatrixIdentity: [ + 1 2 + 3 4 +] +TableIdentity: [ + : Name Score + "Ada" 10 + "Lin" 12 +] +TableCallColumn: [10 12] +GatedTableColumn: ["" "Lin"] DirectConcatScalar: [0] DirectConcatRange: [0 1 2 3 4] +AddElemDirect: [1] +AddElemNamed: [2] +AddElemInPlace: [3] +ConcatEmpty: [] +IdentityEmpty: [] +BeforeEmptyReset: [9] +AfterEmptyReset: [] +EmptyResetKeepsType: [4] SquareVecRange: [0 1 4 9 16] SquareInline: [0 1 4 9 16] SquareSameDriver: [4 5 8 13 20] diff --git a/tests/array/array_func.pt b/tests/array/array_func.pt index 16e22c8a..0cee3b68 100644 --- a/tests/array/array_func.pt +++ b/tests/array/array_func.pt @@ -17,4 +17,13 @@ res = PairSum(i, j) res = i + j res = Acc(a, x) - res = a + x \ No newline at end of file + res = a + x + +res = Identity(x) + res = x + +res = AddElem(a, elem) + res = a ⊕ [elem] + +res = ConcatArrays(a, b) + res = a ⊕ b diff --git a/tests/array/array_func.spt b/tests/array/array_func.spt index c0d7fda3..bceb7359 100644 --- a/tests/array/array_func.spt +++ b/tests/array/array_func.spt @@ -23,6 +23,33 @@ scalar_sum, scalar_prod = SumAndProduct(2, 3) scalar = Square(7) "SquareScalar: -scalar" +matrixIdentity = Identity([ + 1 2 + 3 4 +]) +"MatrixIdentity: -matrixIdentity" + +tableIdentity = Identity([ + : Name Score + "Ada" 10 + "Lin" 12 +]) +"TableIdentity: -tableIdentity" + +tableCallColumn = Identity([ + : Name Score + "Ada" 10 + "Lin" 12 +]).Score +"TableCallColumn: -tableCallColumn" + +gatedTableColumn = (1 > 0 && Identity([ + : Name Score + "Ada" 10 + "Lin" 12 +])).Name > "K" +"GatedTableColumn: -gatedTableColumn" + arr1 = [] ⊕ [0] "DirectConcatScalar: -arr1" @@ -30,6 +57,30 @@ arr2 = [] arr2 = arr2 ⊕ [0:5] "DirectConcatRange: -arr2" +emptyDirect = AddElem([], 1) +"AddElemDirect: -emptyDirect" + +emptyNamed = [] +emptyNamedResult = AddElem(emptyNamed, 2) +"AddElemNamed: -emptyNamedResult" + +emptyInPlace = [] +emptyInPlace = AddElem(emptyInPlace, 3) +"AddElemInPlace: -emptyInPlace" + +emptyConcat = ConcatArrays([], []) +"ConcatEmpty: -emptyConcat" + +emptyIdentity = Identity([]) +"IdentityEmpty: -emptyIdentity" + +lockedArray = [9] +"BeforeEmptyReset: -lockedArray" +lockedArray = [] +"AfterEmptyReset: -lockedArray" +lockedArray = AddElem(lockedArray, 4) +"EmptyResetKeepsType: -lockedArray" + # Range call in array literal squareVec = [Square(0:5)] "SquareVecRange: -squareVec" diff --git a/tests/array/cond_accum.exp b/tests/array/cond_accum.exp index 5dc46055..70e29892 100644 --- a/tests/array/cond_accum.exp +++ b/tests/array/cond_accum.exp @@ -51,12 +51,23 @@ MixedScalarAccumX: 3. MixedScalarAccumY: 5. MixedScalarAccumZ: [0 10 1 20 2 0 3 InterleavedAccumX: [10 1 20 20 1 30 30 1 40 40 1 0]. InterleavedAccumY: 3. InterleavedAccumZ: [1 2 0 1 2 1 1 2 2 1 2 3] 7 CondLastWins: 2 +LoopCarriedGateMax: 80. LoopCarriedGateMin: 4 12 RangePrefixRewrite: -2 RangeInfixRewrite: -1 SameDriverCondNested: [100 101] CondCollectorLocalRange: [2 3] SameDriverCondConst: [1 1 1] +GatedRows: [ + 10 11 + 20 21 +] +GatedRowsEmpty: [ +] +GatedNestedRows: [ + 11 12 + 11 12 +] CondCollectorAffine: [20 30 40 50] CondCollectorAffineOOB: [20 40] CapturedCollector: [0 0 0]. CapturedCollectorUse: [5 5 5] diff --git a/tests/array/cond_accum.spt b/tests/array/cond_accum.spt index 163363c2..d2ff4afe 100644 --- a/tests/array/cond_accum.spt +++ b/tests/array/cond_accum.spt @@ -293,6 +293,15 @@ i = 0:5 ce = i < 3 i > 1 "CondLastWins: -ce" +# Statement gates read loop-carried destination values, not their pre-loop seeds. +foldData = [10 20 30 40 50 40 30 20 70 80 5 4 6 70] +i = 0:14 +foldMax = 0 +foldMax = foldData[i] > foldMax foldData[i] +foldMin = i == 0 foldData[i] +foldMin = foldData[i] < foldMin foldData[i] +"LoopCarriedGateMax: -foldMax. LoopCarriedGateMin: -foldMin" + # Ranged condition + range literal in infix value (rewrite used) rl = 0 i = 0:3 @@ -328,6 +337,23 @@ i = 0:6 sameCondConst = i > 2 1 + [0] "SameDriverCondConst: -sameCondConst" +# Array-valued collectors stack rows over the admitted statement domain, +# including when the collector is nested inside another value expression. +domainMatrix = [ + 10 11 + 20 21 + 30 31 +] +i = 0:3 +gatedRows = i < 2 [domainMatrix[i]] +"GatedRows: -gatedRows" +i = 0:3 +gatedRows = i < 0 [domainMatrix[i]] +"GatedRowsEmpty: -gatedRows" +i = 0:3 +gatedNestedRows = i < 2 [domainMatrix[0]] + 1 +"GatedNestedRows: -gatedNestedRows" + # Collector loops can use the same affine fast path even when the statement # condition itself contains affine array accesses. condAffineArr = [10 20 30 40 50] diff --git a/tests/math/func_nested_range.spt b/tests/math/func_nested_range.spt index 863e2a95..6de165f5 100644 --- a/tests/math/func_nested_range.spt +++ b/tests/math/func_nested_range.spt @@ -2,7 +2,7 @@ i = 0:5 j = 0:3 -# [i] materializes to [0 1 2 3 4], then [j] slices/indexes that array-range. +# [i] materializes to [0 1 2 3 4], then [j] indexes that array-range. # Last iteration for j is 2, so Square([0 1 2]) keeps the last value 4. nested = Square([i][j]) "Square([i][j]): -nested"