From 84de748527609b0772b2dbd122a8c3b5bcc9f093 Mon Sep 17 00:00:00 2001 From: Tejas Date: Wed, 15 Jul 2026 10:19:17 +0530 Subject: [PATCH 01/33] feat(compiler): infer arrays, matrices, and tables Refactor Array into a homogeneous one-dimensional type and classify bracket literals from their shape and column schemas. Multi-row homogeneous literals become row-major matrices, while named or heterogeneous-column literals become columnar tables; a bare header row forces an unnamed table without adding a keyword. Add LLVM/runtime lowering, value-copy and cleanup semantics, named column access, printing, ABI mangling, documentation, and focused end-to-end coverage for the new collection types. --- README.md | 24 ++- ast/ast.go | 22 +-- compiler/array.go | 64 ++++--- compiler/bounds.go | 4 +- compiler/cfuncs.go | 6 + compiler/compiler.go | 170 ++++++++++++++---- compiler/compiler_test.go | 6 +- compiler/cond.go | 2 +- compiler/format.go | 12 +- compiler/mangle_test.go | 21 ++- compiler/solver.go | 325 +++++++++++++++++----------------- compiler/solver_test.go | 11 +- compiler/table.go | 160 +++++++++++++++++ compiler/types.go | 166 +++++++++++++---- docs/Pluto C ABI Spec.md | 10 +- docs/Pluto Range Semantics.md | 7 +- parser/parser.go | 1 + parser/scriptparser_test.go | 3 + runtime/array.c | 64 +++++++ runtime/array.h | 10 ++ tests/array/array.exp | 25 +++ tests/array/array.spt | 39 ++++ tests/array/array_func.exp | 9 + tests/array/array_func.pt | 5 +- tests/array/array_func.spt | 13 ++ 25 files changed, 887 insertions(+), 292 deletions(-) create mode 100644 compiler/table.go diff --git a/README.md b/README.md index 9dd0a759..626a1cf4 100644 --- a/README.md +++ b/README.md @@ -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 arrays (safe slicing), inferred row-major matrices, 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,28 @@ 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 matrices and tables without a type keyword: + +```python +matrix = [ + 1 2 + 3 4 +] + +scores = [ + : Name Score + "Ada" 10 + "Lin" 12 +] +``` + +An empty or one-row headerless literal is an array. A rectangular, multi-row +literal whose cells share one promotable type is a row-major matrix. A header +row always produces a columnar table; without headers, homogeneous columns +with different element types infer an unnamed table. Use a bare `:` header row +to force an unnamed table when all columns have the same type. Named columns +are arrays, so `scores.Score` returns `[10 12]`. + String-array elements print quoted so boundaries stay unambiguous: ```python diff --git a/ast/ast.go b/ast/ast.go index 82381f04..60b30bd0 100644 --- a/ast/ast.go +++ b/ast/ast.go @@ -299,10 +299,11 @@ func (rl *RangeLiteral) String() string { } type ArrayLiteral struct { - Token token.Token // the '[' token - Headers []string // column headers (empty for matrices) - Rows [][]Expression // row data - Indices map[string][]int // named row indices like "books": [2,3] + Token token.Token // the '[' token + HasHeaderRow bool // distinguishes no header row from a bare ':' row + Headers []string // column names; empty when a bare ':' marks an unnamed table + Rows [][]Expression // row data + Indices map[string][]int // named row indices like "books": [2,3] } func (al *ArrayLiteral) expressionNode() {} @@ -312,7 +313,7 @@ func (al *ArrayLiteral) String() string { out.WriteString("[") // Print headers if present - if len(al.Headers) > 0 { + if al.HasHeaderRow { out.WriteString("\n : ") // 2 spaces after : for j, header := range al.Headers { if j > 0 { @@ -325,7 +326,7 @@ func (al *ArrayLiteral) String() string { // Print rows for _, row := range al.Rows { - if len(al.Headers) > 0 { + if al.HasHeaderRow { out.WriteString(" ") // 5 spaces for header tables } else { out.WriteString(" ") // 4 spaces for matrices @@ -626,10 +627,11 @@ func RewriteExpr(expr Expression, rewrite func(Expression) Expression) Expressio return expr } return &ArrayLiteral{ - Token: e.Token, - Headers: append([]string(nil), e.Headers...), - Rows: rows, - Indices: maps.Clone(e.Indices), + Token: e.Token, + HasHeaderRow: e.HasHeaderRow, + Headers: append([]string(nil), e.Headers...), + Rows: rows, + Indices: maps.Clone(e.Indices), } case *StructLiteral: row, changed := rewriteExprSlice(e.Row, rewrite) diff --git a/compiler/array.go b/compiler/array.go index bfd4d694..cf38f06e 100644 --- a/compiler/array.go +++ b/compiler/array.go @@ -64,12 +64,12 @@ var ArrayInfos = map[Kind]ArrayInfo{ } 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, } @@ -265,11 +265,24 @@ 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]. +// compileArrayExpression materializes a bracket literal according to its solved +// Array, Matrix, or Table type. func (c *Compiler) compileArrayExpression(e *ast.ArrayLiteral, _ []*ast.Identifier) (res []*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.compileArrayLiteralInDomain(lit, info, nil, nil)} + case Matrix: + return []*Symbol{c.compileMatrixLiteral(lit, typ)} + case Table: + return []*Symbol{c.compileTableLiteral(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 + } } // resolveArrayLiteralRewrite retrieves the potentially rewritten array literal and its ExprInfo. @@ -294,15 +307,10 @@ func (c *Compiler) resolveArrayLiteralRewrite(e *ast.ArrayLiteral) (*ast.ArrayLi 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 - } - // Handle empty array literal: [] if len(lit.Rows) == 0 { arr := info.OutTypes[0].(Array) - elemType := arr.ColTypes[0] + elemType := arr.ElemType // If element type is unresolved, create a null pointer // The actual array will be created when the variable is used with a concrete type @@ -321,7 +329,7 @@ func (c *Compiler) compileArrayLiteralImmediate(lit *ast.ArrayLiteral, info *Exp } arr := info.OutTypes[0].(Array) - elemType := arr.ColTypes[0] + elemType := arr.ElemType row := lit.Rows[0] nConst := c.ConstI64(uint64(len(row))) @@ -348,7 +356,7 @@ func (c *Compiler) withCollectorLoopNest(ranges []*RangeInfo, probe ast.Expressi 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() { @@ -571,8 +579,8 @@ func (c *Compiler) pushAccumCellValue(acc *ArrayAccumulator, valSym *Symbol, isT // 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] + leftElem := left.Type.(Array).ElemType + rightElem := right.Type.(Array).ElemType leftLen := c.ArrayLen(left, leftElem) rightLen := c.ArrayLen(right, rightElem) return c.builder.CreateSelect( @@ -589,11 +597,11 @@ func (c *Compiler) forEachArrayPair( loopLen 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 if rightIsArray { - rightElem = right.Type.(Array).ColTypes[0] + rightElem = right.Type.(Array).ElemType } r := c.rangeZeroToN(loopLen) @@ -616,7 +624,7 @@ func (c *Compiler) forEachArrayPair( func (c *Compiler) arraySym(array llvm.Value, elemType Type) *Symbol { i8p := llvm.PointerType(c.Context.Int8Type(), 0) return &Symbol{ - Type: Array{Headers: nil, ColTypes: []Type{elemType}, Length: 0}, + Type: Array{ElemType: elemType}, Val: c.builder.CreateBitCast(array, i8p, "arr_i8p"), } } @@ -625,8 +633,8 @@ 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 { @@ -681,7 +689,7 @@ func (c *Compiler) compileArrayConcat(left *Symbol, right *Symbol, leftElem Type } func (c *Compiler) compileArrayScalarInfix(op string, arr *Symbol, scalar *Symbol, resElem Type, arrayOnLeft bool) *Symbol { - arrElem := arr.Type.(Array).ColTypes[0] + arrElem := arr.Type.(Array).ElemType loopLen := c.ArrayLen(arr, arrElem) resVec := c.CreateArrayForType(resElem, loopLen) c.forEachArrayPair(arr, scalar, loopLen, func(iter llvm.Value, elemSym *Symbol, scalarSym *Symbol) { @@ -715,7 +723,7 @@ func (c *Compiler) compileArrayScalarInfix(op string, arr *Symbol, scalar *Symbo 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) @@ -755,7 +763,7 @@ func (c *Compiler) compileArrayArrayMask(op string, left *Symbol, right *Symbol, } func (c *Compiler) compileArrayScalarMask(op string, arr *Symbol, scalar *Symbol, resElem Type, arrayOnLeft bool) *Symbol { - arrElem := arr.Type.(Array).ColTypes[0] + arrElem := arr.Type.(Array).ElemType loopLen := c.ArrayLen(arr, arrElem) resVec := c.CreateArrayForType(resElem, loopLen) c.forEachArrayPair(arr, scalar, loopLen, func(iter llvm.Value, elemSym *Symbol, scalarSym *Symbol) { @@ -775,8 +783,8 @@ func (c *Compiler) compileArrayScalarMask(op string, arr *Symbol, scalar *Symbol 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) resVec := c.CreateArrayForType(resElem, n) @@ -805,7 +813,7 @@ func (c *Compiler) compileArrayUnaryPrefix(op string, arr *Symbol, result Array) func (c *Compiler) arrayStrArg(s *Symbol) llvm.Value { arr := s.Type.(Array) - elemType := arr.ColTypes[0] + elemType := arr.ElemType info, ok := ArrayInfos[elemType.Kind()] if !ok { panic("internal: unsupported array element kind for printing") @@ -931,7 +939,7 @@ func (c *Compiler) compileArrayRangeBasic(expr *ast.ArrayRangeExpression) []*Sym defer c.freeConsumedTemporary(expr.Array, []*Symbol{arraySym}) // Scalar index - element access - arrElemType := arrType.ColTypes[0] + arrElemType := arrType.ElemType resultType := arrayAccessResultType(arrElemType) idxVal := c.normalizeArrayIndex(idxSym) @@ -971,7 +979,7 @@ func (c *Compiler) compileArrayRangeRanges(info *ExprInfo, dest []*ast.Identifie panic("internal: range-lowered array access requires scalar index") } - arrElemType := arrType.ColTypes[0] + arrElemType := arrType.ElemType resultType := arrayAccessResultType(arrElemType) idxVal := c.normalizeArrayIndex(idxSym) diff --git a/compiler/bounds.go b/compiler/bounds.go index 01f7781b..10bb5a73 100644 --- a/compiler/bounds.go +++ b/compiler/bounds.go @@ -491,10 +491,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..65ad3f48 100644 --- a/compiler/cfuncs.go +++ b/compiler/cfuncs.go @@ -18,6 +18,8 @@ const ( STR_QUOTE = "str_quote" STR_QUOTE_PREFIX = "str_quote_prefix" STR_HEX = "str_hex" + MATRIX_STR = "matrix_str" + TABLE_STR = "table_str" // Array I64 functions ARR_I64_NEW = "arr_i64_new" @@ -94,6 +96,10 @@ 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 MATRIX_STR: + return llvm.FunctionType(charPtr, []llvm.Type{charPtr, i32, 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/compiler.go b/compiler/compiler.go index bd5ad3d5..35be7e9a 100644 --- a/compiler/compiler.go +++ b/compiler/compiler.go @@ -496,6 +496,19 @@ func (c *Compiler) mapToLLVMType(t Type) llvm.Type { // 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) + case MatrixKind: + arrayPtr := llvm.PointerType(c.Context.Int8Type(), 0) + i64 := c.Context.Int64Type() + return llvm.StructType([]llvm.Type{arrayPtr, i64, i64}, 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: @@ -788,6 +801,21 @@ func (c *Compiler) makeZeroValue(symType Type) *Symbol { // 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)) + case MatrixKind: + matrixType := symType.(Matrix) + s.Val = c.createMatrixValue( + llvm.ConstPointerNull(llvm.PointerType(c.Context.Int8Type(), 0)), + c.ConstI64(0), + c.ConstI64(0), + matrixType, + ) + 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: @@ -939,8 +967,18 @@ 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 t.ElemType != nil && t.ElemType.Kind() != UnresolvedKind { + c.freeArray(val, t.ElemType) + } + case Matrix: + if t.ElemType != nil && t.ElemType.Kind() != UnresolvedKind { + c.freeArray(c.matrixArrayValue(val), 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. @@ -957,6 +995,10 @@ func typeNeedsCleanup(typ Type) bool { return true case Array: return true + case Matrix: + return true + case Table: + return true case ArrayRange: return true default: @@ -1481,6 +1523,8 @@ func setInstAlignment(inst llvm.Value, t Type) { case Array: // Arrays are represented as opaque pointers to runtime vectors inst.SetAlignment(8) + case Matrix, Table: + inst.SetAlignment(8) case ArrayRange: // ArrayRange is a struct of { i8*, Range }, so align to the largest member, which is i8* inst.SetAlignment(8) @@ -1668,38 +1712,54 @@ func (c *Compiler) compileIdentifier(ident *ast.Identifier) *Symbol { func (c *Compiler) compileDotExpression(expr *ast.DotExpression) []*Symbol { leftSym := c.compileExpression(expr.Left, nil)[0] - 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 - } - } + columnValue := c.tableColumnValue(leftSym.Val, i) + _, namedTable := expr.Left.(*ast.Identifier) + if leftSym.Borrowed || namedTable { + columnValue = c.copyArray(columnValue, column.ElemType) + } else { + for other, otherColumn := range leftType.Columns { + if other != i { + c.freeArray(c.tableColumnValue(leftSym.Val, other), otherColumn.ElemType) + } + } + } - if fieldIndex < 0 { + return []*Symbol{{ + Type: Array{ElemType: column.ElemType}, + 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 +1803,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 +2173,22 @@ 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 t.ElemType.Kind() == UnresolvedKind { + sym.Type = resolved + Put(c.Scopes, name, sym) + } + case Matrix: + if t.ElemType.Kind() == UnresolvedKind { + 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 +2687,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{} @@ -3212,12 +3282,12 @@ func (c *Compiler) deepCopyIfNeeded(sym *Symbol) *Symbol { case ArrayKind: // Deep copy the array arrayType := sym.Type.(Array) - if len(arrayType.ColTypes) > 0 { + if arrayType.ElemType != nil { // Skip copying if the element type is unresolved (will be resolved later) - if arrayType.ColTypes[0].Kind() == UnresolvedKind { + if arrayType.ElemType.Kind() == UnresolvedKind { return sym } - copiedArr := c.copyArray(sym.Val, arrayType.ColTypes[0]) + copiedArr := c.copyArray(sym.Val, arrayType.ElemType) return &Symbol{ Val: copiedArr, Type: sym.Type, // Arrays don't have Static flag @@ -3226,6 +3296,26 @@ func (c *Compiler) deepCopyIfNeeded(sym *Symbol) *Symbol { ReadOnly: false, } } + case MatrixKind: + matrixType := sym.Type.(Matrix) + if matrixType.ElemType.Kind() == UnresolvedKind { + return sym + } + return &Symbol{ + Val: c.copyMatrixValue(sym.Val, matrixType), + Type: matrixType, + Borrowed: 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 +3502,21 @@ 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.ElemType == nil || arrType.ElemType.Kind() == UnresolvedKind { *args = append(*args, c.constCString("[]")) return } strPtr := c.arrayStrArg(s) *args = append(*args, strPtr) *toFree = append(*toFree, strPtr) + case MatrixKind: + strPtr := c.matrixStrArg(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..ecf5159d 100644 --- a/compiler/compiler_test.go +++ b/compiler/compiler_test.go @@ -773,7 +773,7 @@ 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})) } func TestCollectorArrayBindingCannotBeNestedAsCell(t *testing.T) { @@ -796,7 +796,7 @@ arr` 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"), "unsupported bracket literal element type [I64] in column 0") } 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{}}) 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..ae7bd3f2 100644 --- a/compiler/cond.go +++ b/compiler/cond.go @@ -979,7 +979,7 @@ func (c *Compiler) compileCondRangedStatement(stmt *ast.LetStatement, condRanges 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 { + if lit, ok := expr.(*ast.ArrayLiteral); ok && !lit.HasHeaderRow && len(lit.Rows) == 1 { accumDest := stmt.Name[targetIdx] accumLits = append(accumLits, lit) accumAccs = append(accumAccs, c.NewArrayAccumulator(info.OutTypes[0].(Array))) diff --git a/compiler/format.go b/compiler/format.go index a014481f..66da89f4 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 MatrixKind, TableKind: + return "%s", nil case ArrayRangeKind: return "%s", nil case StructKind: @@ -464,7 +466,7 @@ func (c *Compiler) tryFormatArrayElements(tok token.Token, value string, mainSym } arr := mainSym.Type.(Array) - elementType := arr.ColTypes[0] + elementType := arr.ElemType conversion := rune(spec.text[len(spec.text)-1]) if !arrayElementSupportsSpecifier(elementType, conversion) { return formattedMarker{}, false, nil @@ -597,6 +599,14 @@ func (c *Compiler) formatAsString(mainSym *Symbol, result *formattedMarker) bool strPtr := c.arrayStrArg(mainSym) result.args = append(result.args, strPtr) result.toFree = append(result.toFree, strPtr) + case MatrixKind: + strPtr := c.matrixStrArg(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..4bdf60fe 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}}, expected: "Pt_3arr_p_3len_f1_Array_t1_I64", }, + { + name: "with matrix type", + modName: "matrix", + relPath: "", + funcName: "shape", + args: []Type{Matrix{ElemType: F64}}, + expected: "Pt_6matrix_p_5shape_f1_Matrix_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..0d27c46c 100644 --- a/compiler/solver.go +++ b/compiler/solver.go @@ -177,60 +177,30 @@ func (ts *TypeSolver) resolveBindingSlotType(name string, currentType, newType T return slotType } -func cloneArrayHeaders(src []string) []string { - if len(src) == 0 { - return nil - } - 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) - } - - 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 - } - - return Array{ - Headers: headers, - ColTypes: []Type{elem}, - Length: length, - } -} - func (ts *TypeSolver) concatArrayTypes(leftArr, rightArr Array, tok token.Token) Type { - leftElemType := leftArr.ColTypes[0] - rightElemType := rightArr.ColTypes[0] + leftElemType := leftArr.ElemType + rightElemType := rightArr.ElemType if leftElemType.Kind() == UnresolvedKind { - return concatWithMetadata(leftArr, rightArr, rightElemType) + return Array{ElemType: rightElemType} } if rightElemType.Kind() == UnresolvedKind { - return concatWithMetadata(leftArr, rightArr, leftElemType) + return Array{ElemType: leftElemType} } 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{}} } - return concatWithMetadata(leftArr, rightArr, leftElemType) + return Array{ElemType: leftElemType} } 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}} } ce := &token.CompileError{ @@ -242,7 +212,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)] @@ -437,7 +407,7 @@ func (ts *TypeSolver) HandleArrayLiteralRanges(al *ast.ArrayLiteral) ([]*RangeIn 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) { + if al.HasHeaderRow || len(al.Rows) != 1 { info.Ranges = nil info.CollectRanges = nil info.Rewrite = al @@ -461,10 +431,11 @@ func (ts *TypeSolver) HandleArrayLiteralRanges(al *ast.ArrayLiteral) ([]*RangeIn rew := ast.Expression(al) if changed { newLit := &ast.ArrayLiteral{ - Token: al.Token, - Headers: append([]string(nil), al.Headers...), - Rows: [][]ast.Expression{newRow}, - Indices: cloneArrayIndices(al.Indices), + Token: al.Token, + HasHeaderRow: al.HasHeaderRow, + Headers: append([]string(nil), al.Headers...), + Rows: [][]ast.Expression{newRow}, + Indices: cloneArrayIndices(al.Indices), } infoCopy := &ExprInfo{ OutTypes: info.OutTypes, @@ -721,7 +692,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 +846,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 := arrayAccessResultType(exprTypes[0].(ArrayRange).Array.ElemType) exprTypes[0] = elemType info.OutTypes[0] = elemType } @@ -957,76 +928,120 @@ 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 arrays, matrices, or +// tables. Element and column types are homogeneous, with I64-to-F64 promotion. 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} + if !al.HasHeaderRow && len(al.Rows) == 0 { + arr := Array{ElemType: Unresolved{}} 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{}} + if !al.HasHeaderRow && len(al.Rows) == 1 { + elemType := Type(Unresolved{}) row := al.Rows[0] for col := 0; col < len(row); col++ { cellT, ok := ts.typeCell(row[col], al.Tok()) if !ok { continue } - colTypes[0] = ts.mergeColType(colTypes[0], cellT, 0, al.Tok()) + elemType = ts.mergeColType(elemType, cellT, 0, al.Tok()) } - // Length = number of elements in the row (for vectors) - arr := Array{Headers: nil, ColTypes: colTypes, Length: len(row)} + arr := Array{ElemType: elemType} // 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} } - // unsupported as of now - 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} - */ + + numCols := ts.arrayNumCols(al) + if !ts.validateBracketLiteralShape(al, numCols) { + types := []Type{Unresolved{}} + ts.ExprCache[key(ts.FuncNameMangled, al)] = &ExprInfo{OutTypes: types, ExprLen: 1} + return types + } + + colTypes := ts.initColTypes(numCols) + for _, row := range al.Rows { + for col, cell := range row { + cellType, ok := ts.typeCell(cell, al.Tok()) + if !ok { + continue + } + colTypes[col] = ts.mergeColType(colTypes[col], cellType, col, al.Tok()) + } + } + + if !al.HasHeaderRow { + if elemType, ok := commonMatrixElemType(colTypes); ok { + matrix := Matrix{ElemType: elemType} + ts.ExprCache[key(ts.FuncNameMangled, al)] = &ExprInfo{OutTypes: []Type{matrix}, ExprLen: 1} + return []Type{matrix} + } + } + + columns := make([]TableColumn, numCols) + 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 commonMatrixElemType(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 +1124,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}} + 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 +1166,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 +1186,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 +1212,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 +1240,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, matrices, and tables. +func allowedCollectionElement(t Type) bool { switch v := t.(type) { case Int: return v.Width == 64 @@ -1319,15 +1344,7 @@ func (ts *TypeSolver) TypeArrayRangeExpression(ax *ast.ArrayRangeExpression, isR if !ok { return info.OutTypes } - if len(arrType.ColTypes) == 0 { - ts.Errors = append(ts.Errors, &token.CompileError{ - Token: ax.Tok(), - Msg: "array type has no element columns", - }) - return info.OutTypes - } - - elemType := arrType.ColTypes[0] + elemType := arrType.ElemType // String array element access does strdup at runtime, so result is heap-allocated if elemType.Kind() == StrKind { elemType = StrH{} @@ -1506,10 +1523,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 +1554,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}, CondArray } // Handle any expression involving arrays @@ -1800,17 +1817,13 @@ func (ts *TypeSolver) typeArrayArrayInfix(leftArr, rightArr Array, op string, to } // 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} } // resolveArrayElemTypes handles unresolved element types and type inference for array operations @@ -1853,23 +1866,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} } func (ts *TypeSolver) TypeInfixOp(left, right Type, op string, tok token.Token) Type { @@ -1925,7 +1934,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 +1963,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} types = append(types, finalType) continue } @@ -2071,7 +2076,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 +2099,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 } diff --git a/compiler/solver_test.go b/compiler/solver_test.go index 429379b3..3c93ca33 100644 --- a/compiler/solver_test.go +++ b/compiler/solver_test.go @@ -347,11 +347,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 +848,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 +906,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..40053b5e --- /dev/null +++ b/compiler/table.go @@ -0,0 +1,160 @@ +package compiler + +import ( + "github.com/thiremani/pluto/ast" + "tinygo.org/x/go-llvm" +) + +var runtimeElementKinds = map[Kind]uint64{ + IntKind: 0, + FloatKind: 1, + StrKind: 2, +} + +func (c *Compiler) compileMatrixLiteral(lit *ast.ArrayLiteral, matrixType Matrix) *Symbol { + rows := len(lit.Rows) + cols := 0 + if rows > 0 { + cols = len(lit.Rows[0]) + } + + data := c.CreateArrayForType(matrixType.ElemType, c.ConstI64(uint64(rows*cols))) + for rowIndex, row := range lit.Rows { + for colIndex, cell := range row { + index := c.ConstI64(uint64(rowIndex*cols + colIndex)) + c.compileArrayLiteralCell(cell, matrixType.ElemType, func(cellSlot *Symbol) bool { + return c.setArrayCellSlot(data, index, cellSlot, matrixType.ElemType) + }) + } + } + + data = c.builder.CreateBitCast(data, llvm.PointerType(c.Context.Int8Type(), 0), "matrix_data") + return &Symbol{ + Type: matrixType, + Val: c.createMatrixValue( + data, + c.ConstI64(uint64(rows)), + c.ConstI64(uint64(cols)), + matrixType, + ), + } +} + +func (c *Compiler) compileTableLiteral(lit *ast.ArrayLiteral, tableType Table) *Symbol { + 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) createMatrixValue(data, rows, cols llvm.Value, matrixType Matrix) llvm.Value { + value := llvm.Undef(c.mapToLLVMType(matrixType)) + value = c.builder.CreateInsertValue(value, data, 0, "matrix_data_value") + value = c.builder.CreateInsertValue(value, rows, 1, "matrix_rows") + return c.builder.CreateInsertValue(value, cols, 2, "matrix_cols") +} + +func (c *Compiler) matrixArrayValue(matrix llvm.Value) llvm.Value { + return c.builder.CreateExtractValue(matrix, 0, "matrix_data") +} + +func (c *Compiler) copyMatrixValue(value llvm.Value, matrixType Matrix) llvm.Value { + data := c.copyArray(c.matrixArrayValue(value), matrixType.ElemType) + rows := c.builder.CreateExtractValue(value, 1, "matrix_rows") + cols := c.builder.CreateExtractValue(value, 2, "matrix_cols") + return c.createMatrixValue(data, rows, cols, matrixType) +} + +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) matrixStrArg(s *Symbol) llvm.Value { + matrixType := s.Type.(Matrix) + data := c.matrixArrayValue(s.Val) + rows := c.builder.CreateExtractValue(s.Val, 1, "matrix_rows") + cols := c.builder.CreateExtractValue(s.Val, 2, "matrix_cols") + kind := llvm.ConstInt(c.Context.Int32Type(), runtimeElementKinds[matrixType.ElemType.Kind()], false) + fnType, fn := c.GetCFunc(MATRIX_STR) + return c.builder.CreateCall(fnType, fn, []llvm.Value{data, kind, rows, cols}, "matrix_str") +} + +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) + c.builder.CreateStore(llvm.ConstInt(i32, runtimeElementKinds[column.ElemType.Kind()], 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..fb79230e 100644 --- a/compiler/types.go +++ b/compiler/types.go @@ -21,6 +21,8 @@ const ( RangeKind FuncKind ArrayKind + MatrixKind + TableKind ArrayRangeKind StructKind ) @@ -59,6 +61,8 @@ var PrimitiveTypeNames = []string{ // Sorted by descending length in init() for correct prefix matching. var CompoundTypePrefixes = []string{ "ArrayRange", + "Matrix", + "Table", "Array", "Range", "Ptr", @@ -262,11 +266,12 @@ func IsFullyResolvedType(t Type) bool { 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.ElemType != nil && IsFullyResolvedType(tt.ElemType) + case Matrix: + return tt.ElemType != nil && IsFullyResolvedType(tt.ElemType) + case Table: + for _, column := range tt.Columns { + if column.ElemType == nil || !IsFullyResolvedType(column.ElemType) { return false } } @@ -287,42 +292,92 @@ 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). +// Array is a homogeneous, one-dimensional sequence. 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 } func (a Array) String() string { - // Type identity ignores headers and length; show schema only. - if len(a.ColTypes) == 0 { + if a.ElemType == nil { return "[]" } - var cols []string - for _, ct := range a.ColTypes { - cols = append(cols, ct.String()) - } - return "[" + strings.Join(cols, " ") + "]" + return "[" + a.ElemType.String() + "]" } 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() - } - return s + return "Array" + SEP + T + "1" + SEP + a.ElemType.Mangle() } 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()} +} + +// Matrix is a homogeneous, two-dimensional value. Its dimensions are runtime +// properties and therefore do not participate in type identity. +type Matrix struct { + ElemType Type +} + +func (m Matrix) String() string { + if m.ElemType == nil { + return "Matrix[]" + } + return "Matrix[" + m.ElemType.String() + "]" +} + +func (m Matrix) Kind() Kind { return MatrixKind } +func (m Matrix) Mangle() string { + return "Matrix" + SEP + T + "1" + SEP + m.ElemType.Mangle() +} +func (m Matrix) Key() Type { + return Matrix{ElemType: m.ElemType.Key()} +} + +// 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 Array{ColTypes: keyColTypes} + 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 Table{Columns: columns} } // ArrayRange represents an iteration over a range of an array. @@ -340,11 +395,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{ @@ -518,6 +569,12 @@ func CanRefineType(oldType, newType Type) bool { case Array: newArr, ok := newType.(Array) return ok && canRefineArray(old, newArr) + case Matrix: + newMatrix, ok := newType.(Matrix) + return ok && CanRefineType(old.ElemType, newMatrix.ElemType) + case Table: + newTable, ok := newType.(Table) + return ok && canRefineTable(old, newTable) case ArrayRange: newSlice, ok := newType.(ArrayRange) return ok && canRefineArrayRange(old, newSlice) @@ -569,7 +626,20 @@ func canRefineTypes(oldTypes, newTypes []Type) bool { } func canRefineArray(oldArr, newArr Array) bool { - return canRefineTypes(oldArr.ColTypes, newArr.ColTypes) + return 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 { @@ -604,6 +674,10 @@ func typeComparer(k Kind) func(a, b Type) bool { return eqFunc case ArrayKind: return eqArray + case MatrixKind: + return eqMatrix + case TableKind: + return eqTable case ArrayRangeKind: return eqArrayRange case StructKind: @@ -659,10 +733,28 @@ 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 TypeEqual(aa.ElemType, ba.ElemType) +} + +func eqMatrix(a, b Type) bool { + am := a.(Matrix) + bm := b.(Matrix) + return TypeEqual(am.ElemType, bm.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 { @@ -698,6 +790,6 @@ var reservedTypeNames = map[string]struct{}{ "I1": {}, "I8": {}, "I16": {}, "I32": {}, "I64": {}, "U8": {}, "U16": {}, "U32": {}, "U64": {}, "F32": {}, "F64": {}, - "Ptr": {}, "Range": {}, "Array": {}, "ArrayRange": {}, + "Ptr": {}, "Range": {}, "Array": {}, "Matrix": {}, "Table": {}, "ArrayRange": {}, "Func": {}, "Struct": {}, } diff --git a/docs/Pluto C ABI Spec.md b/docs/Pluto C ABI Spec.md index 23ca6aac..9ba1e3bf 100644 --- a/docs/Pluto C ABI Spec.md +++ b/docs/Pluto C ABI Spec.md @@ -203,12 +203,18 @@ 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` | +| Matrix | `Matrix_t1_[Elem]` | `Matrix_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`. + --- ## 4. Examples diff --git a/docs/Pluto Range Semantics.md b/docs/Pluto Range Semantics.md index f4dfee33..38f5267d 100644 --- a/docs/Pluto Range Semantics.md +++ b/docs/Pluto Range Semantics.md @@ -103,9 +103,12 @@ 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 +## One-Dimensional Array Literals -`[]` always materializes an array at the point where it appears. +A headerless bracket literal with at most one data row materializes an array at +the point where it appears. Rectangular literals with multiple rows are +classified as matrices or tables; they do not use the collector behavior in +this section. The collector materializes over: diff --git a/parser/parser.go b/parser/parser.go index 29309061..e98b16fb 100644 --- a/parser/parser.go +++ b/parser/parser.go @@ -1120,6 +1120,7 @@ func (p *StmtParser) parseArrayLiteral() ast.Expression { // Parse headers if present if p.curTokenIs(token.COLON) { + arr.HasHeaderRow = true p.nextToken() // consume ':' if !p.parseHeader(arr) { return nil diff --git a/parser/scriptparser_test.go b/parser/scriptparser_test.go index 7a78ed74..a3b3da4f 100644 --- a/parser/scriptparser_test.go +++ b/parser/scriptparser_test.go @@ -1142,6 +1142,7 @@ func TestArrayLiterals(t *testing.T) { name: "empty array", input: "[]", checkResult: func(t *testing.T, arr *ast.ArrayLiteral) { + require.False(t, arr.HasHeaderRow) require.Empty(t, arr.Headers, "expected no headers") require.Empty(t, arr.Rows, "expected no rows") }, @@ -1153,6 +1154,7 @@ func TestArrayLiterals(t *testing.T) { 4 5 6 ]`, checkResult: func(t *testing.T, arr *ast.ArrayLiteral) { + require.False(t, arr.HasHeaderRow) require.Empty(t, arr.Headers, "expected no headers for matrix") require.Len(t, arr.Rows, 2, "expected 2 rows") require.Len(t, arr.Rows[0], 3, "expected 3 elements in first row") @@ -1177,6 +1179,7 @@ func TestArrayLiterals(t *testing.T) { "Tuesday" "Laptop" 300 ]`, checkResult: func(t *testing.T, arr *ast.ArrayLiteral) { + require.True(t, arr.HasHeaderRow) require.Equal(t, []string{"Day", "Product", "Price"}, arr.Headers) require.Len(t, arr.Rows, 2, "expected 2 rows") diff --git a/runtime/array.c b/runtime/array.c index 45d75be5..bfb4b017 100644 --- a/runtime/array.c +++ b/runtime/array.c @@ -328,6 +328,70 @@ static int strbuf_format_str(StrBuf* sb, const char* fmt, int dynamic_arg_count, value ? value : ""); } +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; + } +} + +const char* matrix_str(const void* values, int32_t kind, size_t rows, size_t cols) { + StrBuf sb = {malloc(256), 0, 256}; + if (!sb.data) return NULL; + if (strbuf_printf(&sb, "[") < 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, values, kind, row * cols + col) < 0) goto fail; + } + } + if (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* 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, "[\n :") < 0) goto fail; + for (size_t col = 0; col < cols; ++col) { + if (names[col][0] != '\0' && 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 (strbuf_printf(&sb, "\n]") < 0) goto fail; + return sb.data; + +fail: + free(sb.data); + return NULL; +} + const char* arr_i64_str(const PtArrayI64* a) { StrBuf sb = {malloc(256), 0, 256}; if (!sb.data) return NULL; diff --git a/runtime/array.h b/runtime/array.h index da9ceae5..63cf4878 100644 --- a/runtime/array.h +++ b/runtime/array.h @@ -74,6 +74,16 @@ 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* matrix_str(const void* values, int32_t kind, size_t rows, size_t cols); +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..b29d9152 100644 --- a/tests/array/array.exp +++ b/tests/array/array.exp @@ -15,3 +15,28 @@ 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] +matrix: [ + 1 2.5 + 3 4 +] +table: [ + : Name Score + "Ada" 10 + "Lin" 12 +] +table scores: [10 12] +replacement table: [ + : Name Score + "Mae" 20 +] +saved table scores: [10 12] +unnamed table: [ + : + 1 2 + 3 4 +] +inferred table: [ + : + 1 "a" + 2 "b" +] diff --git a/tests/array/array.spt b/tests/array/array.spt index 7d8a1c44..2e520e93 100644 --- a/tests/array/array.spt +++ b/tests/array/array.spt @@ -43,3 +43,42 @@ arr3 = [ ] arr3 "arr3 is -arr3" + +matrix = +[ + 1 2.5 + 3 4 +] +"matrix: -matrix" + +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" + +unnamedTable = +[ + : + 1 2 + 3 4 +] +"unnamed table: -unnamedTable" + +inferredTable = +[ + 1 "a" + 2 "b" +] +"inferred table: -inferredTable" diff --git a/tests/array/array_func.exp b/tests/array/array_func.exp index e9b959d3..e5d87d7e 100644 --- a/tests/array/array_func.exp +++ b/tests/array/array_func.exp @@ -9,6 +9,15 @@ ProdReverse: [5 10] SumProductScalar: 5 ProdScalar: 6 SquareScalar: 49 +MatrixIdentity: [ + 1 2 + 3 4 +] +TableIdentity: [ + : Name Score + "Ada" 10 + "Lin" 12 +] DirectConcatScalar: [0] DirectConcatRange: [0 1 2 3 4] SquareVecRange: [0 1 4 9 16] diff --git a/tests/array/array_func.pt b/tests/array/array_func.pt index 16e22c8a..9252bffb 100644 --- a/tests/array/array_func.pt +++ b/tests/array/array_func.pt @@ -17,4 +17,7 @@ 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 diff --git a/tests/array/array_func.spt b/tests/array/array_func.spt index c0d7fda3..29ba52c8 100644 --- a/tests/array/array_func.spt +++ b/tests/array/array_func.spt @@ -23,6 +23,19 @@ 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" + arr1 = [] ⊕ [0] "DirectConcatScalar: -arr1" From 7acfba9cb34360a97f8128de828779cc4acb6101 Mon Sep 17 00:00:00 2001 From: Tejas Date: Wed, 15 Jul 2026 10:47:54 +0530 Subject: [PATCH 02/33] fix(compiler): dereference dot expression operands Normalize pointer-backed indirect call results before struct and table field dispatch. Preserve table projection ownership and cover selecting a column directly from a returned table. --- compiler/compiler.go | 3 +++ tests/array/array_func.exp | 1 + tests/array/array_func.spt | 7 +++++++ 3 files changed, 11 insertions(+) diff --git a/compiler/compiler.go b/compiler/compiler.go index 35be7e9a..6fe0c49c 100644 --- a/compiler/compiler.go +++ b/compiler/compiler.go @@ -1711,6 +1711,7 @@ 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") switch leftType := leftSym.Type.(type) { case Struct: @@ -1734,6 +1735,8 @@ func (c *Compiler) compileDotExpression(expr *ast.DotExpression) []*Symbol { columnValue := c.tableColumnValue(leftSym.Val, i) _, namedTable := expr.Left.(*ast.Identifier) + // Named and borrowed tables must survive projection. An owned + // temporary can instead transfer its selected column. if leftSym.Borrowed || namedTable { columnValue = c.copyArray(columnValue, column.ElemType) } else { diff --git a/tests/array/array_func.exp b/tests/array/array_func.exp index e5d87d7e..44f92712 100644 --- a/tests/array/array_func.exp +++ b/tests/array/array_func.exp @@ -18,6 +18,7 @@ TableIdentity: [ "Ada" 10 "Lin" 12 ] +TableCallColumn: [10 12] DirectConcatScalar: [0] DirectConcatRange: [0 1 2 3 4] SquareVecRange: [0 1 4 9 16] diff --git a/tests/array/array_func.spt b/tests/array/array_func.spt index 29ba52c8..fde7fd13 100644 --- a/tests/array/array_func.spt +++ b/tests/array/array_func.spt @@ -36,6 +36,13 @@ tableIdentity = Identity([ ]) "TableIdentity: -tableIdentity" +tableCallColumn = Identity([ + : Name Score + "Ada" 10 + "Lin" 12 +]).Score +"TableCallColumn: -tableCallColumn" + arr1 = [] ⊕ [0] "DirectConcatScalar: -arr1" From d092faca37883a3d21299bc89fdeb9a8fb839374 Mon Sep 17 00:00:00 2001 From: Tejas Date: Wed, 15 Jul 2026 11:22:52 +0530 Subject: [PATCH 03/33] fix(compiler): unify table column ownership Copy projected columns before consuming temporary table bases through freeConsumedTemporary. This keeps conditional-frame cleanup synchronized when a projected column feeds another expression. --- compiler/compiler.go | 15 ++------------- tests/array/array_func.exp | 1 + tests/array/array_func.spt | 7 +++++++ 3 files changed, 10 insertions(+), 13 deletions(-) diff --git a/compiler/compiler.go b/compiler/compiler.go index 6fe0c49c..ace9c81c 100644 --- a/compiler/compiler.go +++ b/compiler/compiler.go @@ -1733,19 +1733,8 @@ func (c *Compiler) compileDotExpression(expr *ast.DotExpression) []*Symbol { continue } - columnValue := c.tableColumnValue(leftSym.Val, i) - _, namedTable := expr.Left.(*ast.Identifier) - // Named and borrowed tables must survive projection. An owned - // temporary can instead transfer its selected column. - if leftSym.Borrowed || namedTable { - columnValue = c.copyArray(columnValue, column.ElemType) - } else { - for other, otherColumn := range leftType.Columns { - if other != i { - c.freeArray(c.tableColumnValue(leftSym.Val, other), otherColumn.ElemType) - } - } - } + columnValue := c.copyArray(c.tableColumnValue(leftSym.Val, i), column.ElemType) + c.freeConsumedTemporary(expr.Left, []*Symbol{leftSym}) return []*Symbol{{ Type: Array{ElemType: column.ElemType}, diff --git a/tests/array/array_func.exp b/tests/array/array_func.exp index 44f92712..5af3a5c4 100644 --- a/tests/array/array_func.exp +++ b/tests/array/array_func.exp @@ -19,6 +19,7 @@ TableIdentity: [ "Lin" 12 ] TableCallColumn: [10 12] +GatedTableColumn: ["" "Lin"] DirectConcatScalar: [0] DirectConcatRange: [0 1 2 3 4] SquareVecRange: [0 1 4 9 16] diff --git a/tests/array/array_func.spt b/tests/array/array_func.spt index fde7fd13..508ed981 100644 --- a/tests/array/array_func.spt +++ b/tests/array/array_func.spt @@ -43,6 +43,13 @@ tableCallColumn = Identity([ ]).Score "TableCallColumn: -tableCallColumn" +gatedTableColumn = (1 > 0 && Identity([ + : Name Score + "Ada" 10 + "Lin" 12 +])).Name > "K" +"GatedTableColumn: -gatedTableColumn" + arr1 = [] ⊕ [0] "DirectConcatScalar: -arr1" From f3a5604407a2ff9febd291a1128e624cdef8c866 Mon Sep 17 00:00:00 2001 From: Tejas Date: Wed, 15 Jul 2026 11:31:11 +0530 Subject: [PATCH 04/33] fix(parser): require named table headers Reject a bare colon in bracket literals and infer unnamed tables directly from their data rows. Remove the redundant HasHeaderRow flag, standardize examples on four-space indentation, and omit header markers when printing unnamed tables. --- README.md | 17 +++++++++-------- ast/ast.go | 33 ++++++++++++++------------------- compiler/cond.go | 2 +- compiler/solver.go | 17 ++++++++--------- parser/parser.go | 9 ++++++++- parser/scriptparser_test.go | 15 +++++++++------ runtime/array.c | 19 ++++++++++++++++--- tests/array/array.exp | 6 ------ tests/array/array.spt | 20 ++++++-------------- 9 files changed, 71 insertions(+), 67 deletions(-) diff --git a/README.md b/README.md index 626a1cf4..ba1bd86e 100644 --- a/README.md +++ b/README.md @@ -201,23 +201,24 @@ The same bracket syntax infers matrices and tables without a type keyword: ```python matrix = [ - 1 2 - 3 4 + 1 2 + 3 4 ] scores = [ - : Name Score - "Ada" 10 - "Lin" 12 + : Name Score + "Ada" 10 + "Lin" 12 ] ``` An empty or one-row headerless literal is an array. A rectangular, multi-row literal whose cells share one promotable type is a row-major matrix. A header row always produces a columnar table; without headers, homogeneous columns -with different element types infer an unnamed table. Use a bare `:` header row -to force an unnamed table when all columns have the same type. Named columns -are arrays, so `scores.Score` returns `[10 12]`. +with different element types infer an unnamed table. A `:` must be followed by +at least one column name; headerless literals start directly with their first +data row. Indentation inside brackets is not semantic; examples use four spaces. +Named columns are arrays, so `scores.Score` returns `[10 12]`. String-array elements print quoted so boundaries stay unambiguous: diff --git a/ast/ast.go b/ast/ast.go index 60b30bd0..0dafe3ee 100644 --- a/ast/ast.go +++ b/ast/ast.go @@ -299,11 +299,10 @@ func (rl *RangeLiteral) String() string { } type ArrayLiteral struct { - Token token.Token // the '[' token - HasHeaderRow bool // distinguishes no header row from a bare ':' row - Headers []string // column names; empty when a bare ':' marks an unnamed table - Rows [][]Expression // row data - Indices map[string][]int // named row indices like "books": [2,3] + Token token.Token // the '[' token + Headers []string // column names; empty for arrays, matrices, and unnamed tables + Rows [][]Expression // row data + Indices map[string][]int // named row indices like "books": [2,3] } func (al *ArrayLiteral) expressionNode() {} @@ -313,24 +312,19 @@ func (al *ArrayLiteral) String() string { out.WriteString("[") // Print headers if present - if al.HasHeaderRow { - out.WriteString("\n : ") // 2 spaces after : + if len(al.Headers) > 0 { + 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 al.HasHeaderRow { - out.WriteString(" ") // 5 spaces for header tables - } else { - out.WriteString(" ") // 4 spaces for matrices - } + out.WriteString("\n ") for j, expr := range row { if j > 0 { out.WriteString(" ") @@ -341,9 +335,11 @@ func (al *ArrayLiteral) String() string { out.WriteString("") } } - out.WriteString("\n") } + if len(al.Headers) > 0 || len(al.Rows) > 0 { + out.WriteString("\n") + } out.WriteString("]") return out.String() } @@ -627,11 +623,10 @@ func RewriteExpr(expr Expression, rewrite func(Expression) Expression) Expressio return expr } return &ArrayLiteral{ - Token: e.Token, - HasHeaderRow: e.HasHeaderRow, - Headers: append([]string(nil), e.Headers...), - Rows: rows, - Indices: maps.Clone(e.Indices), + Token: e.Token, + Headers: append([]string(nil), e.Headers...), + Rows: rows, + Indices: maps.Clone(e.Indices), } case *StructLiteral: row, changed := rewriteExprSlice(e.Row, rewrite) diff --git a/compiler/cond.go b/compiler/cond.go index ae7bd3f2..7e65dbb8 100644 --- a/compiler/cond.go +++ b/compiler/cond.go @@ -979,7 +979,7 @@ func (c *Compiler) compileCondRangedStatement(stmt *ast.LetStatement, condRanges info := c.ExprCache[key(c.FuncNameMangled, expr)] numOutputs := len(info.OutTypes) - if lit, ok := expr.(*ast.ArrayLiteral); ok && !lit.HasHeaderRow && len(lit.Rows) == 1 { + 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))) diff --git a/compiler/solver.go b/compiler/solver.go index 0d27c46c..2cea9d22 100644 --- a/compiler/solver.go +++ b/compiler/solver.go @@ -407,7 +407,7 @@ func (ts *TypeSolver) HandleArrayLiteralRanges(al *ast.ArrayLiteral) ([]*RangeIn info := ts.ExprCache[key(ts.FuncNameMangled, al)] // Only 1D array literals are currently supported by the compiler. - if al.HasHeaderRow || len(al.Rows) != 1 { + if len(al.Headers) > 0 || len(al.Rows) != 1 { info.Ranges = nil info.CollectRanges = nil info.Rewrite = al @@ -431,11 +431,10 @@ func (ts *TypeSolver) HandleArrayLiteralRanges(al *ast.ArrayLiteral) ([]*RangeIn rew := ast.Expression(al) if changed { newLit := &ast.ArrayLiteral{ - Token: al.Token, - HasHeaderRow: al.HasHeaderRow, - Headers: append([]string(nil), al.Headers...), - Rows: [][]ast.Expression{newRow}, - Indices: cloneArrayIndices(al.Indices), + Token: al.Token, + Headers: append([]string(nil), al.Headers...), + Rows: [][]ast.Expression{newRow}, + Indices: cloneArrayIndices(al.Indices), } infoCopy := &ExprInfo{ OutTypes: info.OutTypes, @@ -931,13 +930,13 @@ func (ts *TypeSolver) TypeLetStatement(stmt *ast.LetStatement) { // TypeArrayExpression classifies bracket literals as arrays, matrices, or // tables. Element and column types are homogeneous, with I64-to-F64 promotion. func (ts *TypeSolver) TypeArrayExpression(al *ast.ArrayLiteral) []Type { - if !al.HasHeaderRow && len(al.Rows) == 0 { + if len(al.Headers) == 0 && len(al.Rows) == 0 { arr := Array{ElemType: Unresolved{}} ts.ExprCache[key(ts.FuncNameMangled, al)] = &ExprInfo{OutTypes: []Type{arr}, ExprLen: 1, HasRanges: false} return []Type{arr} } - if !al.HasHeaderRow && len(al.Rows) == 1 { + if len(al.Headers) == 0 && len(al.Rows) == 1 { elemType := Type(Unresolved{}) row := al.Rows[0] for col := 0; col < len(row); col++ { @@ -973,7 +972,7 @@ func (ts *TypeSolver) TypeArrayExpression(al *ast.ArrayLiteral) []Type { } } - if !al.HasHeaderRow { + if len(al.Headers) == 0 { if elemType, ok := commonMatrixElemType(colTypes); ok { matrix := Matrix{ElemType: elemType} ts.ExprCache[key(ts.FuncNameMangled, al)] = &ExprInfo{OutTypes: []Type{matrix}, ExprLen: 1} diff --git a/parser/parser.go b/parser/parser.go index e98b16fb..8bde488b 100644 --- a/parser/parser.go +++ b/parser/parser.go @@ -1120,8 +1120,15 @@ func (p *StmtParser) parseArrayLiteral() ast.Expression { // Parse headers if present if p.curTokenIs(token.COLON) { - arr.HasHeaderRow = true + 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 } diff --git a/parser/scriptparser_test.go b/parser/scriptparser_test.go index a3b3da4f..1db62e17 100644 --- a/parser/scriptparser_test.go +++ b/parser/scriptparser_test.go @@ -1142,7 +1142,6 @@ func TestArrayLiterals(t *testing.T) { name: "empty array", input: "[]", checkResult: func(t *testing.T, arr *ast.ArrayLiteral) { - require.False(t, arr.HasHeaderRow) require.Empty(t, arr.Headers, "expected no headers") require.Empty(t, arr.Rows, "expected no rows") }, @@ -1154,7 +1153,6 @@ func TestArrayLiterals(t *testing.T) { 4 5 6 ]`, checkResult: func(t *testing.T, arr *ast.ArrayLiteral) { - require.False(t, arr.HasHeaderRow) require.Empty(t, arr.Headers, "expected no headers for matrix") require.Len(t, arr.Rows, 2, "expected 2 rows") require.Len(t, arr.Rows[0], 3, "expected 3 elements in first row") @@ -1174,12 +1172,11 @@ func TestArrayLiterals(t *testing.T) { { 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.True(t, arr.HasHeaderRow) require.Equal(t, []string{"Day", "Product", "Price"}, arr.Headers) require.Len(t, arr.Rows, 2, "expected 2 rows") @@ -1227,6 +1224,12 @@ 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: `[ diff --git a/runtime/array.c b/runtime/array.c index bfb4b017..5d00b447 100644 --- a/runtime/array.c +++ b/runtime/array.c @@ -373,9 +373,21 @@ 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, "[\n :") < 0) goto fail; + + int has_headers = 0; for (size_t col = 0; col < cols; ++col) { - if (names[col][0] != '\0' && strbuf_printf(&sb, " %s", names[col]) < 0) goto fail; + if (names[col][0] != '\0') { + has_headers = 1; + break; + } + } + + 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 (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; @@ -384,7 +396,8 @@ const char* table_str(size_t rows, size_t cols, const char* const* names, if (strbuf_array_cell(&sb, columns[col], kinds[col], row) < 0) goto fail; } } - if (strbuf_printf(&sb, "\n]") < 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: diff --git a/tests/array/array.exp b/tests/array/array.exp index b29d9152..d422d017 100644 --- a/tests/array/array.exp +++ b/tests/array/array.exp @@ -30,13 +30,7 @@ replacement table: [ "Mae" 20 ] saved table scores: [10 12] -unnamed table: [ - : - 1 2 - 3 4 -] inferred table: [ - : 1 "a" 2 "b" ] diff --git a/tests/array/array.spt b/tests/array/array.spt index 2e520e93..bdea8f35 100644 --- a/tests/array/array.spt +++ b/tests/array/array.spt @@ -46,14 +46,14 @@ arr3 matrix = [ - 1 2.5 - 3 4 + 1 2.5 + 3 4 ] "matrix: -matrix" scores = [ - : Name Score + : Name Score "Ada" 10 "Lin" 12 ] @@ -62,23 +62,15 @@ scoreColumn = scores.Score "table scores: -scoreColumn" scores = [ - : Name Score + : Name Score "Mae" 20 ] "replacement table: -scores" "saved table scores: -scoreColumn" -unnamedTable = -[ - : - 1 2 - 3 4 -] -"unnamed table: -unnamedTable" - inferredTable = [ - 1 "a" - 2 "b" + 1 "a" + 2 "b" ] "inferred table: -inferredTable" From 255d4807b4971d8137516ade5a28d7f1c10f84c6 Mon Sep 17 00:00:00 2001 From: Tejas Date: Wed, 15 Jul 2026 11:35:41 +0530 Subject: [PATCH 05/33] fix(runtime): align table headers with values Render and document named table headers as :Name rather than : Name. Source whitespace remains permissive and produces no compiler warnings. --- README.md | 7 ++++--- ast/ast.go | 2 +- parser/scriptparser_test.go | 2 +- runtime/array.c | 3 ++- tests/array/array.exp | 4 ++-- tests/array/array.spt | 4 ++-- tests/array/array_func.exp | 2 +- tests/array/array_func.spt | 6 +++--- 8 files changed, 16 insertions(+), 14 deletions(-) diff --git a/README.md b/README.md index ba1bd86e..3b2df9e0 100644 --- a/README.md +++ b/README.md @@ -206,7 +206,7 @@ matrix = [ ] scores = [ - : Name Score + :Name Score "Ada" 10 "Lin" 12 ] @@ -217,8 +217,9 @@ literal whose cells share one promotable type is a row-major matrix. A header row always produces a columnar table; without headers, homogeneous columns with different element types infer an unnamed table. A `:` must be followed by at least one column name; headerless literals start directly with their first -data row. Indentation inside brackets is not semantic; examples use four spaces. -Named columns are arrays, so `scores.Score` returns `[10 12]`. +data row. The conventional header spelling is `:Name Score`. Indentation and +spacing inside brackets are not semantic; examples use four spaces. Named +columns are arrays, so `scores.Score` returns `[10 12]`. String-array elements print quoted so boundaries stay unambiguous: diff --git a/ast/ast.go b/ast/ast.go index 0dafe3ee..5dd23fa0 100644 --- a/ast/ast.go +++ b/ast/ast.go @@ -313,7 +313,7 @@ func (al *ArrayLiteral) String() string { // Print headers if present if len(al.Headers) > 0 { - out.WriteString("\n : ") + out.WriteString("\n :") for j, header := range al.Headers { if j > 0 { out.WriteString(" ") diff --git a/parser/scriptparser_test.go b/parser/scriptparser_test.go index 1db62e17..2d146b60 100644 --- a/parser/scriptparser_test.go +++ b/parser/scriptparser_test.go @@ -1172,7 +1172,7 @@ func TestArrayLiterals(t *testing.T) { { name: "array with headers", input: `[ - : Day Product Price + :Day Product Price "Monday" "Phone" 200 "Tuesday" "Laptop" 300 ]`, diff --git a/runtime/array.c b/runtime/array.c index 5d00b447..006832a7 100644 --- a/runtime/array.c +++ b/runtime/array.c @@ -386,7 +386,8 @@ const char* table_str(size_t rows, size_t cols, const char* const* names, if (has_headers) { if (strbuf_printf(&sb, "\n :") < 0) goto fail; for (size_t col = 0; col < cols; ++col) { - if (strbuf_printf(&sb, " %s", names[col]) < 0) goto fail; + 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) { diff --git a/tests/array/array.exp b/tests/array/array.exp index d422d017..870c4d7e 100644 --- a/tests/array/array.exp +++ b/tests/array/array.exp @@ -20,13 +20,13 @@ matrix: [ 3 4 ] table: [ - : Name Score + :Name Score "Ada" 10 "Lin" 12 ] table scores: [10 12] replacement table: [ - : Name Score + :Name Score "Mae" 20 ] saved table scores: [10 12] diff --git a/tests/array/array.spt b/tests/array/array.spt index bdea8f35..c4c8e736 100644 --- a/tests/array/array.spt +++ b/tests/array/array.spt @@ -53,7 +53,7 @@ matrix = scores = [ - : Name Score + :Name Score "Ada" 10 "Lin" 12 ] @@ -62,7 +62,7 @@ scoreColumn = scores.Score "table scores: -scoreColumn" scores = [ - : Name Score + :Name Score "Mae" 20 ] "replacement table: -scores" diff --git a/tests/array/array_func.exp b/tests/array/array_func.exp index 5af3a5c4..c53b76aa 100644 --- a/tests/array/array_func.exp +++ b/tests/array/array_func.exp @@ -14,7 +14,7 @@ MatrixIdentity: [ 3 4 ] TableIdentity: [ - : Name Score + :Name Score "Ada" 10 "Lin" 12 ] diff --git a/tests/array/array_func.spt b/tests/array/array_func.spt index 508ed981..c48976b4 100644 --- a/tests/array/array_func.spt +++ b/tests/array/array_func.spt @@ -30,21 +30,21 @@ matrixIdentity = Identity([ "MatrixIdentity: -matrixIdentity" tableIdentity = Identity([ - : Name Score + :Name Score "Ada" 10 "Lin" 12 ]) "TableIdentity: -tableIdentity" tableCallColumn = Identity([ - : Name Score + :Name Score "Ada" 10 "Lin" 12 ]).Score "TableCallColumn: -tableCallColumn" gatedTableColumn = (1 > 0 && Identity([ - : Name Score + :Name Score "Ada" 10 "Lin" 12 ])).Name > "K" From f87308a78e9ebeec5570166cd845328cb328ca78 Mon Sep 17 00:00:00 2001 From: Tejas Date: Wed, 15 Jul 2026 12:46:00 +0530 Subject: [PATCH 06/33] feat(compiler): support untyped empty collections Allow empty arrays and header-only tables to remain valid zero-row values until concatenation supplies a concrete element type. Lower, print, and project these values safely while rejecting indexing, element-wise operations, and function calls that require resolved element types. Document the behavior and cover empty table projection and refinement alongside collection shape and unresolved-type errors. --- README.md | 14 +++++--- compiler/array.go | 3 ++ compiler/compiler.go | 14 ++++++-- compiler/format.go | 5 +++ compiler/solver.go | 20 ++++++++++-- compiler/solver_test.go | 72 +++++++++++++++++++++++++++++++++++++++++ compiler/table.go | 13 +++++++- compiler/types.go | 22 +++++++++++++ tests/array/array.exp | 7 ++++ tests/array/array.spt | 15 +++++++++ 10 files changed, 175 insertions(+), 10 deletions(-) diff --git a/README.md b/README.md index 3b2df9e0..23beb03e 100644 --- a/README.md +++ b/README.md @@ -215,11 +215,15 @@ scores = [ An empty or one-row headerless literal is an array. A rectangular, multi-row literal whose cells share one promotable type is a row-major matrix. A header row always produces a columnar table; without headers, homogeneous columns -with different element types infer an unnamed table. A `:` must be followed by -at least one column name; headerless literals start directly with their first -data row. The conventional header spelling is `:Name Score`. Indentation and -spacing inside brackets are not semantic; examples use four spaces. Named -columns are arrays, so `scores.Score` returns `[10 12]`. +with different element types infer an unnamed table. A header must contain at +least one column name, but it may have no data rows. Such a literal is an empty +table whose columns are untyped empty arrays and print as `[]`. Headerless +literals start directly with their first data row. The conventional header +spelling is `:Name Score`. Indentation and spacing inside brackets are not +semantic; examples use four spaces. Named columns are arrays, so +`scores.Score` returns `[10 12]`. Untyped empty arrays can be printed or refined +by concatenation; operations that require an element type are rejected until +then. String-array elements print quoted so boundaries stay unambiguous: diff --git a/compiler/array.go b/compiler/array.go index cf38f06e..dee2d12e 100644 --- a/compiler/array.go +++ b/compiler/array.go @@ -667,6 +667,9 @@ func (c *Compiler) compileArrayArrayInfix(op string, left *Symbol, right *Symbol func (c *Compiler) compileArrayConcat(left *Symbol, right *Symbol, leftElem Type, rightElem Type, resElem Type) *Symbol { // Array concatenation: arr1 ⊕ arr2 // Result is [arr1..., arr2...] + if resElem.Kind() == UnresolvedKind { + return c.makeZeroValue(Array{ElemType: resElem}) + } // Get lengths of both arrays leftLen := c.ArrayLen(left, leftElem) diff --git a/compiler/compiler.go b/compiler/compiler.go index ace9c81c..c0db89b5 100644 --- a/compiler/compiler.go +++ b/compiler/compiler.go @@ -1733,11 +1733,21 @@ func (c *Compiler) compileDotExpression(expr *ast.DotExpression) []*Symbol { continue } - columnValue := c.copyArray(c.tableColumnValue(leftSym.Val, i), column.ElemType) + columnType := Array{ElemType: column.ElemType} + 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}) return []*Symbol{{ - Type: Array{ElemType: column.ElemType}, + Type: columnType, Val: columnValue, }} } diff --git a/compiler/format.go b/compiler/format.go index 66da89f4..ca90bea2 100644 --- a/compiler/format.go +++ b/compiler/format.go @@ -596,6 +596,11 @@ 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.ElemType.Kind() == UnresolvedKind { + result.args = append(result.args, c.constCString("[]")) + break + } strPtr := c.arrayStrArg(mainSym) result.args = append(result.args, strPtr) result.toFree = append(result.toFree, strPtr) diff --git a/compiler/solver.go b/compiler/solver.go index 2cea9d22..8c1de7d0 100644 --- a/compiler/solver.go +++ b/compiler/solver.go @@ -647,6 +647,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), @@ -1344,6 +1348,13 @@ func (ts *TypeSolver) TypeArrayRangeExpression(ax *ast.ArrayRangeExpression, isR return info.OutTypes } elemType := arrType.ElemType + if elemType.Kind() == UnresolvedKind { + ts.Errors = append(ts.Errors, &token.CompileError{ + Token: ax.Tok(), + Msg: "cannot index an untyped empty array", + }) + return info.OutTypes + } // String array element access does strdup at runtime, so result is heap-allocated if elemType.Kind() == StrKind { elemType = StrH{} @@ -1830,8 +1841,13 @@ func (ts *TypeSolver) resolveArrayElemTypes(leftElem, rightElem Type, op string, 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 untyped empty arrays requires an element type", op), + }) return Unresolved{} } @@ -2207,7 +2223,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 3c93ca33..4a68fa7b 100644 --- a/compiler/solver_test.go +++ b/compiler/solver_test.go @@ -287,6 +287,78 @@ 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: "IndexUntypedEmptyArray", + script: "empty = []\nempty[0]", + expectError: "cannot index an untyped empty array", + }, + { + name: "UntypedEmptyArrayOperator", + script: "result = [] + []", + expectError: `operator "+" on untyped empty arrays requires an element type`, + }, + { + name: "UntypedEmptyArrayArgument", + code: identity, + script: "Identity([])", + expectError: "called with unknown argument type", + }, + { + name: "UntypedEmptyTableArgument", + code: identity, + script: `Identity([ + :Name Score +])`, + expectError: "called with unknown argument type", + }, + } + + 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()) diff --git a/compiler/table.go b/compiler/table.go index 40053b5e..6fd22484 100644 --- a/compiler/table.go +++ b/compiler/table.go @@ -5,6 +5,7 @@ import ( "tinygo.org/x/go-llvm" ) +// Values must match PtElementKind in runtime/array.h. var runtimeElementKinds = map[Kind]uint64{ IntKind: 0, FloatKind: 1, @@ -41,6 +42,10 @@ func (c *Compiler) compileMatrixLiteral(lit *ast.ArrayLiteral, matrixType Matrix } func (c *Compiler) compileTableLiteral(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 { @@ -145,7 +150,13 @@ func (c *Compiler) tableStrArg(s *Symbol) llvm.Value { 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) - c.builder.CreateStore(llvm.ConstInt(i32, runtimeElementKinds[column.ElemType.Kind()], false), kindSlot) + // 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) } diff --git a/compiler/types.go b/compiler/types.go index fb79230e..f289780f 100644 --- a/compiler/types.go +++ b/compiler/types.go @@ -292,6 +292,28 @@ func IsFullyResolvedType(t Type) bool { } } +// isUntypedEmptyCollection reports whether t is a runtime-representable empty +// collection whose element types have not been established yet. Non-empty +// unresolved collections fail during literal typing and never reach this check. +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, one-dimensional sequence. type Array struct { ElemType Type diff --git a/tests/array/array.exp b/tests/array/array.exp index 870c4d7e..098b3083 100644 --- a/tests/array/array.exp +++ b/tests/array/array.exp @@ -30,6 +30,13 @@ replacement table: [ "Mae" 20 ] saved table scores: [10 12] +empty array: [] +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 c4c8e736..d8b6386a 100644 --- a/tests/array/array.spt +++ b/tests/array/array.spt @@ -68,6 +68,21 @@ scores = "replacement table: -scores" "saved table scores: -scoreColumn" +emptyArray = [] +"empty array: -emptyArray" +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" From fa3dc2d65f4e848f81023d17b5ac70b33a5c205c Mon Sep 17 00:00:00 2001 From: Tejas Date: Wed, 15 Jul 2026 18:02:26 +0530 Subject: [PATCH 07/33] feat(compiler): add first-class empty arrays Represent [] as a resolved Array[Empty] so empty arrays can specialize templates and refine through concatenation. Preserve a binding's concrete element type when it is reset to [], while lowering empty values to the canonical null array representation. Document the empty-array rules and cover direct, named, in-place, identity, empty-concat, and type-lock behavior. --- README.md | 12 +++++++--- compiler/array.go | 35 ++++++++++++++--------------- compiler/compiler.go | 24 +++++++++++++++----- compiler/format.go | 2 +- compiler/solver.go | 36 +++++++++++++++++++++++++----- compiler/solver_test.go | 15 ++++++------- compiler/types.go | 45 +++++++++++++++++++++++++++++++++----- tests/array/array_func.exp | 8 +++++++ tests/array/array_func.pt | 6 +++++ tests/array/array_func.spt | 24 ++++++++++++++++++++ 10 files changed, 159 insertions(+), 48 deletions(-) diff --git a/README.md b/README.md index 23beb03e..ab2e62f5 100644 --- a/README.md +++ b/README.md @@ -221,9 +221,15 @@ table whose columns are untyped empty arrays and print as `[]`. Headerless literals start directly with their first data row. The conventional header spelling is `:Name Score`. Indentation and spacing inside brackets are not semantic; examples use four spaces. Named columns are arrays, so -`scores.Score` returns `[10 12]`. Untyped empty arrays can be printed or refined -by concatenation; operations that require an element type are rejected until -then. +`scores.Score` returns `[10 12]`. Header-only tables can be printed and +projected, but cannot be passed to functions until their column types are +established. + +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. String-array elements print quoted so boundaries stay unambiguous: diff --git a/compiler/array.go b/compiler/array.go index dee2d12e..e2a508d2 100644 --- a/compiler/array.go +++ b/compiler/array.go @@ -310,21 +310,8 @@ func (c *Compiler) compileArrayLiteralImmediate(lit *ast.ArrayLiteral, info *Exp // Handle empty array literal: [] if len(lit.Rows) == 0 { arr := info.OutTypes[0].(Array) - elemType := arr.ElemType - - // 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") + s.Val = llvm.ConstPointerNull(llvm.PointerType(c.Context.Int8Type(), 0)) return []*Symbol{s} } @@ -667,13 +654,19 @@ func (c *Compiler) compileArrayArrayInfix(op string, left *Symbol, right *Symbol func (c *Compiler) compileArrayConcat(left *Symbol, right *Symbol, leftElem Type, rightElem Type, resElem Type) *Symbol { // Array concatenation: arr1 ⊕ arr2 // Result is [arr1..., arr2...] - if resElem.Kind() == UnresolvedKind { + if !hasConcreteArrayElemType(resElem) { return c.makeZeroValue(Array{ElemType: resElem}) } // Get lengths of both arrays - leftLen := c.ArrayLen(left, leftElem) - rightLen := c.ArrayLen(right, rightElem) + leftLen := c.ConstI64(0) + if hasConcreteArrayElemType(leftElem) { + leftLen = c.ArrayLen(left, leftElem) + } + rightLen := c.ConstI64(0) + if hasConcreteArrayElemType(rightElem) { + rightLen = c.ArrayLen(right, rightElem) + } // Calculate total length totalLen := c.builder.CreateAdd(leftLen, rightLen, "concat_len") @@ -682,10 +675,14 @@ 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) diff --git a/compiler/compiler.go b/compiler/compiler.go index c0db89b5..de6473e6 100644 --- a/compiler/compiler.go +++ b/compiler/compiler.go @@ -913,6 +913,18 @@ 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 { + return &Symbol{ + Val: derefed.Val, + Type: targetArray, + FuncArg: derefed.FuncArg, + Borrowed: derefed.Borrowed, + ReadOnly: derefed.ReadOnly, + } + } + return derefed } @@ -967,7 +979,7 @@ func (c *Compiler) freeValue(val llvm.Value, typ Type) { case StrG: // Static strings live forever, no free needed case Array: - if t.ElemType != nil && t.ElemType.Kind() != UnresolvedKind { + if hasConcreteArrayElemType(t.ElemType) { c.freeArray(val, t.ElemType) } case Matrix: @@ -994,7 +1006,7 @@ func typeNeedsCleanup(typ Type) bool { case StrH: return true case Array: - return true + return hasConcreteArrayElemType(t.ElemType) case Matrix: return true case Table: @@ -2175,7 +2187,7 @@ func (c *Compiler) cleanupRangeInfixTemps( func (c *Compiler) updateUnresolvedType(name string, sym *Symbol, resolved Type) { switch t := sym.Type.(type) { case Array: - if t.ElemType.Kind() == UnresolvedKind { + if !hasConcreteArrayElemType(t.ElemType) { sym.Type = resolved Put(c.Scopes, name, sym) } @@ -3285,8 +3297,8 @@ func (c *Compiler) deepCopyIfNeeded(sym *Symbol) *Symbol { // Deep copy the array arrayType := sym.Type.(Array) if arrayType.ElemType != nil { - // Skip copying if the element type is unresolved (will be resolved later) - if arrayType.ElemType.Kind() == UnresolvedKind { + // Empty and unresolved arrays have no runtime allocation to copy. + if !hasConcreteArrayElemType(arrayType.ElemType) { return sym } copiedArr := c.copyArray(sym.Val, arrayType.ElemType) @@ -3504,7 +3516,7 @@ func (c *Compiler) appendPrintSymbol(s *Symbol, expr ast.Expression, formatStr * *toFree = append(*toFree, strPtr) case ArrayKind: arrType := s.Type.(Array) - if arrType.ElemType == nil || arrType.ElemType.Kind() == UnresolvedKind { + if !hasConcreteArrayElemType(arrType.ElemType) { *args = append(*args, c.constCString("[]")) return } diff --git a/compiler/format.go b/compiler/format.go index ca90bea2..34ac11c9 100644 --- a/compiler/format.go +++ b/compiler/format.go @@ -597,7 +597,7 @@ func (c *Compiler) formatAsString(mainSym *Symbol, result *formattedMarker) bool result.toFree = append(result.toFree, strPtr) case ArrayKind: arrType := mainSym.Type.(Array) - if arrType.ElemType.Kind() == UnresolvedKind { + if !hasConcreteArrayElemType(arrType.ElemType) { result.args = append(result.args, c.constCString("[]")) break } diff --git a/compiler/solver.go b/compiler/solver.go index 8c1de7d0..952952e1 100644 --- a/compiler/solver.go +++ b/compiler/solver.go @@ -181,6 +181,14 @@ func (ts *TypeSolver) concatArrayTypes(leftArr, rightArr Array, tok token.Token) leftElemType := leftArr.ElemType rightElemType := rightArr.ElemType + if leftElemType.Kind() == EmptyKind { + return Array{ElemType: rightElemType} + } + + if rightElemType.Kind() == EmptyKind { + return Array{ElemType: leftElemType} + } + if leftElemType.Kind() == UnresolvedKind { return Array{ElemType: rightElemType} } @@ -935,7 +943,7 @@ func (ts *TypeSolver) TypeLetStatement(stmt *ast.LetStatement) { // tables. Element and column types are homogeneous, with I64-to-F64 promotion. func (ts *TypeSolver) TypeArrayExpression(al *ast.ArrayLiteral) []Type { if len(al.Headers) == 0 && len(al.Rows) == 0 { - arr := Array{ElemType: Unresolved{}} + arr := Array{ElemType: Empty{}} ts.ExprCache[key(ts.FuncNameMangled, al)] = &ExprInfo{OutTypes: []Type{arr}, ExprLen: 1, HasRanges: false} return []Type{arr} } @@ -1348,10 +1356,10 @@ func (ts *TypeSolver) TypeArrayRangeExpression(ax *ast.ArrayRangeExpression, isR return info.OutTypes } elemType := arrType.ElemType - if elemType.Kind() == UnresolvedKind { + if !hasConcreteArrayElemType(elemType) { ts.Errors = append(ts.Errors, &token.CompileError{ Token: ax.Tok(), - Msg: "cannot index an untyped empty array", + Msg: "cannot index an empty array without an element type", }) return info.OutTypes } @@ -1836,8 +1844,26 @@ func (ts *TypeSolver) typeArrayArrayInfix(leftArr, rightArr Array, op string, to return Array{ElemType: resultElem} } -// 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 @@ -1846,7 +1872,7 @@ func (ts *TypeSolver) resolveArrayElemTypes(leftElem, rightElem Type, op string, if leftUnresolved && rightUnresolved { ts.Errors = append(ts.Errors, &token.CompileError{ Token: tok, - Msg: fmt.Sprintf("operator %q on untyped empty arrays requires an element type", op), + Msg: fmt.Sprintf("operator %q on unresolved arrays requires an element type", op), }) return Unresolved{} } diff --git a/compiler/solver_test.go b/compiler/solver_test.go index 4a68fa7b..4415f8a5 100644 --- a/compiler/solver_test.go +++ b/compiler/solver_test.go @@ -307,20 +307,19 @@ func TestCollectionTypeErrors(t *testing.T) { expectError: "bracket literal row 1 has 1 cells, expected 2", }, { - name: "IndexUntypedEmptyArray", + name: "IndexEmptyArray", script: "empty = []\nempty[0]", - expectError: "cannot index an untyped empty array", + expectError: "cannot index an empty array without an element type", }, { - name: "UntypedEmptyArrayOperator", + name: "EmptyArrayOperator", script: "result = [] + []", - expectError: `operator "+" on untyped empty arrays requires an element type`, + expectError: `operator "+" on empty arrays requires an element type`, }, { - name: "UntypedEmptyArrayArgument", - code: identity, - script: "Identity([])", - expectError: "called with unknown argument 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: "UntypedEmptyTableArgument", diff --git a/compiler/types.go b/compiler/types.go index f289780f..7b9ded02 100644 --- a/compiler/types.go +++ b/compiler/types.go @@ -13,6 +13,7 @@ type Kind int const ( UnresolvedKind Kind = iota + EmptyKind IntKind UintKind FloatKind @@ -52,6 +53,7 @@ var PrimitiveTypeNames = []string{ "U64", "U32", "U16", "U8", "F64", "F32", "StrG", "StrH", // StrG = global/static (.rodata), StrH = heap + "Empty", "X", // Unresolved placeholder } @@ -88,6 +90,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 @@ -259,7 +270,7 @@ 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) @@ -292,9 +303,8 @@ func IsFullyResolvedType(t Type) bool { } } -// isUntypedEmptyCollection reports whether t is a runtime-representable empty -// collection whose element types have not been established yet. Non-empty -// unresolved collections fail during literal typing and never reach this check. +// 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: @@ -334,6 +344,10 @@ func (a Array) Key() Type { return Array{ElemType: a.ElemType.Key()} } +func hasConcreteArrayElemType(elem Type) bool { + return elem != nil && elem.Kind() != EmptyKind && elem.Kind() != UnresolvedKind +} + // Matrix is a homogeneous, two-dimensional value. Its dimensions are runtime // properties and therefore do not participate in type identity. type Matrix struct { @@ -578,7 +592,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 } @@ -622,6 +636,12 @@ 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 && + (oldArray.ElemType.Kind() == EmptyKind || newArray.ElemType.Kind() == EmptyKind) { + return true + } return CanRefineType(oldType, newType) } @@ -632,6 +652,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 } @@ -682,6 +712,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: @@ -710,6 +742,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) @@ -808,7 +841,7 @@ 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": {}, diff --git a/tests/array/array_func.exp b/tests/array/array_func.exp index c53b76aa..d1de7f02 100644 --- a/tests/array/array_func.exp +++ b/tests/array/array_func.exp @@ -22,6 +22,14 @@ 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 9252bffb..0cee3b68 100644 --- a/tests/array/array_func.pt +++ b/tests/array/array_func.pt @@ -21,3 +21,9 @@ res = Acc(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 c48976b4..0aacabf7 100644 --- a/tests/array/array_func.spt +++ b/tests/array/array_func.spt @@ -57,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" From 7c7983cbc487b660de3604b51c5a1c9afdb092d1 Mon Sep 17 00:00:00 2001 From: Tejas Date: Wed, 15 Jul 2026 23:59:38 +0530 Subject: [PATCH 08/33] feat(compiler): generalize arrays to rank-N rectangular values Replace the Matrix type with Array{ElemType, Rank}: rank is part of type identity, dimension lengths are runtime values, and every rank shares one flat row-major buffer (rank 1 stays a bare runtime-vector pointer; higher ranks carry {data, dim0..dimN}). Mangling composes as repeated Array_t1_, so no new ABI entry is needed. Literals infer rank from shape: stacked scalar rows are rank 2, and array-valued cells of equal rank and shape stack one rank higher, so [[1 2] [3 4]] equals the stacked spelling and variables stack as rows. Mixed scalar/array cells, ragged rows, rank-mismatched operations, and statically unequal child shapes are compile errors; cells are never padded with defaults. Indexing removes the outer dimension and returns an owned copy, so chained indexing reaches inner ranks; OOB reads keep the per-lane failed-condition contract. Range indexing stays rank-1 outside collectors, while [m[range]] stacks selected rows. Runtime shape checks (stacking, concatenation, zipping with dynamically unequal inner dimensions) now abort through array_shape_fail with a diagnostic naming the mismatched dimensions instead of a silent trap. Stacked-literal children are released via freeConsumedTemporary so conditional-frame cleanup stays synchronized. Document the model in docs/Pluto Array Semantics.md and cover rank-3 construction and chained indexing, stacked collectors, string matrices, empty resets, and the new shape/rank error paths. Co-Authored-By: Claude Fable 5 --- README.md | 44 +++-- ast/ast.go | 2 +- compiler/array.go | 237 ++++++++++++++++++++------ compiler/array_nd.go | 262 +++++++++++++++++++++++++++++ compiler/bounds.go | 15 +- compiler/cfuncs.go | 9 +- compiler/compiler.go | 99 +++++------ compiler/compiler_test.go | 20 +-- compiler/format.go | 11 +- compiler/mangle_test.go | 8 +- compiler/solver.go | 278 ++++++++++++++++++++++++------- compiler/solver_test.go | 30 ++++ compiler/table.go | 64 ------- compiler/types.go | 79 ++++----- docs/Pluto Array Semantics.md | 88 ++++++++++ docs/Pluto C ABI Spec.md | 7 +- docs/Pluto Memory Model.md | 11 +- docs/Pluto Range Semantics.md | 9 +- parser/scriptparser_test.go | 4 +- runtime/array.c | 69 ++++++-- runtime/array.h | 3 +- tests/array/array.exp | 39 +++++ tests/array/array.spt | 40 +++++ tests/math/func_nested_range.spt | 2 +- 24 files changed, 1092 insertions(+), 338 deletions(-) create mode 100644 compiler/array_nd.go create mode 100644 docs/Pluto Array Semantics.md diff --git a/README.md b/README.md index ab2e62f5..755e7a76 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), inferred row-major matrices, columnar tables, 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,7 +197,7 @@ 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 matrices and tables without a type keyword: +The same bracket syntax infers higher-rank arrays and tables without a type keyword: ```python matrix = [ @@ -205,6 +205,17 @@ matrix = [ 3 4 ] +cube = [ + [ + 1 2 + 3 4 + ] + [ + 5 6 + 7 8 + ] +] + scores = [ :Name Score "Ada" 10 @@ -212,18 +223,25 @@ scores = [ ] ``` -An empty or one-row headerless literal is an array. A rectangular, multi-row -literal whose cells share one promotable type is a row-major matrix. A header -row always produces a columnar table; without headers, homogeneous columns -with different element types infer an unnamed table. A header must contain at -least one column name, but it may have no data rows. Such a literal is an empty -table whose columns are untyped empty arrays and print as `[]`. Headerless -literals start directly with their first data row. The conventional header -spelling is `:Name Score`. Indentation and spacing inside brackets are not -semantic; examples use four spaces. Named columns are arrays, so +An empty or one-row scalar literal is rank 1. A rectangular, multi-row scalar +literal is rank 2. Array-valued cells of equal rank and shape stack along a new +outer dimension, so `[[1 2] [3 4]]` is the same rank-2 value as the `matrix` +literal above and the rule extends to any rank. Storage is flat and row-major; +there is no pointer per row. Ragged literals are compile errors and missing +cells are never padded with default values. Indexing removes the outer +dimension, so `cube[1][0]` is `[5 6]`. + +A header row always produces a columnar table; without headers, homogeneous +columns with different element types infer an unnamed table. A header must +contain at least one column name, but it may have no data rows. Such a literal +is an empty table whose columns are untyped empty arrays and print as `[]`. +Headerless literals start directly with their first data row. The conventional +header spelling is `:Name Score`. Indentation and spacing inside brackets are +not semantic; examples use four spaces. Named columns are arrays, so `scores.Score` returns `[10 12]`. Header-only tables can be printed and projected, but cannot be passed to functions until their column types are -established. +established. See [Pluto Array Semantics](docs/Pluto%20Array%20Semantics.md) for +the complete inference and shape rules. 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. diff --git a/ast/ast.go b/ast/ast.go index 5dd23fa0..9d308bdc 100644 --- a/ast/ast.go +++ b/ast/ast.go @@ -300,7 +300,7 @@ func (rl *RangeLiteral) String() string { type ArrayLiteral struct { Token token.Token // the '[' token - Headers []string // column names; empty for arrays, matrices, and unnamed tables + 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] } diff --git a/compiler/array.go b/compiler/array.go index e2a508d2..c41e0ba5 100644 --- a/compiler/array.go +++ b/compiler/array.go @@ -63,6 +63,13 @@ 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.ElemType.Kind()] fnTy, fn := c.GetCFunc(info.NewName) @@ -174,7 +181,10 @@ 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") + c.trapOnFalse(ok, name) +} +func (c *Compiler) trapOnFalse(ok llvm.Value, name string) { fn := c.builder.GetInsertBlock().Parent() failBlock := c.Context.AddBasicBlock(fn, name+"_fail") contBlock := c.Context.AddBasicBlock(fn, name+"_cont") @@ -229,17 +239,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 +263,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 { @@ -266,14 +280,21 @@ func (c *Compiler) ArrayGetBorrowed(arr *Symbol, elem Type, idx llvm.Value) llvm // Array compilation functions // compileArrayExpression materializes a bracket literal according to its solved -// Array, Matrix, or Table type. +// Array or Table type. func (c *Compiler) compileArrayExpression(e *ast.ArrayLiteral, _ []*ast.Identifier) (res []*Symbol) { lit, info := c.resolveArrayLiteralRewrite(e) switch typ := info.OutTypes[0].(type) { case Array: + if typ.Rank > 1 { + if c.arrayLiteralHasArrayCells(lit) { + if len(info.CollectRanges) > 0 { + return []*Symbol{c.compileStackedArrayCollector(lit, info, typ)} + } + return []*Symbol{c.compileStackedArrayLiteral(lit, typ)} + } + return []*Symbol{c.compileRectangularArrayLiteral(lit, typ)} + } return []*Symbol{c.compileArrayLiteralInDomain(lit, info, nil, nil)} - case Matrix: - return []*Symbol{c.compileMatrixLiteral(lit, typ)} case Table: return []*Symbol{c.compileTableLiteral(lit, typ)} default: @@ -285,6 +306,18 @@ func (c *Compiler) compileArrayExpression(e *ast.ArrayLiteral, _ []*ast.Identifi } } +func (c *Compiler) arrayLiteralHasArrayCells(lit *ast.ArrayLiteral) bool { + for _, row := range lit.Rows { + for _, cell := range row { + info := c.ExprCache[key(c.FuncNameMangled, cell)] + if info != nil && len(info.OutTypes) == 1 && info.OutTypes[0].Kind() == ArrayKind { + return true + } + } + } + return false +} + // resolveArrayLiteralRewrite retrieves the potentially rewritten array literal and its ExprInfo. // The type solver may rewrite array literals to replace range expressions with temporary iterators. func (c *Compiler) resolveArrayLiteralRewrite(e *ast.ArrayLiteral) (*ast.ArrayLiteral, *ExprInfo) { @@ -311,7 +344,7 @@ func (c *Compiler) compileArrayLiteralImmediate(lit *ast.ArrayLiteral, info *Exp if len(lit.Rows) == 0 { arr := info.OutTypes[0].(Array) s.Type = arr - s.Val = llvm.ConstPointerNull(llvm.PointerType(c.Context.Int8Type(), 0)) + s.Val = c.createArrayValue(llvm.Value{}, []llvm.Value{c.ConstI64(0)}, arr) return []*Symbol{s} } @@ -330,7 +363,7 @@ func (c *Compiler) compileArrayLiteralImmediate(lit *ast.ArrayLiteral, info *Exp } s.Type = arr - s.Val = c.builder.CreateBitCast(arrVal, llvm.PointerType(c.Context.Int8Type(), 0), "arr_i8p") + s.Val = c.createArrayValue(arrVal, []llvm.Value{nConst}, arr) return []*Symbol{s} } @@ -564,16 +597,39 @@ 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).ElemType - rightElem := right.Type.(Array).ElemType - 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. Higher-rank arrays zip the outer dimension and require equal inner shape. +func (c *Compiler) arrayPairShape(left *Symbol, right *Symbol) (llvm.Value, []llvm.Value) { + leftType := left.Type.(Array) + if leftType.Rank == 1 { + leftLen := c.ArrayLen(left, leftType.ElemType) + rightLen := c.ArrayLen(right, right.Type.(Array).ElemType) + length := c.builder.CreateSelect( + c.builder.CreateICmp(llvm.IntULT, leftLen, rightLen, "cmp_len"), + leftLen, rightLen, "min_len", + ) + return length, []llvm.Value{length} + } + + leftDims := c.arrayDimensions(left) + rightDims := c.arrayDimensions(right) + zero := c.ConstI64(0) + bothNonEmpty := c.builder.CreateAnd( + c.builder.CreateICmp(llvm.IntNE, leftDims[0], zero, "left_array_nonempty"), + c.builder.CreateICmp(llvm.IntNE, rightDims[0], zero, "right_array_nonempty"), + "both_arrays_nonempty", + ) + c.requireSameArrayShapeWhen(bothNonEmpty, leftDims[1:], rightDims[1:], "array_inner_shape") + outer := c.builder.CreateSelect( + c.builder.CreateICmp(llvm.IntULT, leftDims[0], rightDims[0], "cmp_outer_len"), + leftDims[0], rightDims[0], "min_outer_len", ) + dimensions := append([]llvm.Value{outer}, leftDims[1:]...) + length := outer + for _, dimension := range dimensions[1:] { + length = c.builder.CreateMul(length, dimension, "array_zip_len") + } + return length, dimensions } // forEachArrayPair iterates element-wise over an array LHS and either an array @@ -606,13 +662,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{ElemType: elemType}, - Val: c.builder.CreateBitCast(array, i8p, "arr_i8p"), + Type: arrayType, + Val: c.createArrayValue(data, dimensions, arrayType), } } @@ -631,7 +684,7 @@ func (c *Compiler) compileArrayArrayInfix(op string, left *Symbol, right *Symbol // 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) + loopLen, dimensions := c.arrayPairShape(left, right) resVec := c.CreateArrayForType(resElem, loopLen) c.forEachArrayPair(left, right, loopLen, func(iter llvm.Value, leftSym *Symbol, rightSym *Symbol) { // compileInfix reads values and produces a new result @@ -648,14 +701,38 @@ 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) { - return c.makeZeroValue(Array{ElemType: resElem}) + if arrayType.Rank == 1 { + return c.makeZeroValue(arrayType) + } + return c.arrayValueSymbol(llvm.Value{}, arrayType, dimensions) } // Get lengths of both arrays @@ -685,12 +762,16 @@ func (c *Compiler) compileArrayConcat(left *Symbol, right *Symbol, leftElem Type } // Return concatenated array - return c.arraySym(resVec, resElem) + if arrayType.Rank == 1 { + dimensions = []llvm.Value{totalLen} + } + 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).ElemType loopLen := c.ArrayLen(arr, arrElem) + dimensions := c.arrayDimensions(arr) resVec := c.CreateArrayForType(resElem, loopLen) c.forEachArrayPair(arr, scalar, loopLen, func(iter llvm.Value, elemSym *Symbol, scalarSym *Symbol) { // Respect the original operand order for non-commutative operations @@ -711,7 +792,7 @@ 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: arr.Type.(Array).Rank}, dimensions) } // compileArrayMask dispatches value-position comparison masking when at least one @@ -752,19 +833,20 @@ 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) { 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).ElemType loopLen := c.ArrayLen(arr, arrElem) + dimensions := c.arrayDimensions(arr) resVec := c.CreateArrayForType(resElem, loopLen) c.forEachArrayPair(arr, scalar, loopLen, func(iter llvm.Value, elemSym *Symbol, scalarSym *Symbol) { // Preserve operand order for non-commutative comparisons. The masked value @@ -778,7 +860,7 @@ 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: arr.Type.(Array).Rank}, dimensions) } func (c *Compiler) compileArrayUnaryPrefix(op string, arr *Symbol, result Array) *Symbol { @@ -786,6 +868,7 @@ func (c *Compiler) compileArrayUnaryPrefix(op string, arr *Symbol, result Array) elem := arrType.ElemType resElem := result.ElemType n := c.ArrayLen(arr, elem) + dimensions := c.arrayDimensions(arr) resVec := c.CreateArrayForType(resElem, n) r := c.rangeZeroToN(n) @@ -806,20 +889,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) + 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") } @@ -828,7 +914,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, @@ -855,14 +941,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 { @@ -896,6 +974,44 @@ 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) 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.makeZeroValue(resultType) + 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. @@ -940,13 +1056,16 @@ func (c *Compiler) compileArrayRangeBasic(expr *ast.ArrayRangeExpression) []*Sym // Scalar index - element access arrElemType := arrType.ElemType - resultType := arrayAccessResultType(arrElemType) + 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}} } @@ -956,6 +1075,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}} @@ -980,10 +1102,15 @@ func (c *Compiler) compileArrayRangeRanges(info *ExprInfo, dest []*ast.Identifie } arrElemType := arrType.ElemType - resultType := arrayAccessResultType(arrElemType) + 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 @@ -994,8 +1121,13 @@ 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) @@ -1010,8 +1142,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..973a708e --- /dev/null +++ b/compiler/array_nd.go @@ -0,0 +1,262 @@ +package compiler + +import ( + "fmt" + + "github.com/thiremani/pluto/ast" + "github.com/thiremani/pluto/token" + "tinygo.org/x/go-llvm" +) + +func (c *Compiler) compileRectangularArrayLiteral(lit *ast.ArrayLiteral, arrayType Array) *Symbol { + rows := len(lit.Rows) + cols := 0 + if rows > 0 { + cols = len(lit.Rows[0]) + } + + data := c.CreateArrayForType(arrayType.ElemType, c.ConstI64(uint64(rows*cols))) + for rowIndex, row := range lit.Rows { + for colIndex, cell := range row { + index := c.ConstI64(uint64(rowIndex*cols + colIndex)) + c.compileArrayLiteralCell(cell, arrayType.ElemType, func(cellSlot *Symbol) bool { + return c.setArrayCellSlot(data, index, cellSlot, arrayType.ElemType) + }) + } + } + + return &Symbol{ + Type: arrayType, + Val: c.createArrayValue(data, []llvm.Value{c.ConstI64(uint64(rows)), c.ConstI64(uint64(cols))}, arrayType), + } +} + +func (c *Compiler) compileStackedArrayLiteral(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 { + compiled := c.compileExpression(child, nil) + if len(compiled) != 1 { + c.Errors = append(c.Errors, &token.CompileError{ + Token: child.Tok(), + Msg: fmt.Sprintf("array cell produced %d values; expected 1", len(compiled)), + }) + return c.makeZeroValue(arrayType) + } + childSymbols = append(childSymbols, c.derefIfPointer(compiled[0], "stacked_array_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) + dimensions = append(dimensions, c.ConstI64(uint64(len(childSymbols)))) + 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 { + offset := llvm.Value{} + applyOffset := i > 0 + if applyOffset { + offset = c.builder.CreateMul(c.ConstI64(uint64(i)), childLen, "stacked_array_offset") + } + c.CopyArrayInto(data, child, child.Type.(Array).ElemType, arrayType.ElemType, offset, applyOffset) + c.freeConsumedTemporary(children[i], []*Symbol{child}) + } + + return &Symbol{Type: arrayType, Val: c.createArrayValue(data, dimensions, arrayType)} +} + +func (c *Compiler) compileStackedArrayCollector(lit *ast.ArrayLiteral, info *ExprInfo, arrayType Array) *Symbol { + if !hasConcreteArrayElemType(arrayType.ElemType) { + return c.makeZeroValue(arrayType) + } + + // The runtime accumulator owns the flat leaf buffer. Shape is tracked + // separately while lower-rank child arrays are appended. + acc := c.NewArrayAccumulator(Array{ElemType: arrayType.ElemType, Rank: 1}) + 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) + } + + c.withCollectorLoopNest(info.CollectRanges, lit, nil, func() { + for _, row := range lit.Rows { + for _, child := range row { + c.appendStackedArrayChild(acc, outerLenSlot, childDimSlots, child) + } + } + }) + + dimensions := make([]llvm.Value, 0, arrayType.Rank) + dimensions = append(dimensions, c.createLoad(outerLenSlot, I64, "stacked_array_outer_len")) + for _, slot := range childDimSlots { + dimensions = append(dimensions, c.createLoad(slot, I64, "stacked_array_dimension")) + } + + return &Symbol{Type: arrayType, Val: c.createArrayValue(acc.Vec, dimensions, arrayType)} +} + +func (c *Compiler) appendStackedArrayChild( + acc *ArrayAccumulator, + outerLenSlot llvm.Value, + childDimSlots []llvm.Value, + child ast.Expression, +) { + compiled := c.compileExpression(child, nil) + if len(compiled) != 1 { + c.Errors = append(c.Errors, &token.CompileError{ + Token: child.Tok(), + Msg: fmt.Sprintf("array cell produced %d values; expected 1", len(compiled)), + }) + return + } + + childSymbol := c.derefIfPointer(compiled[0], "stacked_array_child") + childType := childSymbol.Type.(Array) + childDimensions := c.arrayDimensions(childSymbol) + outerLen := c.createLoad(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(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"), childDimSlots[i], I64) + } + + 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, &Symbol{Val: value, Type: childType.ElemType}, false, acc.ElemType) + }) + + c.createStore(c.builder.CreateAdd(outerLen, c.ConstI64(1), "stacked_array_outer_len_next"), 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 +} + +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 10bb5a73..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") diff --git a/compiler/cfuncs.go b/compiler/cfuncs.go index 65ad3f48..20c19aca 100644 --- a/compiler/cfuncs.go +++ b/compiler/cfuncs.go @@ -18,7 +18,8 @@ const ( STR_QUOTE = "str_quote" STR_QUOTE_PREFIX = "str_quote_prefix" STR_HEX = "str_hex" - MATRIX_STR = "matrix_str" + ARRAY_ND_STR = "array_nd_str" + ARRAY_SHAPE_FAIL = "array_shape_fail" TABLE_STR = "table_str" // Array I64 functions @@ -96,8 +97,10 @@ 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 MATRIX_STR: - return llvm.FunctionType(charPtr, []llvm.Type{charPtr, i32, i64, i64}, 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) diff --git a/compiler/compiler.go b/compiler/compiler.go index de6473e6..eb358832 100644 --- a/compiler/compiler.go +++ b/compiler/compiler.go @@ -493,13 +493,18 @@ 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) - case MatrixKind: + arrayType := t.(Array) arrayPtr := llvm.PointerType(c.Context.Int8Type(), 0) + if arrayType.Rank == 1 { + return arrayPtr + } i64 := c.Context.Int64Type() - return llvm.StructType([]llvm.Type{arrayPtr, i64, i64}, false) + 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) @@ -797,18 +802,12 @@ 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)) - case MatrixKind: - matrixType := symType.(Matrix) - s.Val = c.createMatrixValue( - llvm.ConstPointerNull(llvm.PointerType(c.Context.Int8Type(), 0)), - c.ConstI64(0), - c.ConstI64(0), - matrixType, - ) + 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)) @@ -916,8 +915,9 @@ 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 { + zero := c.makeZeroValue(targetArray) return &Symbol{ - Val: derefed.Val, + Val: zero.Val, Type: targetArray, FuncArg: derefed.FuncArg, Borrowed: derefed.Borrowed, @@ -936,7 +936,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.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 } @@ -980,11 +998,7 @@ func (c *Compiler) freeValue(val llvm.Value, typ Type) { // Static strings live forever, no free needed case Array: if hasConcreteArrayElemType(t.ElemType) { - c.freeArray(val, t.ElemType) - } - case Matrix: - if t.ElemType != nil && t.ElemType.Kind() != UnresolvedKind { - c.freeArray(c.matrixArrayValue(val), t.ElemType) + c.freeArray(c.arrayDataValue(val, t), t.ElemType) } case Table: for i, column := range t.Columns { @@ -1007,8 +1021,6 @@ func typeNeedsCleanup(typ Type) bool { return true case Array: return hasConcreteArrayElemType(t.ElemType) - case Matrix: - return true case Table: return true case ArrayRange: @@ -1532,10 +1544,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 - inst.SetAlignment(8) - case Matrix, Table: + case Array, Table: inst.SetAlignment(8) case ArrayRange: // ArrayRange is a struct of { i8*, Range }, so align to the largest member, which is i8* @@ -1745,7 +1754,7 @@ func (c *Compiler) compileDotExpression(expr *ast.DotExpression) []*Symbol { continue } - columnType := Array{ElemType: column.ElemType} + 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 @@ -2191,11 +2200,6 @@ func (c *Compiler) updateUnresolvedType(name string, sym *Symbol, resolved Type) sym.Type = resolved Put(c.Scopes, name, sym) } - case Matrix: - if t.ElemType.Kind() == UnresolvedKind { - sym.Type = resolved - Put(c.Scopes, name, sym) - } case Table: if !IsFullyResolvedType(t) { sym.Type = resolved @@ -3294,32 +3298,19 @@ func (c *Compiler) deepCopyIfNeeded(sym *Symbol) *Symbol { ReadOnly: false, } case ArrayKind: - // Deep copy the array arrayType := sym.Type.(Array) if arrayType.ElemType != nil { - // Empty and unresolved arrays have no runtime allocation to copy. if !hasConcreteArrayElemType(arrayType.ElemType) { return sym } - copiedArr := c.copyArray(sym.Val, arrayType.ElemType) 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 MatrixKind: - matrixType := sym.Type.(Matrix) - if matrixType.ElemType.Kind() == UnresolvedKind { - return sym - } - return &Symbol{ - Val: c.copyMatrixValue(sym.Val, matrixType), - Type: matrixType, - Borrowed: false, - } case TableKind: tableType := sym.Type.(Table) if !IsFullyResolvedType(tableType) { @@ -3516,17 +3507,13 @@ func (c *Compiler) appendPrintSymbol(s *Symbol, expr ast.Expression, formatStr * *toFree = append(*toFree, strPtr) case ArrayKind: arrType := s.Type.(Array) - if !hasConcreteArrayElemType(arrType.ElemType) { + 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 MatrixKind: - strPtr := c.matrixStrArg(s) - *args = append(*args, strPtr) - *toFree = append(*toFree, strPtr) case TableKind: strPtr := c.tableStrArg(s) *args = append(*args, strPtr) diff --git a/compiler/compiler_test.go b/compiler/compiler_test.go index ecf5159d..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{ElemType: 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 bracket literal 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{ElemType: 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/format.go b/compiler/format.go index 34ac11c9..337025b6 100644 --- a/compiler/format.go +++ b/compiler/format.go @@ -53,7 +53,7 @@ func defaultSpecifier(t Type) (string, error) { case ArrayKind: // Arrays are converted to char* via runtime helpers return "%s", nil - case MatrixKind, TableKind: + case TableKind: return "%s", nil case ArrayRangeKind: return "%s", nil @@ -466,6 +466,9 @@ func (c *Compiler) tryFormatArrayElements(tok token.Token, value string, mainSym } arr := mainSym.Type.(Array) + if arr.Rank != 1 { + return formattedMarker{}, false, nil + } elementType := arr.ElemType conversion := rune(spec.text[len(spec.text)-1]) if !arrayElementSupportsSpecifier(elementType, conversion) { @@ -597,17 +600,13 @@ func (c *Compiler) formatAsString(mainSym *Symbol, result *formattedMarker) bool result.toFree = append(result.toFree, strPtr) case ArrayKind: arrType := mainSym.Type.(Array) - if !hasConcreteArrayElemType(arrType.ElemType) { + 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 MatrixKind: - strPtr := c.matrixStrArg(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) diff --git a/compiler/mangle_test.go b/compiler/mangle_test.go index 4bdf60fe..fb81bf38 100644 --- a/compiler/mangle_test.go +++ b/compiler/mangle_test.go @@ -295,16 +295,16 @@ func TestMangle(t *testing.T) { modName: "arr", relPath: "", funcName: "len", - args: []Type{Array{ElemType: I64}}, + args: []Type{Array{ElemType: I64, Rank: 1}}, expected: "Pt_3arr_p_3len_f1_Array_t1_I64", }, { - name: "with matrix type", + name: "with rank-2 array type", modName: "matrix", relPath: "", funcName: "shape", - args: []Type{Matrix{ElemType: F64}}, - expected: "Pt_6matrix_p_5shape_f1_Matrix_t1_F64", + args: []Type{Array{ElemType: F64, Rank: 2}}, + expected: "Pt_6matrix_p_5shape_f1_Array_t1_Array_t1_F64", }, { name: "with table type", diff --git a/compiler/solver.go b/compiler/solver.go index 952952e1..dbe0fcc1 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. @@ -178,37 +180,45 @@ func (ts *TypeSolver) resolveBindingSlotType(name string, currentType, newType T } 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{} + } + leftElemType := leftArr.ElemType rightElemType := rightArr.ElemType if leftElemType.Kind() == EmptyKind { - return Array{ElemType: rightElemType} + return Array{ElemType: rightElemType, Rank: leftArr.Rank} } if rightElemType.Kind() == EmptyKind { - return Array{ElemType: leftElemType} + return Array{ElemType: leftElemType, Rank: leftArr.Rank} } if leftElemType.Kind() == UnresolvedKind { - return Array{ElemType: rightElemType} + return Array{ElemType: rightElemType, Rank: leftArr.Rank} } if rightElemType.Kind() == UnresolvedKind { - return Array{ElemType: 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 Array{ElemType: StrH{}} + return Array{ElemType: StrH{}, Rank: leftArr.Rank} } - return Array{ElemType: leftElemType} + return Array{ElemType: leftElemType, Rank: leftArr.Rank} } if (leftElemType.Kind() == IntKind && rightElemType.Kind() == FloatKind) || (leftElemType.Kind() == FloatKind && rightElemType.Kind() == IntKind) { - return Array{ElemType: Float{Width: 64}} + return Array{ElemType: Float{Width: 64}, Rank: leftArr.Rank} } ce := &token.CompileError{ @@ -247,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) } } @@ -414,7 +424,8 @@ 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. + // Only single-row literals act as collectors. Multiple source rows describe + // a statically rectangular array or table instead. if len(al.Headers) > 0 || len(al.Rows) != 1 { info.Ranges = nil info.CollectRanges = nil @@ -857,7 +868,7 @@ func (ts *TypeSolver) mergeCondRangesIntoValue(expr ast.Expression, exprTypes [] exprTypes[0] = iterType info.OutTypes[0] = iterType case ArrayRangeKind: - elemType := arrayAccessResultType(exprTypes[0].(ArrayRange).Array.ElemType) + elemType := arrayIndexResultType(exprTypes[0].(ArrayRange).Array) exprTypes[0] = elemType info.OutTypes[0] = elemType } @@ -939,60 +950,198 @@ func (ts *TypeSolver) TypeLetStatement(stmt *ast.LetStatement) { PutBulk(ts.Scopes, trueValues) } -// TypeArrayExpression classifies bracket literals as arrays, matrices, or -// tables. Element and column types are homogeneous, with I64-to-F64 promotion. +// TypeArrayExpression classifies bracket literals as rectangular arrays or +// tables. Array rank is inferred from row layout and nested array-valued cells. func (ts *TypeSolver) TypeArrayExpression(al *ast.ArrayLiteral) []Type { if len(al.Headers) == 0 && len(al.Rows) == 0 { - arr := Array{ElemType: Empty{}} - ts.ExprCache[key(ts.FuncNameMangled, al)] = &ExprInfo{OutTypes: []Type{arr}, ExprLen: 1, HasRanges: false} - return []Type{arr} + return ts.cacheArrayLiteralType(al, Array{ElemType: Empty{}, Rank: 1}, []uint64{0}) } - if len(al.Headers) == 0 && len(al.Rows) == 1 { + 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 _, row := range cellTypes { + for _, cellType := range row { + if cellType.Kind() == ArrayKind { + hasArrayCells = true + } else { + hasScalarCells = true + } + } + } + + 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) + } + 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 +} + +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 len(al.Rows) == 1 { elemType := 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 := []uint64{uint64(len(al.Rows[0]))} + 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: 1}, shape) + } + + numCols := len(al.Rows[0]) + if !ts.validateBracketLiteralShape(al, numCols) { + return ts.cacheInvalidBracketLiteral(al) + } + + colTypes := ts.initColTypes(numCols) + for rowIndex, row := range cellTypes { + for col, cellType := range row { + if ts.ExprCache[key(ts.FuncNameMangled, al.Rows[rowIndex][col])].HasRanges { + ts.Errors = append(ts.Errors, &token.CompileError{ + Token: al.Rows[rowIndex][col].Tok(), + Msg: "multidimensional array rows require statically sized cells", + }) } - elemType = ts.mergeColType(elemType, cellT, 0, al.Tok()) + colTypes[col] = ts.mergeColType(colTypes[col], cellType, col, al.Tok()) } - arr := Array{ElemType: elemType} + } - // 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 elemType, ok := commonArrayElemType(colTypes); ok { + shape := []uint64{uint64(len(al.Rows)), uint64(numCols)} + return ts.cacheArrayLiteralType(al, Array{ElemType: elemType, Rank: 2}, shape) } + return ts.cacheTableLiteralType(al, colTypes) +} + +func (ts *TypeSolver) typeStackedArrayLiteral(al *ast.ArrayLiteral, cellTypes [][]Type) []Type { + children := make([]ast.Expression, 0) + childTypes := make([]Array, 0) + for rowIndex, row := range cellTypes { + for colIndex, cellType := range row { + children = append(children, al.Rows[rowIndex][colIndex]) + 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), + }) + } + } + + var shape []uint64 + if shapeKnown && childShape != nil { + shape = append([]uint64{uint64(len(children))}, childShape...) + } + return ts.cacheArrayLiteralType(al, Array{ElemType: elemType, Rank: first.Rank + 1}, 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) { - types := []Type{Unresolved{}} - ts.ExprCache[key(ts.FuncNameMangled, al)] = &ExprInfo{OutTypes: types, ExprLen: 1} - return types + 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 { - continue + if ok { + colTypes[col] = ts.mergeColType(colTypes[col], cellType, col, al.Tok()) } - colTypes[col] = ts.mergeColType(colTypes[col], cellType, col, al.Tok()) - } - } - - if len(al.Headers) == 0 { - if elemType, ok := commonMatrixElemType(colTypes); ok { - matrix := Matrix{ElemType: elemType} - ts.ExprCache[key(ts.FuncNameMangled, al)] = &ExprInfo{OutTypes: []Type{matrix}, ExprLen: 1} - return []Type{matrix} } } + return ts.cacheTableLiteralType(al, colTypes) +} - columns := make([]TableColumn, numCols) +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) { @@ -1000,6 +1149,7 @@ func (ts *TypeSolver) TypeArrayExpression(al *ast.ArrayLiteral) []Type { } 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} @@ -1034,7 +1184,7 @@ func (ts *TypeSolver) validateBracketLiteralShape(al *ast.ArrayLiteral, numCols return valid } -func commonMatrixElemType(colTypes []Type) (Type, bool) { +func commonArrayElemType(colTypes []Type) (Type, bool) { elemType := Type(Unresolved{}) for _, colType := range colTypes { switch { @@ -1153,7 +1303,7 @@ func (ts *TypeSolver) TypeDotExpression(expr *ast.DotExpression) []Type { case Table: for _, column := range leftType.Columns { if column.Name == expr.Field { - info.OutTypes = []Type{Array{ElemType: column.ElemType}} + info.OutTypes = []Type{Array{ElemType: column.ElemType, Rank: 1}} info.HasRanges = leftInfo.HasRanges return info.OutTypes } @@ -1252,7 +1402,7 @@ func (ts *TypeSolver) mergeColType(cur Type, newT Type, colIdx int, tok token.To } // allowedCollectionElement reports whether a value can be stored in the -// runtime backing arrays used by arrays, matrices, and tables. +// runtime backing arrays used by arrays and tables. func allowedCollectionElement(t Type) bool { switch v := t.(type) { case Int: @@ -1355,18 +1505,14 @@ func (ts *TypeSolver) TypeArrayRangeExpression(ax *ast.ArrayRangeExpression, isR if !ok { return info.OutTypes } - elemType := arrType.ElemType - if !hasConcreteArrayElemType(elemType) { + if !hasConcreteArrayElemType(arrType.ElemType) { ts.Errors = append(ts.Errors, &token.CompileError{ Token: ax.Tok(), Msg: "cannot index an empty array without an element type", }) return info.OutTypes } - // 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 @@ -1399,6 +1545,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{ @@ -1410,15 +1563,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 } @@ -1572,7 +1725,7 @@ func (ts *TypeSolver) typeInfixSlot(expr *ast.InfixExpression, leftType, rightTy if leftType.Kind() == ArrayKind { return leftType, CondArray } - return Array{ElemType: leftType}, CondArray + return Array{ElemType: leftType, Rank: rightType.(Array).Rank}, CondArray } // Handle any expression involving arrays @@ -1833,6 +1986,13 @@ 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.ElemType @@ -1841,7 +2001,7 @@ func (ts *TypeSolver) typeArrayArrayInfix(leftArr, rightArr Array, op string, to resultElem := ts.resolveArrayElemTypes(leftElem, rightElem, op, tok) // Return array type with dynamic length (determined at runtime) - return Array{ElemType: resultElem} + return Array{ElemType: resultElem, Rank: leftArr.Rank} } // resolveArrayElemTypes handles empty/unresolved element types and type inference for array operations. @@ -1919,7 +2079,7 @@ func (ts *TypeSolver) typeArrayScalarInfix(left, right Type, leftIsArr bool, op elemType := ts.TypeInfixOp(leftType, rightType, op, tok) // Return array with computed element type - return Array{ElemType: elemType} + return Array{ElemType: elemType, Rank: arrType.Rank} } func (ts *TypeSolver) TypeInfixOp(left, right Type, op string, tok token.Token) Type { @@ -2004,7 +2164,7 @@ func (ts *TypeSolver) TypePrefixExpression(expr *ast.PrefixExpression) (types [] resultType := fn(ts.ScriptCompiler.Compiler, opSym, false).Type if isArrayExpr { - finalType := Array{ElemType: resultType} + finalType := Array{ElemType: resultType, Rank: origArrayType.Rank} types = append(types, finalType) continue } diff --git a/compiler/solver_test.go b/compiler/solver_test.go index 4415f8a5..56c0f612 100644 --- a/compiler/solver_test.go +++ b/compiler/solver_test.go @@ -306,6 +306,11 @@ func TestCollectionTypeErrors(t *testing.T) { script: "table = [\n :Name Score\n \"Ada\"\n]", expectError: "bracket literal row 1 has 1 cells, expected 2", }, + { + 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]", @@ -329,6 +334,31 @@ func TestCollectionTypeErrors(t *testing.T) { ])`, 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 { diff --git a/compiler/table.go b/compiler/table.go index 6fd22484..8a817daf 100644 --- a/compiler/table.go +++ b/compiler/table.go @@ -5,42 +5,6 @@ import ( "tinygo.org/x/go-llvm" ) -// Values must match PtElementKind in runtime/array.h. -var runtimeElementKinds = map[Kind]uint64{ - IntKind: 0, - FloatKind: 1, - StrKind: 2, -} - -func (c *Compiler) compileMatrixLiteral(lit *ast.ArrayLiteral, matrixType Matrix) *Symbol { - rows := len(lit.Rows) - cols := 0 - if rows > 0 { - cols = len(lit.Rows[0]) - } - - data := c.CreateArrayForType(matrixType.ElemType, c.ConstI64(uint64(rows*cols))) - for rowIndex, row := range lit.Rows { - for colIndex, cell := range row { - index := c.ConstI64(uint64(rowIndex*cols + colIndex)) - c.compileArrayLiteralCell(cell, matrixType.ElemType, func(cellSlot *Symbol) bool { - return c.setArrayCellSlot(data, index, cellSlot, matrixType.ElemType) - }) - } - } - - data = c.builder.CreateBitCast(data, llvm.PointerType(c.Context.Int8Type(), 0), "matrix_data") - return &Symbol{ - Type: matrixType, - Val: c.createMatrixValue( - data, - c.ConstI64(uint64(rows)), - c.ConstI64(uint64(cols)), - matrixType, - ), - } -} - func (c *Compiler) compileTableLiteral(lit *ast.ArrayLiteral, tableType Table) *Symbol { if len(lit.Rows) == 0 { return c.makeZeroValue(tableType) @@ -73,24 +37,6 @@ func (c *Compiler) compileTableLiteral(lit *ast.ArrayLiteral, tableType Table) * } } -func (c *Compiler) createMatrixValue(data, rows, cols llvm.Value, matrixType Matrix) llvm.Value { - value := llvm.Undef(c.mapToLLVMType(matrixType)) - value = c.builder.CreateInsertValue(value, data, 0, "matrix_data_value") - value = c.builder.CreateInsertValue(value, rows, 1, "matrix_rows") - return c.builder.CreateInsertValue(value, cols, 2, "matrix_cols") -} - -func (c *Compiler) matrixArrayValue(matrix llvm.Value) llvm.Value { - return c.builder.CreateExtractValue(matrix, 0, "matrix_data") -} - -func (c *Compiler) copyMatrixValue(value llvm.Value, matrixType Matrix) llvm.Value { - data := c.copyArray(c.matrixArrayValue(value), matrixType.ElemType) - rows := c.builder.CreateExtractValue(value, 1, "matrix_rows") - cols := c.builder.CreateExtractValue(value, 2, "matrix_cols") - return c.createMatrixValue(data, rows, cols, matrixType) -} - 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") @@ -113,16 +59,6 @@ func (c *Compiler) copyTableValue(value llvm.Value, tableType Table) llvm.Value return c.createTableValue(rows, columns, tableType) } -func (c *Compiler) matrixStrArg(s *Symbol) llvm.Value { - matrixType := s.Type.(Matrix) - data := c.matrixArrayValue(s.Val) - rows := c.builder.CreateExtractValue(s.Val, 1, "matrix_rows") - cols := c.builder.CreateExtractValue(s.Val, 2, "matrix_cols") - kind := llvm.ConstInt(c.Context.Int32Type(), runtimeElementKinds[matrixType.ElemType.Kind()], false) - fnType, fn := c.GetCFunc(MATRIX_STR) - return c.builder.CreateCall(fnType, fn, []llvm.Value{data, kind, rows, cols}, "matrix_str") -} - func (c *Compiler) tableStrArg(s *Symbol) llvm.Value { tableType := s.Type.(Table) rows := c.builder.CreateExtractValue(s.Val, 0, "table_rows") diff --git a/compiler/types.go b/compiler/types.go index 7b9ded02..a03cda27 100644 --- a/compiler/types.go +++ b/compiler/types.go @@ -22,7 +22,6 @@ const ( RangeKind FuncKind ArrayKind - MatrixKind TableKind ArrayRangeKind StructKind @@ -63,7 +62,6 @@ var PrimitiveTypeNames = []string{ // Sorted by descending length in init() for correct prefix matching. var CompoundTypePrefixes = []string{ "ArrayRange", - "Matrix", "Table", "Array", "Range", @@ -277,9 +275,7 @@ func IsFullyResolvedType(t Type) bool { case Range: return IsFullyResolvedType(tt.Iter) case Array: - return tt.ElemType != nil && IsFullyResolvedType(tt.ElemType) - case Matrix: - return tt.ElemType != nil && IsFullyResolvedType(tt.ElemType) + return tt.Rank > 0 && tt.ElemType != nil && IsFullyResolvedType(tt.ElemType) case Table: for _, column := range tt.Columns { if column.ElemType == nil || !IsFullyResolvedType(column.ElemType) { @@ -324,49 +320,48 @@ func isUntypedEmptyCollection(t Type) bool { } } -// Array is a homogeneous, one-dimensional sequence. +// Array is a homogeneous rectangular value. Rank is part of the type while +// dimension lengths are runtime properties. type Array struct { ElemType Type + Rank int } func (a Array) String() string { - if a.ElemType == nil { + if a.ElemType == nil || a.Rank < 1 { return "[]" } - return "[" + a.ElemType.String() + "]" + result := a.ElemType.String() + for range a.Rank { + result = "[" + result + "]" + } + return result } func (a Array) Kind() Kind { return ArrayKind } func (a Array) Mangle() string { - return "Array" + SEP + T + "1" + SEP + a.ElemType.Mangle() + result := a.ElemType.Mangle() + for range a.Rank { + result = "Array" + SEP + T + "1" + SEP + result + } + return result } func (a Array) Key() Type { - return Array{ElemType: a.ElemType.Key()} + return Array{ElemType: a.ElemType.Key(), Rank: a.Rank} } func hasConcreteArrayElemType(elem Type) bool { return elem != nil && elem.Kind() != EmptyKind && elem.Kind() != UnresolvedKind } -// Matrix is a homogeneous, two-dimensional value. Its dimensions are runtime -// properties and therefore do not participate in type identity. -type Matrix struct { - ElemType Type -} - -func (m Matrix) String() string { - if m.ElemType == nil { - return "Matrix[]" +func arrayIndexResultType(array Array) Type { + if array.Rank > 1 { + return Array{ElemType: array.ElemType, Rank: array.Rank - 1} } - return "Matrix[" + m.ElemType.String() + "]" -} - -func (m Matrix) Kind() Kind { return MatrixKind } -func (m Matrix) Mangle() string { - return "Matrix" + SEP + T + "1" + SEP + m.ElemType.Mangle() -} -func (m Matrix) Key() Type { - return Matrix{ElemType: m.ElemType.Key()} + if array.ElemType.Kind() == StrKind { + return StrH{} + } + return array.ElemType } // TableColumn pairs an optional source-level name with a homogeneous column @@ -605,9 +600,6 @@ func CanRefineType(oldType, newType Type) bool { case Array: newArr, ok := newType.(Array) return ok && canRefineArray(old, newArr) - case Matrix: - newMatrix, ok := newType.(Matrix) - return ok && CanRefineType(old.ElemType, newMatrix.ElemType) case Table: newTable, ok := newType.(Table) return ok && canRefineTable(old, newTable) @@ -638,9 +630,14 @@ func bindingSlotCompatible(oldType, newType Type) bool { } oldArray, oldIsArray := oldType.(Array) newArray, newIsArray := newType.(Array) - if oldIsArray && newIsArray && - (oldArray.ElemType.Kind() == EmptyKind || newArray.ElemType.Kind() == EmptyKind) { - return true + if oldIsArray && newIsArray { + // [] resets an existing array without changing its established type or rank. + if newArray.ElemType.Kind() == EmptyKind { + return true + } + if oldArray.ElemType.Kind() == EmptyKind { + return oldArray.Rank == newArray.Rank + } } return CanRefineType(oldType, newType) } @@ -678,7 +675,7 @@ func canRefineTypes(oldTypes, newTypes []Type) bool { } func canRefineArray(oldArr, newArr Array) bool { - return CanRefineType(oldArr.ElemType, newArr.ElemType) + return oldArr.Rank == newArr.Rank && CanRefineType(oldArr.ElemType, newArr.ElemType) } func canRefineTable(oldTable, newTable Table) bool { @@ -728,8 +725,6 @@ func typeComparer(k Kind) func(a, b Type) bool { return eqFunc case ArrayKind: return eqArray - case MatrixKind: - return eqMatrix case TableKind: return eqTable case ArrayRangeKind: @@ -788,13 +783,7 @@ func eqFunc(a, b Type) bool { func eqArray(a, b Type) bool { aa := a.(Array) ba := b.(Array) - return TypeEqual(aa.ElemType, ba.ElemType) -} - -func eqMatrix(a, b Type) bool { - am := a.(Matrix) - bm := b.(Matrix) - return TypeEqual(am.ElemType, bm.ElemType) + return aa.Rank == ba.Rank && TypeEqual(aa.ElemType, ba.ElemType) } func eqTable(a, b Type) bool { @@ -845,6 +834,6 @@ var reservedTypeNames = map[string]struct{}{ "I1": {}, "I8": {}, "I16": {}, "I32": {}, "I64": {}, "U8": {}, "U16": {}, "U32": {}, "U64": {}, "F32": {}, "F64": {}, - "Ptr": {}, "Range": {}, "Array": {}, "Matrix": {}, "Table": {}, "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..78b14fdb --- /dev/null +++ b/docs/Pluto Array Semantics.md @@ -0,0 +1,88 @@ +# 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. +- `[1 2 3]` is a rank-1 `[I64]` value. +- Multiple homogeneous scalar rows infer a rank-2 array. +- Array-valued cells of one rank and shape stack into an array one rank higher. +- Multiple scalar rows with homogeneous but different column types infer an + unnamed table. A header always produces a table. + +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 + ] +] +``` + +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 a rank-1 literal remain collectors and may determine its runtime +length. Array values are nested rather than flattened when used as cells. + +## 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]] +``` + +Shape-preserving collection across multiple indexed dimensions is not yet +defined. Chained range drivers in one collector follow the ordinary flattened +range-domain semantics; adding collector brackets does not select another axis. + +Array-scalar operations preserve shape. Array-array element-wise operations +require equal rank and equal inner dimensions, then zip the outer dimension to +the shorter input. Concatenation joins the outer dimension and requires every +inner dimension to match. Literal-construction mismatches are compile errors; +operation 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. diff --git a/docs/Pluto C ABI Spec.md b/docs/Pluto C ABI Spec.md index 9ba1e3bf..69054baa 100644 --- a/docs/Pluto C ABI Spec.md +++ b/docs/Pluto C ABI Spec.md @@ -204,7 +204,7 @@ Built-in compound types use the `_tN_` pattern: | Pointer | `Ptr_t1_[Elem]` | `Ptr_t1_I64` | | Range | `Range_t1_[Iter]` | `Range_t1_I64` | | Array | `Array_t1_[Elem]` | `Array_t1_I64` | -| Matrix | `Matrix_t1_[Elem]` | `Matrix_t1_F64` | +| 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` | @@ -215,6 +215,11 @@ 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 Memory Model.md b/docs/Pluto Memory Model.md index 1439d2f2..3c910856 100644 --- a/docs/Pluto Memory Model.md +++ b/docs/Pluto Memory Model.md @@ -110,9 +110,11 @@ 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. --- @@ -125,7 +127,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. @@ -294,4 +297,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 38f5267d..77086663 100644 --- a/docs/Pluto Range Semantics.md +++ b/docs/Pluto Range Semantics.md @@ -105,10 +105,11 @@ per iteration as an if-else. ## One-Dimensional Array Literals -A headerless bracket literal with at most one data row materializes an array at -the point where it appears. Rectangular literals with multiple rows are -classified as matrices or tables; they do not use the collector behavior in -this section. +A headerless bracket literal with one scalar row materializes a rank-1 array at +the point where it appears. Rectangular literals with multiple scalar rows and +literals containing array-valued cells infer higher-rank arrays; they do not +use the collector behavior in this section. Heterogeneous rectangular literals +may infer tables instead. The collector materializes over: diff --git a/parser/scriptparser_test.go b/parser/scriptparser_test.go index 2d146b60..9295f0a2 100644 --- a/parser/scriptparser_test.go +++ b/parser/scriptparser_test.go @@ -1347,7 +1347,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") @@ -1360,7 +1360,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 006832a7..23c186d6 100644 --- a/runtime/array.c +++ b/runtime/array.c @@ -349,19 +349,56 @@ static int strbuf_array_cell(StrBuf* sb, const void* values, int32_t kind, size_ } } -const char* matrix_str(const void* values, int32_t kind, size_t rows, size_t cols) { - StrBuf sb = {malloc(256), 0, 256}; - if (!sb.data) return NULL; - if (strbuf_printf(&sb, "[") < 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, values, kind, row * cols + col) < 0) goto fail; +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) { + for (size_t row = 0; row < dimensions[0]; ++row) { + if (strbuf_indent(sb, depth + 1) < 0) return -1; + if (dimensions[1] == 0) { + if (strbuf_printf(sb, "[]") < 0) return -1; + continue; + } + for (size_t col = 0; col < dimensions[1]; ++col) { + if (col > 0 && strbuf_printf(sb, " ") < 0) return -1; + if (strbuf_array_cell(sb, values, kind, (*value_index)++) < 0) return -1; + } + } + if (dimensions[0] > 0 && strbuf_indent(sb, depth) < 0) return -1; + } else { + for (size_t i = 0; i < dimensions[0]; ++i) { + if (strbuf_indent(sb, depth + 1) < 0) return -1; + if (strbuf_array_nd(sb, values, kind, rank - 1, dimensions + 1, + depth + 1, value_index) < 0) return -1; + } + if (dimensions[0] > 0 && strbuf_indent(sb, depth) < 0) return -1; } - if (rows > 0 && strbuf_printf(&sb, "\n") < 0) goto fail; - if (strbuf_printf(&sb, "]") < 0) goto fail; + + 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 (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: @@ -369,6 +406,16 @@ const char* matrix_str(const void* values, int32_t kind, size_t rows, size_t col return NULL; } +/* Aborts the process; called from generated code when a runtime shape check + * fails (stacking, concatenation, or zipping arrays with unequal inner + * dimensions). 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}; diff --git a/runtime/array.h b/runtime/array.h index 63cf4878..67f48261 100644 --- a/runtime/array.h +++ b/runtime/array.h @@ -80,7 +80,8 @@ typedef enum { PT_ELEM_STR } PtElementKind; -const char* matrix_str(const void* values, int32_t kind, size_t rows, size_t cols); +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); diff --git a/tests/array/array.exp b/tests/array/array.exp index 098b3083..954101c3 100644 --- a/tests/array/array.exp +++ b/tests/array/array.exp @@ -19,6 +19,45 @@ 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 +] +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 diff --git a/tests/array/array.spt b/tests/array/array.spt index d8b6386a..3e774007 100644 --- a/tests/array/array.spt +++ b/tests/array/array.spt @@ -51,6 +51,46 @@ matrix = ] "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" + +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 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" From ed84d1eeab46b8b0312de1dcd8ac5bbc8f910dd2 Mon Sep 17 00:00:00 2001 From: Tejas Date: Thu, 16 Jul 2026 12:44:47 +0530 Subject: [PATCH 09/33] docs: define deferred multidimensional range semantics Record grouped rank-N selection and nested range construction as post-PIR work. Distinguish shared statement gates from local value-position && and align the PIR vocabulary with that scope. --- docs/Pluto Array Semantics.md | 64 +++++++++++++++++++++-- docs/Pluto Conditional Value Semantics.md | 13 +++-- docs/Pluto IR Plan.md | 25 +++++---- docs/Pluto Memory Model.md | 54 +++++++++++++++++-- docs/Pluto Range Semantics.md | 53 ++++++++++++++----- 5 files changed, 178 insertions(+), 31 deletions(-) diff --git a/docs/Pluto Array Semantics.md b/docs/Pluto Array Semantics.md index 78b14fdb..ffe26612 100644 --- a/docs/Pluto Array Semantics.md +++ b/docs/Pluto Array Semantics.md @@ -73,9 +73,67 @@ i = 0:2 selected = [matrix[i]] ``` -Shape-preserving collection across multiple indexed dimensions is not yet -defined. Chained range drivers in one collector follow the ordinary flattened -range-domain semantics; adding collector brackets does not select another axis. +### Planned 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. + +### Planned nested range construction + +Grouped indexing selects from an existing array. Computed rectangular values +need a separate construction rule. The first planned case is: + +```pluto +i = 0:3 +j = 0:3 +submatrix = [i && [matrix[i][j]]] +``` + +Here `&&` is in value position. It binds the outer `i` yield for the local +right-hand value; the inner collector owns `j` and produces one row, and the +outer collector stacks the rows. It is not a statement gate and does not alter +sibling RHS expressions. Bare ranges on the left of value-position `&&` are +not implemented yet; this construction is deferred until PIR can represent +the two nested domains and their collector ownership directly. Array-scalar operations preserve shape. Array-array element-wise operations require equal rank and equal inner dimensions, then zip the outer dimension to diff --git a/docs/Pluto Conditional Value Semantics.md b/docs/Pluto Conditional Value Semantics.md index 2ee66b08..9af81712 100644 --- a/docs/Pluto Conditional Value Semantics.md +++ b/docs/Pluto Conditional Value Semantics.md @@ -18,6 +18,13 @@ 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. + ## Statement gate: keep-old A condition before the value gates the assignment. If it fails, no assignment @@ -95,7 +102,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: @@ -310,8 +317,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..ddda1e5d 100644 --- a/docs/Pluto IR Plan.md +++ b/docs/Pluto IR Plan.md @@ -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 @@ -92,7 +92,7 @@ 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 versioned ranges, 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 | @@ -111,7 +111,8 @@ PIR should use structured control plus a small Pluto-specific vocabulary: | `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 | +| `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 | @@ -257,7 +258,7 @@ and releases the replaced carry only after the iteration update is safe, and 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 @@ -299,7 +300,7 @@ 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 @@ -397,7 +398,7 @@ statement x execute fallback primary - gate + value-and condition eval(a > 0) then eval(data[i]) on-oob skip @@ -455,7 +456,7 @@ The PIR validator should reject a plan unless: 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. @@ -471,7 +472,7 @@ The PIR validator should reject a plan unless: decision. 17. 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 @@ -507,7 +508,7 @@ its current lowering without reading LLVM helper code. ### Phase 2: Conditional values, final set, 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-set policies. - 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. @@ -642,6 +643,12 @@ The statement plan can grow without becoming a machine IR: - 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 - gated prints become a statement plan whose final action prints yielded outcomes instead of setting targets - test contexts can become explicit statement inputs/effects diff --git a/docs/Pluto Memory Model.md b/docs/Pluto Memory Model.md index 3c910856..86878636 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] @@ -116,6 +117,34 @@ access as `[arr[i]]` materializes the selected values. **Difference:** Pluto separates range views from explicit collection, while Julia copies a range selection unless a view is requested explicitly. +### 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 lazy selection view, not an eagerly allocated slice. +Scalar indices drop their axes; range indices and omitted trailing axes retain +theirs. The selection rank is therefore the source rank minus the number of +scalar indices. When at least one range is present, the leftmost range drives +iteration and each yield has one less rank. An ordinary `[]` collector stacks +those yields, restoring the selection rank. + +Grouped indexing preserves selected axes. Chained range indexing keeps the +existing flat range-domain behavior. Mixed grouped and chained indexing should +initially be rejected rather than assigned an implicit shape rule. This work is +deferred until PIR models range ownership and collector boundaries explicitly; +see [Pluto Array Semantics](Pluto%20Array%20Semantics.md). + --- ### 5. vs. Zig @@ -147,7 +176,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 @@ -174,6 +205,21 @@ 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 value-position +meaning; support for a bare range on its left is deferred until PIR represents +the nested range and collector scopes. + ### Conditional Assignment Guard expressions with conditions: diff --git a/docs/Pluto Range Semantics.md b/docs/Pluto Range Semantics.md index 77086663..b126e33e 100644 --- a/docs/Pluto Range Semantics.md +++ b/docs/Pluto Range Semantics.md @@ -97,11 +97,13 @@ 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. +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 @@ -236,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: @@ -253,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 @@ -274,6 +282,27 @@ 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. + +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. @@ -318,11 +347,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 From 9e316805f40763647725b41ea0d5c8c170be55b0 Mon Sep 17 00:00:00 2001 From: Tejas Date: Thu, 16 Jul 2026 13:52:48 +0530 Subject: [PATCH 10/33] docs: clarify range-bound collector placement Specify that value-position range && binds a domain without collecting it. Document matching row fallbacks and preserve explicit stacking and rectangular shape checks. --- docs/Pluto Array Semantics.md | 29 +++++++++++++++++++++++++++++ docs/Pluto IR Plan.md | 3 ++- docs/Pluto Memory Model.md | 4 +++- docs/Pluto Range Semantics.md | 14 ++++++++++++++ 4 files changed, 48 insertions(+), 2 deletions(-) diff --git a/docs/Pluto Array Semantics.md b/docs/Pluto Array Semantics.md index ffe26612..2ad65493 100644 --- a/docs/Pluto Array Semantics.md +++ b/docs/Pluto Array Semantics.md @@ -135,6 +135,35 @@ sibling RHS expressions. Bare ranges on the left of value-position `&&` are not implemented yet; this construction is deferred until PIR can represent the two nested domains and their collector ownership directly. +`&&` binds a domain; it does not collect or flatten the values yielded by that +domain. Collector placement therefore determines the resulting rank: + +```pluto +[j && -1] # one rank-1 row containing one -1 per j +j && [-1] # a stream containing one singleton array per j +[j && [-1]] # rank 2, with shape len(j) x 1 +``` + +A row-level fallback used inside the outer collector must produce a row with +the same runtime shape as every other row that is actually stacked. Reusing +the `j` domain makes that shape explicit: + +```pluto +result = [ + i && ( + i < 2 && [matrix[i][j]] + || [j && -1] + ) +] +``` + +Both alternatives produce a rank-1 row of length `len(j)`. By contrast, +`i && [matrix[i][j]] || [-1]` has no reachable fallback: a bare range yields +each domain point and the inner collector resolves to an array. If a genuinely +failable row condition made `[-1]` reachable, mixing its singleton shape with +longer rows would fail the outer collector's rectangular shape check. Pluto +does not pad, truncate, or flatten mismatched rows. + Array-scalar operations preserve shape. Array-array element-wise operations require equal rank and equal inner dimensions, then zip the outer dimension to the shorter input. Concatenation joins the outer dimension and requires every diff --git a/docs/Pluto IR Plan.md b/docs/Pluto IR Plan.md index ddda1e5d..aa8c2709 100644 --- a/docs/Pluto IR Plan.md +++ b/docs/Pluto IR Plan.md @@ -648,7 +648,8 @@ The statement plan can grow without becoming a machine IR: 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 + value and must not become a statement gate or implicit collector — only an + explicit `[]` closes the bound domain into an array - gated prints become a statement plan whose final action prints yielded outcomes instead of setting targets - test contexts can become explicit statement inputs/effects diff --git a/docs/Pluto Memory Model.md b/docs/Pluto Memory Model.md index 86878636..548ac318 100644 --- a/docs/Pluto Memory Model.md +++ b/docs/Pluto Memory Model.md @@ -218,7 +218,9 @@ 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 value-position meaning; support for a bare range on its left is deferred until PIR represents -the nested range and collector scopes. +the nested range and collector scopes. The operator only binds that domain; +materialization remains explicit, so `[j && -1]` forms one row while +`j && [-1]` yields one singleton array per `j`. ### Conditional Assignment diff --git a/docs/Pluto Range Semantics.md b/docs/Pluto Range Semantics.md index b126e33e..d23d3c04 100644 --- a/docs/Pluto Range Semantics.md +++ b/docs/Pluto Range Semantics.md @@ -299,6 +299,20 @@ 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 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. + 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. From 03b120559c02019a774b86bf1ce6d10936c60809 Mon Sep 17 00:00:00 2001 From: Tejas Date: Thu, 16 Jul 2026 14:09:17 +0530 Subject: [PATCH 11/33] docs: define nested range construction shapes Document explicit range binders for multidimensional construction, constant collection with [i && 1], and zero-filled array children for failed value-position conditions. Require PIR to prove skipped child shape or use an explicit compatible fallback. --- docs/Pluto Array Semantics.md | 33 +++++++++++++++++++++++ docs/Pluto Conditional Value Semantics.md | 17 ++++++++++++ docs/Pluto IR Plan.md | 4 +++ docs/Pluto Memory Model.md | 5 +++- docs/Pluto Range Semantics.md | 22 +++++++++++++++ 5 files changed, 80 insertions(+), 1 deletion(-) diff --git a/docs/Pluto Array Semantics.md b/docs/Pluto Array Semantics.md index 2ad65493..b7193ede 100644 --- a/docs/Pluto Array Semantics.md +++ b/docs/Pluto Array Semantics.md @@ -135,6 +135,21 @@ sibling RHS expressions. Bare ranges on the left of value-position `&&` are not implemented yet; this construction is deferred until PIR can represent the two nested domains and their collector ownership directly. +The binder distinguishes flat cartesian collection from nested dimensions: + +```pluto +flat = [F(i, j)] # rank 1 over the i x j domain +rows = [i && [F(i, j)]] # rank 2: i rows, j cells +cols = [j && [F(i, j)]] # rank 2: j rows, i cells +cube = [i && [j && [F(i, j, k)]]] # rank 3: i, then j, then k +ones = [i && 1] # rank 1: one 1 per i +``` + +A bare range binder always yields each domain point; its numeric value is not a +truth test, so `i = 0` still produces a `1` in the last example. Without an +explicit binder, the nearest collector owns every unbound range mentioned in +its cells. Ordinary arithmetic does not establish a nested domain. + `&&` binds a domain; it does not collect or flatten the values yielded by that domain. Collector placement therefore determines the resulting rank: @@ -164,6 +179,24 @@ failable row condition made `[-1]` reachable, mixing its singleton shape with longer rows would fail the outer collector's rectangular shape check. Pluto does not pad, truncate, or flatten mismatched rows. +A failed array-valued cell preserves the outer domain by contributing a +zero-filled child with the expected inner shape: + +```pluto +zeroRows = [i > 0 && [F(i, j)]] +minusRows = [i > 0 && [F(i, j)] || [j && -1]] +``` + +`zeroRows` contains a zero row for every `i` that fails `i > 0`. +`minusRows` uses the explicit row fallback instead. The child shape must be +known statically or derivable from its bound domains, such as `j` here. If PIR +cannot establish the shape without evaluating the skipped value, the compiler +rejects the implicit zero-fill and requires an explicit shape-bearing fallback. +Use `|| [j && 0]` to state the default zero row, or another compatible value +such as `|| [j && -1]`. +Only a statement gate removes a rejected iteration from the shared statement +domain; value-position `&&` never does. + Array-scalar operations preserve shape. Array-array element-wise operations require equal rank and equal inner dimensions, then zip the outer dimension to the shorter input. Concatenation joins the outer dimension and requires every diff --git a/docs/Pluto Conditional Value Semantics.md b/docs/Pluto Conditional Value Semantics.md index 9af81712..997808ae 100644 --- a/docs/Pluto Conditional Value Semantics.md +++ b/docs/Pluto Conditional Value Semantics.md @@ -201,6 +201,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 diff --git a/docs/Pluto IR Plan.md b/docs/Pluto IR Plan.md index aa8c2709..4ff0c327 100644 --- a/docs/Pluto IR Plan.md +++ b/docs/Pluto IR Plan.md @@ -650,6 +650,10 @@ The statement plan can grow without becoming a machine IR: 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 - test contexts can become explicit statement inputs/effects diff --git a/docs/Pluto Memory Model.md b/docs/Pluto Memory Model.md index 548ac318..a53373f6 100644 --- a/docs/Pluto Memory Model.md +++ b/docs/Pluto Memory Model.md @@ -220,7 +220,10 @@ The planned `[i && [matrix[i][j]]]` construction uses this local value-position meaning; support for a bare range on its left is deferred until PIR represents the nested range and collector scopes. The operator only binds that domain; materialization remains explicit, so `[j && -1]` forms one row while -`j && [-1]` yields one singleton array per `j`. +`j && [-1]` yields one singleton array per `j`. The same rule makes +`[i && 1]` an array containing one `1` per `i`. A failed array-valued cell +contributes a zero-filled child of its expected shape unless `||` supplies an +explicit shape-compatible fallback. ### Conditional Assignment diff --git a/docs/Pluto Range Semantics.md b/docs/Pluto Range Semantics.md index d23d3c04..346824f8 100644 --- a/docs/Pluto Range Semantics.md +++ b/docs/Pluto Range Semantics.md @@ -299,6 +299,19 @@ 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: @@ -313,6 +326,15 @@ 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. +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. From 25ebca4d1e8bc49ca7230d7602b98bba0bf27c19 Mon Sep 17 00:00:00 2001 From: Tejas Date: Thu, 16 Jul 2026 15:09:41 +0530 Subject: [PATCH 12/33] fix(compiler): close collection shape edge cases Preserve inner dimensions when higher-rank OOB cells zero-fill, and distinguish rank-1 empty resets from shaped Empty values. Reject range-driven table cells before code generation and align the empty-table and empty-operand documentation with runtime behavior. --- README.md | 17 +++++++++-------- compiler/array.go | 17 ++++++++++++++++- compiler/array_nd.go | 30 ++++++++++++++++++++++-------- compiler/compiler.go | 13 +++++++++++-- compiler/solver.go | 6 ++++++ compiler/solver_test.go | 10 ++++++++++ compiler/types.go | 5 +++-- docs/Pluto Array Semantics.md | 11 +++++++---- tests/array/array.exp | 15 +++++++++++++++ tests/array/array.spt | 11 +++++++++++ 10 files changed, 110 insertions(+), 25 deletions(-) diff --git a/README.md b/README.md index 755e7a76..46971b13 100644 --- a/README.md +++ b/README.md @@ -234,14 +234,15 @@ dimension, so `cube[1][0]` is `[5 6]`. A header row always produces a columnar table; without headers, homogeneous columns with different element types infer an unnamed table. A header must contain at least one column name, but it may have no data rows. Such a literal -is an empty table whose columns are untyped empty arrays and print as `[]`. -Headerless literals start directly with their first data row. The conventional -header spelling is `:Name Score`. Indentation and spacing inside brackets are -not semantic; examples use four spaces. Named columns are arrays, so -`scores.Score` returns `[10 12]`. Header-only tables can be printed and -projected, but cannot be passed to functions until their column types are -established. See [Pluto Array Semantics](docs/Pluto%20Array%20Semantics.md) for -the complete inference and shape rules. +is an empty table whose printed value retains the header; each projected column +is an untyped empty array and prints as `[]`. Headerless literals start directly +with their first data row. The conventional header spelling is `:Name Score`. +Indentation and spacing inside brackets are not semantic; examples use four +spaces. Named columns are arrays, so `scores.Score` returns `[10 12]`. +Header-only tables can be printed and projected, but cannot be passed to +functions until their column types are established. See +[Pluto Array Semantics](docs/Pluto%20Array%20Semantics.md) for the complete +inference and shape rules. 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. diff --git a/compiler/array.go b/compiler/array.go index c41e0ba5..6264777b 100644 --- a/compiler/array.go +++ b/compiler/array.go @@ -993,6 +993,18 @@ func (c *Compiler) compileArraySubarray(array *Symbol, index llvm.Value) *Symbol 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") @@ -1004,7 +1016,7 @@ func (c *Compiler) checkedArraySubarray(array *Symbol, index llvm.Value, inBound c.builder.CreateBr(contBlock) c.builder.SetInsertPointAtEnd(missBlock) - zero := c.makeZeroValue(resultType) + zero := c.zeroArraySubarray(array) c.createStore(zero.Val, resultSlot, resultType) c.builder.CreateBr(contBlock) @@ -1132,6 +1144,9 @@ func (c *Compiler) compileArrayRangeRanges(info *ExprInfo, dest []*ast.Identifie 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) diff --git a/compiler/array_nd.go b/compiler/array_nd.go index 973a708e..440e9dc0 100644 --- a/compiler/array_nd.go +++ b/compiler/array_nd.go @@ -39,7 +39,7 @@ func (c *Compiler) compileStackedArrayLiteral(lit *ast.ArrayLiteral, arrayType A childSymbols := make([]*Symbol, 0, len(children)) for _, child := range children { - compiled := c.compileExpression(child, nil) + compiled := c.compileArrayValuedCell(child) if len(compiled) != 1 { c.Errors = append(c.Errors, &token.CompileError{ Token: child.Tok(), @@ -67,18 +67,30 @@ func (c *Compiler) compileStackedArrayLiteral(lit *ast.ArrayLiteral, arrayType A 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, child.Type.(Array).ElemType, arrayType.ElemType, offset, applyOffset) + 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) + }) + return compiled +} + func (c *Compiler) compileStackedArrayCollector(lit *ast.ArrayLiteral, info *ExprInfo, arrayType Array) *Symbol { if !hasConcreteArrayElemType(arrayType.ElemType) { return c.makeZeroValue(arrayType) @@ -119,7 +131,7 @@ func (c *Compiler) appendStackedArrayChild( childDimSlots []llvm.Value, child ast.Expression, ) { - compiled := c.compileExpression(child, nil) + compiled := c.compileArrayValuedCell(child) if len(compiled) != 1 { c.Errors = append(c.Errors, &token.CompileError{ Token: child.Tok(), @@ -141,11 +153,13 @@ func (c *Compiler) appendStackedArrayChild( c.createStore(c.builder.CreateSelect(firstChild, dimension, stored, "stacked_array_dimension_value"), childDimSlots[i], I64) } - 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, &Symbol{Val: value, Type: childType.ElemType}, false, acc.ElemType) - }) + if 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, &Symbol{Val: value, Type: childType.ElemType}, false, acc.ElemType) + }) + } c.createStore(c.builder.CreateAdd(outerLen, c.ConstI64(1), "stacked_array_outer_len_next"), outerLenSlot, I64) c.freeTemporary(child, []*Symbol{childSymbol}) diff --git a/compiler/compiler.go b/compiler/compiler.go index eb358832..63cc8ab5 100644 --- a/compiler/compiler.go +++ b/compiler/compiler.go @@ -914,7 +914,16 @@ 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 { + 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, @@ -940,7 +949,7 @@ func (c *Compiler) storeSymbolToSlot(dst *Symbol, src *Symbol, target Type, load source := c.derefIfPointer(src, loadName) targetArray, targetIsArray := target.(Array) sourceArray, sourceIsArray := source.Type.(Array) - if targetIsArray && sourceIsArray && sourceArray.ElemType.Kind() == EmptyKind && targetArray.Rank > 1 { + 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) diff --git a/compiler/solver.go b/compiler/solver.go index dbe0fcc1..2e1b5c82 100644 --- a/compiler/solver.go +++ b/compiler/solver.go @@ -1133,6 +1133,12 @@ func (ts *TypeSolver) typeTableLiteral(al *ast.ArrayLiteral) []Type { 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()) } } diff --git a/compiler/solver_test.go b/compiler/solver_test.go index 56c0f612..00c978fe 100644 --- a/compiler/solver_test.go +++ b/compiler/solver_test.go @@ -306,6 +306,11 @@ func TestCollectionTypeErrors(t *testing.T) { 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]", @@ -326,6 +331,11 @@ func TestCollectionTypeErrors(t *testing.T) { 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, diff --git a/compiler/types.go b/compiler/types.go index a03cda27..c5f5e81f 100644 --- a/compiler/types.go +++ b/compiler/types.go @@ -631,9 +631,10 @@ func bindingSlotCompatible(oldType, newType Type) bool { oldArray, oldIsArray := oldType.(Array) newArray, newIsArray := newType.(Array) if oldIsArray && newIsArray { - // [] resets an existing array without changing its established type or rank. + // 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 true + return newArray.Rank == 1 || oldArray.Rank == newArray.Rank } if oldArray.ElemType.Kind() == EmptyKind { return oldArray.Rank == newArray.Rank diff --git a/docs/Pluto Array Semantics.md b/docs/Pluto Array Semantics.md index b7193ede..5e31a4ac 100644 --- a/docs/Pluto Array Semantics.md +++ b/docs/Pluto Array Semantics.md @@ -198,10 +198,13 @@ Only a statement gate removes a rejected iteration from the shared statement domain; value-position `&&` never does. Array-scalar operations preserve shape. Array-array element-wise operations -require equal rank and equal inner dimensions, then zip the outer dimension to -the shorter input. Concatenation joins the outer dimension and requires every -inner dimension to match. Literal-construction mismatches are compile errors; -operation shapes that depend on runtime values are checked before proceeding. +require equal rank and, when both outer dimensions are nonzero, equal inner +dimensions; they zip the outer dimension to the shorter input. Concatenation +joins the outer dimension and applies the same inner-shape check 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; operation 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 diff --git a/tests/array/array.exp b/tests/array/array.exp index 954101c3..4b92b63a 100644 --- a/tests/array/array.exp +++ b/tests/array/array.exp @@ -29,6 +29,15 @@ 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 ] @@ -70,6 +79,12 @@ replacement table: [ ] saved table scores: [10 12] empty array: [] +rank 2 empty: [ + [] +] +rank 2 refined: [ + 9 +] empty concat: [] empty table: [ :Name Score diff --git a/tests/array/array.spt b/tests/array/array.spt index 3e774007..9a6b4698 100644 --- a/tests/array/array.spt +++ b/tests/array/array.spt @@ -63,6 +63,13 @@ 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" @@ -110,6 +117,10 @@ scores = emptyArray = [] "empty array: -emptyArray" +rank2Refined = [[]] +"rank 2 empty: -rank2Refined" +rank2Refined = [[9]] +"rank 2 refined: -rank2Refined" emptyConcat = [] ⊕ [] "empty concat: -emptyConcat" From 6785525b342128b2b58c61de7bb78510c4566554 Mon Sep 17 00:00:00 2001 From: Tejas Date: Thu, 16 Jul 2026 17:21:38 +0530 Subject: [PATCH 13/33] fix(array): align table headers with values Render the table header marker as a two-space hanging prefix so the first header and first row value begin in the same column. Document this as the preferred source layout while keeping table whitespace non-semantic and all existing spellings accepted. --- README.md | 9 +++++---- ast/ast.go | 2 +- docs/Pluto Array Semantics.md | 11 +++++++++++ runtime/array.c | 2 +- tests/array/array.exp | 6 +++--- tests/array/array.spt | 6 +++--- tests/array/array_func.exp | 2 +- tests/array/array_func.spt | 6 +++--- 8 files changed, 28 insertions(+), 16 deletions(-) diff --git a/README.md b/README.md index 46971b13..8dbdc55c 100644 --- a/README.md +++ b/README.md @@ -217,7 +217,7 @@ cube = [ ] scores = [ - :Name Score + : Name Score "Ada" 10 "Lin" 12 ] @@ -236,9 +236,10 @@ columns with different element types infer an unnamed table. A header must contain at least one column name, but it may have no data rows. Such a literal is an empty table whose printed value retains the header; each projected column is an untyped empty array and prints as `[]`. Headerless literals start directly -with their first data row. The conventional header spelling is `:Name Score`. -Indentation and spacing inside brackets are not semantic; examples use four -spaces. Named columns are arrays, so `scores.Score` returns `[10 12]`. +with their first data row. The preferred layout uses ` : Name Score`, with the +outdented `:` leaving the first header aligned with the first value below it. +Indentation and spacing inside brackets are not semantic. Named columns are +arrays, so `scores.Score` returns `[10 12]`. Header-only tables can be printed and projected, but cannot be passed to functions until their column types are established. See [Pluto Array Semantics](docs/Pluto%20Array%20Semantics.md) for the complete diff --git a/ast/ast.go b/ast/ast.go index 9d308bdc..580b915e 100644 --- a/ast/ast.go +++ b/ast/ast.go @@ -313,7 +313,7 @@ func (al *ArrayLiteral) String() string { // Print headers if present if len(al.Headers) > 0 { - out.WriteString("\n :") + out.WriteString("\n : ") for j, header := range al.Headers { if j > 0 { out.WriteString(" ") diff --git a/docs/Pluto Array Semantics.md b/docs/Pluto Array Semantics.md index 5e31a4ac..94f5340c 100644 --- a/docs/Pluto Array Semantics.md +++ b/docs/Pluto Array Semantics.md @@ -18,6 +18,17 @@ dimension lengths beside that buffer; rows are not separately allocated. - Multiple scalar rows with homogeneous but different column types infer an unnamed table. A header always produces a table. +The preferred table layout outdents the `:` marker so the first header and +first value begin in the same column. Whitespace remains non-semantic: + +```pluto +scores = [ + : Name Score + "Ada" 10 + "Lin" 12 +] +``` + These two literals therefore have the same rank-2 type and value: ```pluto diff --git a/runtime/array.c b/runtime/array.c index 23c186d6..9df8c837 100644 --- a/runtime/array.c +++ b/runtime/array.c @@ -431,7 +431,7 @@ const char* table_str(size_t rows, size_t cols, const char* const* names, if (strbuf_printf(&sb, "[") < 0) goto fail; if (has_headers) { - if (strbuf_printf(&sb, "\n :") < 0) goto fail; + 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; diff --git a/tests/array/array.exp b/tests/array/array.exp index 4b92b63a..f6ee4803 100644 --- a/tests/array/array.exp +++ b/tests/array/array.exp @@ -68,13 +68,13 @@ string matrix: [ ] string matrix row: ["Ada" ""] table: [ - :Name Score + : Name Score "Ada" 10 "Lin" 12 ] table scores: [10 12] replacement table: [ - :Name Score + : Name Score "Mae" 20 ] saved table scores: [10 12] @@ -87,7 +87,7 @@ rank 2 refined: [ ] empty concat: [] empty table: [ - :Name Score + : Name Score ] empty score column: [] typed empty scores: [7 8] diff --git a/tests/array/array.spt b/tests/array/array.spt index 9a6b4698..7f616e78 100644 --- a/tests/array/array.spt +++ b/tests/array/array.spt @@ -100,7 +100,7 @@ stringMatrixRow = stringMatrix[0] scores = [ - :Name Score + : Name Score "Ada" 10 "Lin" 12 ] @@ -109,7 +109,7 @@ scoreColumn = scores.Score "table scores: -scoreColumn" scores = [ - :Name Score + : Name Score "Mae" 20 ] "replacement table: -scores" @@ -126,7 +126,7 @@ emptyConcat = [] ⊕ [] emptyTable = [ - :Name Score + : Name Score ] "empty table: -emptyTable" emptyScoreColumn = emptyTable.Score diff --git a/tests/array/array_func.exp b/tests/array/array_func.exp index d1de7f02..26ee6005 100644 --- a/tests/array/array_func.exp +++ b/tests/array/array_func.exp @@ -14,7 +14,7 @@ MatrixIdentity: [ 3 4 ] TableIdentity: [ - :Name Score + : Name Score "Ada" 10 "Lin" 12 ] diff --git a/tests/array/array_func.spt b/tests/array/array_func.spt index 0aacabf7..bceb7359 100644 --- a/tests/array/array_func.spt +++ b/tests/array/array_func.spt @@ -30,21 +30,21 @@ matrixIdentity = Identity([ "MatrixIdentity: -matrixIdentity" tableIdentity = Identity([ - :Name Score + : Name Score "Ada" 10 "Lin" 12 ]) "TableIdentity: -tableIdentity" tableCallColumn = Identity([ - :Name Score + : Name Score "Ada" 10 "Lin" 12 ]).Score "TableCallColumn: -tableCallColumn" gatedTableColumn = (1 > 0 && Identity([ - :Name Score + : Name Score "Ada" 10 "Lin" 12 ])).Name > "K" From 1bfac19303ae914eef3186c7431700e1df8140a1 Mon Sep 17 00:00:00 2001 From: Tejas Date: Thu, 16 Jul 2026 17:32:07 +0530 Subject: [PATCH 14/33] docs(array): consolidate collection semantics Keep the README focused on the user-facing overview, move detailed table rules into Array Semantics, and make Range Semantics authoritative for nested domain construction. Trim repeated selection and binder rules from the Memory Model in favor of links to their canonical sections. --- README.md | 31 +++----- docs/Pluto Array Semantics.md | 145 ++++++++++++---------------------- docs/Pluto Memory Model.md | 30 +++---- 3 files changed, 69 insertions(+), 137 deletions(-) diff --git a/README.md b/README.md index 8dbdc55c..a4a35553 100644 --- a/README.md +++ b/README.md @@ -223,27 +223,13 @@ scores = [ ] ``` -An empty or one-row scalar literal is rank 1. A rectangular, multi-row scalar -literal is rank 2. Array-valued cells of equal rank and shape stack along a new -outer dimension, so `[[1 2] [3 4]]` is the same rank-2 value as the `matrix` -literal above and the rule extends to any rank. Storage is flat and row-major; -there is no pointer per row. Ragged literals are compile errors and missing -cells are never padded with default values. Indexing removes the outer -dimension, so `cube[1][0]` is `[5 6]`. - -A header row always produces a columnar table; without headers, homogeneous -columns with different element types infer an unnamed table. A header must -contain at least one column name, but it may have no data rows. Such a literal -is an empty table whose printed value retains the header; each projected column -is an untyped empty array and prints as `[]`. Headerless literals start directly -with their first data row. The preferred layout uses ` : Name Score`, with the -outdented `:` leaving the first header aligned with the first value below it. -Indentation and spacing inside brackets are not semantic. Named columns are -arrays, so `scores.Score` returns `[10 12]`. -Header-only tables can be printed and projected, but cannot be passed to -functions until their column types are established. See -[Pluto Array Semantics](docs/Pluto%20Array%20Semantics.md) for the complete -inference and shape rules. +One scalar row is rank 1; multiple rectangular scalar rows are rank 2; and +equal-shaped array cells stack into higher ranks. Storage is flat and +row-major, and 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; whitespace remains 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. @@ -251,6 +237,9 @@ 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/docs/Pluto Array Semantics.md b/docs/Pluto Array Semantics.md index 94f5340c..7872b737 100644 --- a/docs/Pluto Array Semantics.md +++ b/docs/Pluto Array Semantics.md @@ -15,19 +15,6 @@ dimension lengths beside that buffer; rows are not separately allocated. - `[1 2 3]` is a rank-1 `[I64]` value. - Multiple homogeneous scalar rows infer a rank-2 array. - Array-valued cells of one rank and shape stack into an array one rank higher. -- Multiple scalar rows with homogeneous but different column types infer an - unnamed table. A header always produces a table. - -The preferred table layout outdents the `:` marker so the first header and -first value begin in the same column. Whitespace remains non-semantic: - -```pluto -scores = [ - : Name Score - "Ada" 10 - "Lin" 12 -] -``` These two literals therefore have the same rank-2 type and value: @@ -69,6 +56,29 @@ arr = [ Ranges inside a rank-1 literal remain collectors and may determine its runtime length. 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. Whitespace remains 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; @@ -84,7 +94,22 @@ i = 0:2 selected = [matrix[i]] ``` -### Planned grouped multi-axis indexing +Array-scalar operations preserve shape. Array-array element-wise operations +require equal rank and, when both outer dimensions are nonzero, equal inner +dimensions; they zip the outer dimension to the shorter input. Concatenation +joins the outer dimension and applies the same inner-shape check 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; operation 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. @@ -128,10 +153,10 @@ 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. -### Planned nested range construction +### Nested range construction Grouped indexing selects from an existing array. Computed rectangular values -need a separate construction rule. The first planned case is: +need a separate construction rule: ```pluto i = 0:3 @@ -139,84 +164,12 @@ j = 0:3 submatrix = [i && [matrix[i][j]]] ``` -Here `&&` is in value position. It binds the outer `i` yield for the local -right-hand value; the inner collector owns `j` and produces one row, and the -outer collector stacks the rows. It is not a statement gate and does not alter -sibling RHS expressions. Bare ranges on the left of value-position `&&` are -not implemented yet; this construction is deferred until PIR can represent -the two nested domains and their collector ownership directly. - -The binder distinguishes flat cartesian collection from nested dimensions: - -```pluto -flat = [F(i, j)] # rank 1 over the i x j domain -rows = [i && [F(i, j)]] # rank 2: i rows, j cells -cols = [j && [F(i, j)]] # rank 2: j rows, i cells -cube = [i && [j && [F(i, j, k)]]] # rank 3: i, then j, then k -ones = [i && 1] # rank 1: one 1 per i -``` - -A bare range binder always yields each domain point; its numeric value is not a -truth test, so `i = 0` still produces a `1` in the last example. Without an -explicit binder, the nearest collector owns every unbound range mentioned in -its cells. Ordinary arithmetic does not establish a nested domain. - -`&&` binds a domain; it does not collect or flatten the values yielded by that -domain. Collector placement therefore determines the resulting rank: - -```pluto -[j && -1] # one rank-1 row containing one -1 per j -j && [-1] # a stream containing one singleton array per j -[j && [-1]] # rank 2, with shape len(j) x 1 -``` - -A row-level fallback used inside the outer collector must produce a row with -the same runtime shape as every other row that is actually stacked. Reusing -the `j` domain makes that shape explicit: - -```pluto -result = [ - i && ( - i < 2 && [matrix[i][j]] - || [j && -1] - ) -] -``` - -Both alternatives produce a rank-1 row of length `len(j)`. By contrast, -`i && [matrix[i][j]] || [-1]` has no reachable fallback: a bare range yields -each domain point and the inner collector resolves to an array. If a genuinely -failable row condition made `[-1]` reachable, mixing its singleton shape with -longer rows would fail the outer collector's rectangular shape check. Pluto -does not pad, truncate, or flatten mismatched rows. - -A failed array-valued cell preserves the outer domain by contributing a -zero-filled child with the expected inner shape: +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. -```pluto -zeroRows = [i > 0 && [F(i, j)]] -minusRows = [i > 0 && [F(i, j)] || [j && -1]] -``` - -`zeroRows` contains a zero row for every `i` that fails `i > 0`. -`minusRows` uses the explicit row fallback instead. The child shape must be -known statically or derivable from its bound domains, such as `j` here. If PIR -cannot establish the shape without evaluating the skipped value, the compiler -rejects the implicit zero-fill and requires an explicit shape-bearing fallback. -Use `|| [j && 0]` to state the default zero row, or another compatible value -such as `|| [j && -1]`. -Only a statement gate removes a rejected iteration from the shared statement -domain; value-position `&&` never does. - -Array-scalar operations preserve shape. Array-array element-wise operations -require equal rank and, when both outer dimensions are nonzero, equal inner -dimensions; they zip the outer dimension to the shorter input. Concatenation -joins the outer dimension and applies the same inner-shape check 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; operation 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. +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 Memory Model.md b/docs/Pluto Memory Model.md index a53373f6..a5642d01 100644 --- a/docs/Pluto Memory Model.md +++ b/docs/Pluto Memory Model.md @@ -132,18 +132,12 @@ submatrix = [matrix[i j]] rowSums = [Sum(matrix[i j])] ``` -`matrix[i j]` will be a lazy selection view, not an eagerly allocated slice. -Scalar indices drop their axes; range indices and omitted trailing axes retain -theirs. The selection rank is therefore the source rank minus the number of -scalar indices. When at least one range is present, the leftmost range drives -iteration and each yield has one less rank. An ordinary `[]` collector stacks -those yields, restoring the selection rank. - -Grouped indexing preserves selected axes. Chained range indexing keeps the -existing flat range-domain behavior. Mixed grouped and chained indexing should -initially be rejected rather than assigned an implicit shape rule. This work is -deferred until PIR models range ownership and collector boundaries explicitly; -see [Pluto Array Semantics](Pluto%20Array%20Semantics.md). +`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. --- @@ -216,14 +210,10 @@ 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 value-position -meaning; support for a bare range on its left is deferred until PIR represents -the nested range and collector scopes. The operator only binds that domain; -materialization remains explicit, so `[j && -1]` forms one row while -`j && [-1]` yields one singleton array per `j`. The same rule makes -`[i && 1]` an array containing one `1` per `i`. A failed array-valued cell -contributes a zero-filled child of its expected shape unless `||` supplies an -explicit shape-compatible fallback. +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 From 0cb204bc676733ffad36540cce4bab4183b235ec Mon Sep 17 00:00:00 2001 From: Tejas Date: Thu, 16 Jul 2026 17:46:06 +0530 Subject: [PATCH 15/33] refactor(array): share fixed literal lowering Use one flat-buffer allocation and cell-fill path for rank-1 and rectangular scalar literals while leaving dimension calculation with each caller. Restore the deferred unreachable-fallback diagnostic and the no-padding shape guarantee in Range Semantics. --- compiler/array.go | 48 +++++++++++++++++++---------------- compiler/array_nd.go | 17 ++----------- docs/Pluto Range Semantics.md | 6 +++++ 3 files changed, 34 insertions(+), 37 deletions(-) diff --git a/compiler/array.go b/compiler/array.go index 6264777b..5a692130 100644 --- a/compiler/array.go +++ b/compiler/array.go @@ -338,33 +338,37 @@ func (c *Compiler) resolveArrayLiteralRewrite(e *ast.ArrayLiteral) (*ast.ArrayLi } func (c *Compiler) compileArrayLiteralImmediate(lit *ast.ArrayLiteral, info *ExprInfo) (res []*Symbol) { - s := &Symbol{} - - // Handle empty array literal: [] - if len(lit.Rows) == 0 { - arr := info.OutTypes[0].(Array) - s.Type = arr - s.Val = c.createArrayValue(llvm.Value{}, []llvm.Value{c.ConstI64(0)}, arr) - return []*Symbol{s} - } - arr := info.OutTypes[0].(Array) - elemType := arr.ElemType + length := 0 + if len(lit.Rows) > 0 { + length = len(lit.Rows[0]) + } + dimensions := []llvm.Value{c.ConstI64(uint64(length))} + return []*Symbol{c.compileFixedArrayLiteral(lit, arr, dimensions)} +} - row := lit.Rows[0] - nConst := c.ConstI64(uint64(len(row))) - arrVal := c.CreateArrayForType(elemType, nConst) +func (c *Compiler) compileFixedArrayLiteral(lit *ast.ArrayLiteral, arrayType Array, dimensions []llvm.Value) *Symbol { + cellCount := 0 + for _, row := range lit.Rows { + cellCount += len(row) + } + if cellCount == 0 || !hasConcreteArrayElemType(arrayType.ElemType) { + return &Symbol{Type: arrayType, Val: c.createArrayValue(llvm.Value{}, dimensions, arrayType)} + } - 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.createArrayValue(arrVal, []llvm.Value{nConst}, arr) - 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()) { diff --git a/compiler/array_nd.go b/compiler/array_nd.go index 440e9dc0..5849df5f 100644 --- a/compiler/array_nd.go +++ b/compiler/array_nd.go @@ -14,21 +14,8 @@ func (c *Compiler) compileRectangularArrayLiteral(lit *ast.ArrayLiteral, arrayTy if rows > 0 { cols = len(lit.Rows[0]) } - - data := c.CreateArrayForType(arrayType.ElemType, c.ConstI64(uint64(rows*cols))) - for rowIndex, row := range lit.Rows { - for colIndex, cell := range row { - index := c.ConstI64(uint64(rowIndex*cols + colIndex)) - c.compileArrayLiteralCell(cell, arrayType.ElemType, func(cellSlot *Symbol) bool { - return c.setArrayCellSlot(data, index, cellSlot, arrayType.ElemType) - }) - } - } - - return &Symbol{ - Type: arrayType, - Val: c.createArrayValue(data, []llvm.Value{c.ConstI64(uint64(rows)), c.ConstI64(uint64(cols))}, arrayType), - } + dimensions := []llvm.Value{c.ConstI64(uint64(rows)), c.ConstI64(uint64(cols))} + return c.compileFixedArrayLiteral(lit, arrayType, dimensions) } func (c *Compiler) compileStackedArrayLiteral(lit *ast.ArrayLiteral, arrayType Array) *Symbol { diff --git a/docs/Pluto Range Semantics.md b/docs/Pluto Range Semantics.md index 346824f8..63752b7f 100644 --- a/docs/Pluto Range Semantics.md +++ b/docs/Pluto Range Semantics.md @@ -326,6 +326,12 @@ 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 From 21c5841fff40bdc8870880c24b7b26e753b09902 Mon Sep 17 00:00:00 2001 From: Tejas Date: Thu, 16 Jul 2026 18:24:18 +0530 Subject: [PATCH 16/33] refactor(array): unify array string formatting Render one-row AST literals inline while retaining multiline source rendering for tables and multi-row literals. Route all rank-1 runtime string conversions through the existing rank-aware formatter so scalar and higher-rank arrays share cell formatting and allocation behavior. --- ast/ast.go | 29 +++++++++++++++--------- parser/scriptparser_test.go | 2 ++ runtime/array.c | 45 +++++-------------------------------- 3 files changed, 27 insertions(+), 49 deletions(-) diff --git a/ast/ast.go b/ast/ast.go index 580b915e..cbf509c8 100644 --- a/ast/ast.go +++ b/ast/ast.go @@ -310,6 +310,11 @@ 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 && len(al.Rows) == 1 { + writeArrayRow(&out, al.Rows[0]) + out.WriteString("]") + return out.String() + } // Print headers if present if len(al.Headers) > 0 { @@ -325,16 +330,7 @@ func (al *ArrayLiteral) String() string { // Print rows for _, row := range al.Rows { out.WriteString("\n ") - for j, expr := range row { - if j > 0 { - out.WriteString(" ") - } - if expr != nil { - out.WriteString(expr.String()) - } else { - out.WriteString("") - } - } + writeArrayRow(&out, row) } if len(al.Headers) > 0 || len(al.Rows) > 0 { @@ -344,6 +340,19 @@ func (al *ArrayLiteral) String() string { 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 diff --git a/parser/scriptparser_test.go b/parser/scriptparser_test.go index 9295f0a2..1f53151c 100644 --- a/parser/scriptparser_test.go +++ b/parser/scriptparser_test.go @@ -574,6 +574,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) { @@ -1157,6 +1158,7 @@ func TestArrayLiterals(t *testing.T) { 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)) diff --git a/runtime/array.c b/runtime/array.c index 9df8c837..11f65f23 100644 --- a/runtime/array.c +++ b/runtime/array.c @@ -454,51 +454,18 @@ const char* table_str(size_t rows, size_t cols, const char* const* names, } const char* arr_i64_str(const PtArrayI64* a) { - 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; } - return sb.data; + 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) { - 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; } - } - } - if (strbuf_printf(&sb, "]") < 0) { free(sb.data); return NULL; } - return sb.data; + 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, From 4a4a3bfa0e7749ac7ec3ed26397acd814b8e7d91 Mon Sep 17 00:00:00 2001 From: Tejas Date: Thu, 16 Jul 2026 19:17:48 +0530 Subject: [PATCH 17/33] fix(ast): quote string literals in diagnostics Preserve source delimiters when rendering string-literal AST nodes so literal text cannot be confused with missing-expression markers. --- ast/ast.go | 2 +- parser/codeparser_test.go | 4 +--- parser/scriptparser_test.go | 1 + 3 files changed, 3 insertions(+), 4 deletions(-) diff --git a/ast/ast.go b/ast/ast.go index cbf509c8..18c10cdd 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 { 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/scriptparser_test.go b/parser/scriptparser_test.go index 1f53151c..a0a5c20b 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) { From 722e3929a828385f0b3fe7de1fbaeb1395cbfb27 Mon Sep 17 00:00:00 2001 From: Tejas Date: Thu, 16 Jul 2026 20:49:57 +0530 Subject: [PATCH 18/33] refactor(compiler): inline runtime trap branch Remove the single-use trapOnFalse wrapper while preserving the existing checked runtime-status control flow. --- compiler/array.go | 4 ---- 1 file changed, 4 deletions(-) diff --git a/compiler/array.go b/compiler/array.go index 5a692130..16de18b3 100644 --- a/compiler/array.go +++ b/compiler/array.go @@ -181,10 +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") - c.trapOnFalse(ok, name) -} - -func (c *Compiler) trapOnFalse(ok llvm.Value, name string) { fn := c.builder.GetInsertBlock().Parent() failBlock := c.Context.AddBasicBlock(fn, name+"_fail") contBlock := c.Context.AddBasicBlock(fn, name+"_cont") From 877f072bc5c767544f750a4cbd9f44be3d891a4e Mon Sep 17 00:00:00 2001 From: Tejas Date: Thu, 16 Jul 2026 22:16:36 +0530 Subject: [PATCH 19/33] refactor(compiler): flatten bracket literal dispatch Separate solved Array and Table lowering from the shared AST entry point, using guard clauses for array strategy selection. --- compiler/array.go | 28 ++++++++++++++++------------ compiler/table.go | 2 +- 2 files changed, 17 insertions(+), 13 deletions(-) diff --git a/compiler/array.go b/compiler/array.go index 16de18b3..499db605 100644 --- a/compiler/array.go +++ b/compiler/array.go @@ -277,22 +277,13 @@ func (c *Compiler) ArrayGetBorrowed(arr *Symbol, elem Type, idx llvm.Value) llvm // compileArrayExpression materializes a bracket literal according to its solved // Array or Table type. -func (c *Compiler) compileArrayExpression(e *ast.ArrayLiteral, _ []*ast.Identifier) (res []*Symbol) { +func (c *Compiler) compileArrayExpression(e *ast.ArrayLiteral, _ []*ast.Identifier) []*Symbol { lit, info := c.resolveArrayLiteralRewrite(e) switch typ := info.OutTypes[0].(type) { case Array: - if typ.Rank > 1 { - if c.arrayLiteralHasArrayCells(lit) { - if len(info.CollectRanges) > 0 { - return []*Symbol{c.compileStackedArrayCollector(lit, info, typ)} - } - return []*Symbol{c.compileStackedArrayLiteral(lit, typ)} - } - return []*Symbol{c.compileRectangularArrayLiteral(lit, typ)} - } - return []*Symbol{c.compileArrayLiteralInDomain(lit, info, nil, nil)} + return []*Symbol{c.compileArray(lit, info, typ)} case Table: - return []*Symbol{c.compileTableLiteral(lit, typ)} + return []*Symbol{c.compileTable(lit, typ)} default: c.Errors = append(c.Errors, &token.CompileError{ Token: lit.Tok(), @@ -302,6 +293,19 @@ func (c *Compiler) compileArrayExpression(e *ast.ArrayLiteral, _ []*ast.Identifi } } +func (c *Compiler) compileArray(lit *ast.ArrayLiteral, info *ExprInfo, arrayType Array) *Symbol { + if arrayType.Rank == 1 { + return c.compileArrayLiteralInDomain(lit, info, nil, nil) + } + if !c.arrayLiteralHasArrayCells(lit) { + return c.compileRectangularArrayLiteral(lit, arrayType) + } + if len(info.CollectRanges) == 0 { + return c.compileStackedArrayLiteral(lit, arrayType) + } + return c.compileStackedArrayCollector(lit, info, arrayType) +} + func (c *Compiler) arrayLiteralHasArrayCells(lit *ast.ArrayLiteral) bool { for _, row := range lit.Rows { for _, cell := range row { diff --git a/compiler/table.go b/compiler/table.go index 8a817daf..d4b44892 100644 --- a/compiler/table.go +++ b/compiler/table.go @@ -5,7 +5,7 @@ import ( "tinygo.org/x/go-llvm" ) -func (c *Compiler) compileTableLiteral(lit *ast.ArrayLiteral, tableType Table) *Symbol { +func (c *Compiler) compileTable(lit *ast.ArrayLiteral, tableType Table) *Symbol { if len(lit.Rows) == 0 { return c.makeZeroValue(tableType) } From ef6e830989750c30f7698b1f9ae027f3c8ce8117 Mon Sep 17 00:00:00 2001 From: Tejas Date: Thu, 16 Jul 2026 22:44:20 +0530 Subject: [PATCH 20/33] refactor(compiler): trust solved array cell types Rely on the solver invariant that every valid bracket-literal cell has exactly one cached output type. --- compiler/array.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/compiler/array.go b/compiler/array.go index 499db605..6824dde2 100644 --- a/compiler/array.go +++ b/compiler/array.go @@ -310,7 +310,7 @@ func (c *Compiler) arrayLiteralHasArrayCells(lit *ast.ArrayLiteral) bool { for _, row := range lit.Rows { for _, cell := range row { info := c.ExprCache[key(c.FuncNameMangled, cell)] - if info != nil && len(info.OutTypes) == 1 && info.OutTypes[0].Kind() == ArrayKind { + if info.OutTypes[0].Kind() == ArrayKind { return true } } From 6fbe4d4b33eac45a7e247a7758739662091276a7 Mon Sep 17 00:00:00 2001 From: Tejas Date: Thu, 16 Jul 2026 22:48:26 +0530 Subject: [PATCH 21/33] refactor(compiler): classify array literals by first cell Use the solver invariant that valid literals cannot mix scalar and array-valued cells, avoiding a redundant full-cell scan during lowering. --- compiler/array.go | 12 +++--------- 1 file changed, 3 insertions(+), 9 deletions(-) diff --git a/compiler/array.go b/compiler/array.go index 6824dde2..0e0f2f26 100644 --- a/compiler/array.go +++ b/compiler/array.go @@ -307,15 +307,9 @@ func (c *Compiler) compileArray(lit *ast.ArrayLiteral, info *ExprInfo, arrayType } func (c *Compiler) arrayLiteralHasArrayCells(lit *ast.ArrayLiteral) bool { - for _, row := range lit.Rows { - for _, cell := range row { - info := c.ExprCache[key(c.FuncNameMangled, cell)] - if info.OutTypes[0].Kind() == ArrayKind { - return true - } - } - } - return false + // The solver rejects literals that mix scalar and array-valued cells. + firstCell := lit.Rows[0][0] + return c.ExprCache[key(c.FuncNameMangled, firstCell)].OutTypes[0].Kind() == ArrayKind } // resolveArrayLiteralRewrite retrieves the potentially rewritten array literal and its ExprInfo. From 761fa2a89e276a07d16a6eff5094d526b2461c60 Mon Sep 17 00:00:00 2001 From: Tejas Date: Fri, 17 Jul 2026 00:01:33 +0530 Subject: [PATCH 22/33] refactor(compiler): remove redundant array literal wrapper Compile immediate rank-one literals through compileFixedArrayLiteral directly. Rank-one values do not store dimensions, so remove the unused dimension calculation and slice-returning adapter. --- compiler/array.go | 12 +----------- 1 file changed, 1 insertion(+), 11 deletions(-) diff --git a/compiler/array.go b/compiler/array.go index 0e0f2f26..136bca70 100644 --- a/compiler/array.go +++ b/compiler/array.go @@ -331,16 +331,6 @@ func (c *Compiler) resolveArrayLiteralRewrite(e *ast.ArrayLiteral) (*ast.ArrayLi return lit, info } -func (c *Compiler) compileArrayLiteralImmediate(lit *ast.ArrayLiteral, info *ExprInfo) (res []*Symbol) { - arr := info.OutTypes[0].(Array) - length := 0 - if len(lit.Rows) > 0 { - length = len(lit.Rows[0]) - } - dimensions := []llvm.Value{c.ConstI64(uint64(length))} - return []*Symbol{c.compileFixedArrayLiteral(lit, arr, dimensions)} -} - func (c *Compiler) compileFixedArrayLiteral(lit *ast.ArrayLiteral, arrayType Array, dimensions []llvm.Value) *Symbol { cellCount := 0 for _, row := range lit.Rows { @@ -405,7 +395,7 @@ func (c *Compiler) compileArrayLiteralWithLoops(lit *ast.ArrayLiteral, info *Exp 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] + return c.compileFixedArrayLiteral(lit, info.OutTypes[0].(Array), nil) } return c.compileArrayLiteralWithLoops(lit, info, collectRanges, condExprs) } From 517e84abf81673cba43da0573d1b1a674b4d8f77 Mon Sep 17 00:00:00 2001 From: Tejas Date: Fri, 17 Jul 2026 14:49:52 +0530 Subject: [PATCH 23/33] feat(compiler): zip every array dimension to its minimum Generalize shortest-input zip semantics to rank-N element-wise operations by selecting each result dimension independently and mapping result coordinates through each source's row-major strides. Element-wise operations no longer abort on unequal inner dimensions; stacking and concatenation retain their rectangular shape checks. Document conditional outcomes as conceptual value/yield lanes for the future PIR lowering. --- compiler/array.go | 101 ++++++++++++++-------- docs/Pluto Array Semantics.md | 15 ++-- docs/Pluto Conditional Value Semantics.md | 26 +++++- docs/Pluto IR Plan.md | 12 ++- runtime/array.c | 4 +- tests/array/array_elementwise.exp | 10 +++ tests/array/array_elementwise.spt | 35 +++++++- 7 files changed, 153 insertions(+), 50 deletions(-) diff --git a/compiler/array.go b/compiler/array.go index 136bca70..2dc50d0b 100644 --- a/compiler/array.go +++ b/compiler/array.go @@ -586,63 +586,93 @@ func (c *Compiler) pushAccumCellValue(acc *ArrayAccumulator, valSym *Symbol, isT // Array operation functions // arrayPairShape returns the flat iteration length and dimensions for a zipped -// pair. Higher-rank arrays zip the outer dimension and require equal inner shape. +// pair. Every dimension is limited to the shorter corresponding dimension. func (c *Compiler) arrayPairShape(left *Symbol, right *Symbol) (llvm.Value, []llvm.Value) { - leftType := left.Type.(Array) - if leftType.Rank == 1 { - leftLen := c.ArrayLen(left, leftType.ElemType) - rightLen := c.ArrayLen(right, right.Type.(Array).ElemType) - length := c.builder.CreateSelect( - c.builder.CreateICmp(llvm.IntULT, leftLen, rightLen, "cmp_len"), - leftLen, rightLen, "min_len", + 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", ) - return length, []llvm.Value{length} } - leftDims := c.arrayDimensions(left) - rightDims := c.arrayDimensions(right) - zero := c.ConstI64(0) - bothNonEmpty := c.builder.CreateAnd( - c.builder.CreateICmp(llvm.IntNE, leftDims[0], zero, "left_array_nonempty"), - c.builder.CreateICmp(llvm.IntNE, rightDims[0], zero, "right_array_nonempty"), - "both_arrays_nonempty", - ) - c.requireSameArrayShapeWhen(bothNonEmpty, leftDims[1:], rightDims[1:], "array_inner_shape") - outer := c.builder.CreateSelect( - c.builder.CreateICmp(llvm.IntULT, leftDims[0], rightDims[0], "cmp_outer_len"), - leftDims[0], rightDims[0], "min_outer_len", - ) - dimensions := append([]llvm.Value{outer}, leftDims[1:]...) - length := outer + 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).ElemType rightIsArray := right.Type.Kind() == ArrayKind var rightElem Type + var resultStrides, leftStrides, rightStrides []llvm.Value if rightIsArray { 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} } @@ -669,12 +699,11 @@ func (c *Compiler) compileArrayArrayInfix(op string, left *Symbol, right *Symbol 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. + // 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) @@ -761,7 +790,7 @@ func (c *Compiler) compileArrayScalarInfix(op string, arr *Symbol, scalar *Symbo loopLen := c.ArrayLen(arr, arrElem) dimensions := c.arrayDimensions(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 { @@ -787,8 +816,8 @@ func (c *Compiler) compileArrayScalarInfix(op string, arr *Symbol, scalar *Symbo // 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, "") @@ -823,7 +852,7 @@ 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, 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) }) @@ -836,7 +865,7 @@ func (c *Compiler) compileArrayScalarMask(op string, arr *Symbol, scalar *Symbol loopLen := c.ArrayLen(arr, arrElem) dimensions := c.arrayDimensions(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. diff --git a/docs/Pluto Array Semantics.md b/docs/Pluto Array Semantics.md index 7872b737..6772b225 100644 --- a/docs/Pluto Array Semantics.md +++ b/docs/Pluto Array Semantics.md @@ -95,13 +95,14 @@ selected = [matrix[i]] ``` Array-scalar operations preserve shape. Array-array element-wise operations -require equal rank and, when both outer dimensions are nonzero, equal inner -dimensions; they zip the outer dimension to the shorter input. Concatenation -joins the outer dimension and applies the same inner-shape check 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; operation shapes that -depend on runtime values are checked before proceeding. +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 diff --git a/docs/Pluto Conditional Value Semantics.md b/docs/Pluto Conditional Value Semantics.md index 997808ae..99bb55d7 100644 --- a/docs/Pluto Conditional Value Semantics.md +++ b/docs/Pluto Conditional Value Semantics.md @@ -25,6 +25,27 @@ 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 @@ -229,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) diff --git a/docs/Pluto IR Plan.md b/docs/Pluto IR Plan.md index 4ff0c327..8e7d2493 100644 --- a/docs/Pluto IR Plan.md +++ b/docs/Pluto IR Plan.md @@ -148,9 +148,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 `set` 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 `&&`/`||`, diff --git a/runtime/array.c b/runtime/array.c index 11f65f23..07d0a284 100644 --- a/runtime/array.c +++ b/runtime/array.c @@ -407,8 +407,8 @@ const char* array_nd_str(const void* values, int32_t kind, size_t rank, } /* Aborts the process; called from generated code when a runtime shape check - * fails (stacking, concatenation, or zipping arrays with unequal inner - * dimensions). Static shape mismatches are compile errors instead. */ + * 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); diff --git a/tests/array/array_elementwise.exp b/tests/array/array_elementwise.exp index 3c338834..fbaa4265 100644 --- a/tests/array/array_elementwise.exp +++ b/tests/array/array_elementwise.exp @@ -20,3 +20,13 @@ 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..8a2d7885 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,36 @@ 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" From cbe5d65701fc4e3fa41a7bdc00595f9868b6a06c Mon Sep 17 00:00:00 2001 From: Tejas Date: Sat, 18 Jul 2026 19:02:19 +0530 Subject: [PATCH 24/33] refactor(compiler): avoid ignored rank-one dimensions Rank-one length is stored by the runtime vector rather than in the LLVM array value. Skip rebuilding that discarded metadata in scalar array operations and concatenation while preserving rank-N dimensions. --- compiler/array.go | 20 +++++++++----------- compiler/array_nd.go | 9 +++++++++ 2 files changed, 18 insertions(+), 11 deletions(-) diff --git a/compiler/array.go b/compiler/array.go index 2dc50d0b..c750b57f 100644 --- a/compiler/array.go +++ b/compiler/array.go @@ -778,17 +778,14 @@ func (c *Compiler) compileArrayConcat(left *Symbol, right *Symbol, leftElem Type c.CopyArrayInto(resVec, right, rightElem, resElem, leftLen, true) } - // Return concatenated array - if arrayType.Rank == 1 { - dimensions = []llvm.Value{totalLen} - } 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).ElemType + arrType := arr.Type.(Array) + arrElem := arrType.ElemType loopLen := c.ArrayLen(arr, arrElem) - dimensions := c.arrayDimensions(arr) + dimensions := c.arrayValueDimensions(arr) resVec := c.CreateArrayForType(resElem, loopLen) c.forEachArrayPair(arr, scalar, loopLen, nil, func(iter llvm.Value, elemSym *Symbol, scalarSym *Symbol) { // Respect the original operand order for non-commutative operations @@ -809,7 +806,7 @@ func (c *Compiler) compileArrayScalarInfix(op string, arr *Symbol, scalar *Symbo c.ArraySetOwnForType(resElem, resVec, iter, resultVal) }) - return c.arrayValueSymbol(resVec, Array{ElemType: resElem, Rank: arr.Type.(Array).Rank}, dimensions) + return c.arrayValueSymbol(resVec, Array{ElemType: resElem, Rank: arrType.Rank}, dimensions) } // compileArrayMask dispatches value-position comparison masking when at least one @@ -861,9 +858,10 @@ func (c *Compiler) compileArrayArrayMask(op string, left *Symbol, right *Symbol, } func (c *Compiler) compileArrayScalarMask(op string, arr *Symbol, scalar *Symbol, resElem Type, arrayOnLeft bool) *Symbol { - arrElem := arr.Type.(Array).ElemType + arrType := arr.Type.(Array) + arrElem := arrType.ElemType loopLen := c.ArrayLen(arr, arrElem) - dimensions := c.arrayDimensions(arr) + dimensions := c.arrayValueDimensions(arr) resVec := c.CreateArrayForType(resElem, loopLen) c.forEachArrayPair(arr, scalar, loopLen, nil, func(iter llvm.Value, elemSym *Symbol, scalarSym *Symbol) { // Preserve operand order for non-commutative comparisons. The masked value @@ -877,7 +875,7 @@ func (c *Compiler) compileArrayScalarMask(op string, arr *Symbol, scalar *Symbol c.maskStore(resElem, resVec, iter, lhs, cond) }) - return c.arrayValueSymbol(resVec, Array{ElemType: resElem, Rank: arr.Type.(Array).Rank}, dimensions) + return c.arrayValueSymbol(resVec, Array{ElemType: resElem, Rank: arrType.Rank}, dimensions) } func (c *Compiler) compileArrayUnaryPrefix(op string, arr *Symbol, result Array) *Symbol { @@ -885,7 +883,7 @@ func (c *Compiler) compileArrayUnaryPrefix(op string, arr *Symbol, result Array) elem := arrType.ElemType resElem := result.ElemType n := c.ArrayLen(arr, elem) - dimensions := c.arrayDimensions(arr) + dimensions := c.arrayValueDimensions(arr) resVec := c.CreateArrayForType(resElem, n) r := c.rangeZeroToN(n) diff --git a/compiler/array_nd.go b/compiler/array_nd.go index 5849df5f..ab9fa29c 100644 --- a/compiler/array_nd.go +++ b/compiler/array_nd.go @@ -191,6 +191,15 @@ func (c *Compiler) arrayDimensions(symbol *Symbol) []llvm.Value { 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) From 0d41d96506a3abee6779034dc12fce883bf01da7 Mon Sep 17 00:00:00 2001 From: Tejas Date: Mon, 20 Jul 2026 00:15:08 +0530 Subject: [PATCH 25/33] refactor(compiler): rely on array length contract Let ArrayLen supply zero for empty and unresolved element types during concatenation. Keep concrete-type checks only around operations that emit typed element access. --- compiler/array.go | 10 ++-------- 1 file changed, 2 insertions(+), 8 deletions(-) diff --git a/compiler/array.go b/compiler/array.go index c750b57f..37b3b795 100644 --- a/compiler/array.go +++ b/compiler/array.go @@ -753,14 +753,8 @@ func (c *Compiler) compileArrayConcat(left *Symbol, right *Symbol, leftElem Type } // Get lengths of both arrays - leftLen := c.ConstI64(0) - if hasConcreteArrayElemType(leftElem) { - leftLen = c.ArrayLen(left, leftElem) - } - rightLen := c.ConstI64(0) - if hasConcreteArrayElemType(rightElem) { - rightLen = c.ArrayLen(right, rightElem) - } + leftLen := c.ArrayLen(left, leftElem) + rightLen := c.ArrayLen(right, rightElem) // Calculate total length totalLen := c.builder.CreateAdd(leftLen, rightLen, "concat_len") From bf32f49951f49301b0e31ccdbb2de6de5b769909 Mon Sep 17 00:00:00 2001 From: Tejas Date: Mon, 20 Jul 2026 17:01:14 +0530 Subject: [PATCH 26/33] feat(compiler)!: distinguish inline and block array layouts Record whether an array literal starts in block layout. Inline literals contribute one axis and use backslash continuation, while blocks contribute row and column axes even when they contain one row. Carry the layout through rank inference and code generation, and render rank-N arrays in canonical two-axis blocks. BREAKING CHANGE: A one-row block array now has rank 2. Use inline syntax for a rank-1 array. --- README.md | 19 +++++----- ast/ast.go | 10 ++++-- compiler/array.go | 36 ++++++++++++++----- compiler/array_nd.go | 14 ++------ compiler/cond.go | 2 +- compiler/solver.go | 57 +++++++++++++++++------------ docs/Pluto Array Semantics.md | 60 ++++++++++++++++++++++++------- docs/Pluto Range Semantics.md | 10 +++--- lexer/lexer.go | 25 +++++++------ parser/parser.go | 8 +++++ parser/scriptparser_test.go | 26 +++++++++++--- runtime/array.c | 29 +++++++-------- tests/array/array.exp | 27 +++++--------- tests/array/array.spt | 40 +++++++++------------ tests/array/array_elementwise.exp | 5 +-- tests/array/array_elementwise.spt | 22 ++++-------- 16 files changed, 226 insertions(+), 164 deletions(-) diff --git a/README.md b/README.md index a4a35553..ab12a650 100644 --- a/README.md +++ b/README.md @@ -206,14 +206,8 @@ matrix = [ ] cube = [ - [ - 1 2 - 3 4 - ] - [ - 5 6 - 7 8 - ] + [1 2] [3 4] + [5 6] [7 8] ] scores = [ @@ -223,9 +217,12 @@ scores = [ ] ``` -One scalar row is rank 1; multiple rectangular scalar rows are rank 2; and -equal-shaped array cells stack into higher ranks. Storage is flat and -row-major, and ragged literals are compile errors. +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 diff --git a/ast/ast.go b/ast/ast.go index 18c10cdd..5afd37c6 100644 --- a/ast/ast.go +++ b/ast/ast.go @@ -300,6 +300,7 @@ func (rl *RangeLiteral) String() string { type ArrayLiteral struct { Token token.Token // the '[' token + 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,8 +311,10 @@ 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 && len(al.Rows) == 1 { - writeArrayRow(&out, al.Rows[0]) + if len(al.Headers) == 0 && !al.Block { + if len(al.Rows) == 1 { + writeArrayRow(&out, al.Rows[0]) + } out.WriteString("]") return out.String() } @@ -333,7 +336,7 @@ func (al *ArrayLiteral) String() string { writeArrayRow(&out, row) } - if len(al.Headers) > 0 || len(al.Rows) > 0 { + if al.Block || len(al.Headers) > 0 || len(al.Rows) > 0 { out.WriteString("\n") } out.WriteString("]") @@ -633,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 37b3b795..67cb07d2 100644 --- a/compiler/array.go +++ b/compiler/array.go @@ -294,22 +294,32 @@ func (c *Compiler) compileArrayExpression(e *ast.ArrayLiteral, _ []*ast.Identifi } func (c *Compiler) compileArray(lit *ast.ArrayLiteral, info *ExprInfo, arrayType Array) *Symbol { - if arrayType.Rank == 1 { + if !arrayLiteralHasArrayCells(lit, arrayType) { return c.compileArrayLiteralInDomain(lit, info, nil, nil) } - if !c.arrayLiteralHasArrayCells(lit) { - return c.compileRectangularArrayLiteral(lit, arrayType) - } if len(info.CollectRanges) == 0 { return c.compileStackedArrayLiteral(lit, arrayType) } return c.compileStackedArrayCollector(lit, info, arrayType) } -func (c *Compiler) arrayLiteralHasArrayCells(lit *ast.ArrayLiteral) bool { - // The solver rejects literals that mix scalar and array-valued cells. - firstCell := lit.Rows[0][0] - return c.ExprCache[key(c.FuncNameMangled, firstCell)].OutTypes[0].Kind() == ArrayKind +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. @@ -395,7 +405,15 @@ func (c *Compiler) compileArrayLiteralWithLoops(lit *ast.ArrayLiteral, info *Exp 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.compileFixedArrayLiteral(lit, info.OutTypes[0].(Array), nil) + 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) } diff --git a/compiler/array_nd.go b/compiler/array_nd.go index ab9fa29c..9c40520b 100644 --- a/compiler/array_nd.go +++ b/compiler/array_nd.go @@ -8,16 +8,6 @@ import ( "tinygo.org/x/go-llvm" ) -func (c *Compiler) compileRectangularArrayLiteral(lit *ast.ArrayLiteral, arrayType Array) *Symbol { - rows := len(lit.Rows) - cols := 0 - if rows > 0 { - cols = len(lit.Rows[0]) - } - dimensions := []llvm.Value{c.ConstI64(uint64(rows)), c.ConstI64(uint64(cols))} - return c.compileFixedArrayLiteral(lit, arrayType, dimensions) -} - func (c *Compiler) compileStackedArrayLiteral(lit *ast.ArrayLiteral, arrayType Array) *Symbol { children := make([]ast.Expression, 0) for _, row := range lit.Rows { @@ -44,7 +34,9 @@ func (c *Compiler) compileStackedArrayLiteral(lit *ast.ArrayLiteral, arrayType A } dimensions := make([]llvm.Value, 0, arrayType.Rank) - dimensions = append(dimensions, c.ConstI64(uint64(len(childSymbols)))) + 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)} diff --git a/compiler/cond.go b/compiler/cond.go index 7e65dbb8..2a7e23b5 100644 --- a/compiler/cond.go +++ b/compiler/cond.go @@ -979,7 +979,7 @@ func (c *Compiler) compileCondRangedStatement(stmt *ast.LetStatement, condRanges 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 { + if lit, ok := expr.(*ast.ArrayLiteral); ok && !lit.Block && 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))) diff --git a/compiler/solver.go b/compiler/solver.go index 2e1b5c82..f298721f 100644 --- a/compiler/solver.go +++ b/compiler/solver.go @@ -424,9 +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 single-row literals act as collectors. Multiple source rows describe - // a statically rectangular array or table instead. - 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 al.Block || len(al.Headers) > 0 || len(al.Rows) != 1 { info.Ranges = nil info.CollectRanges = nil info.Rewrite = al @@ -451,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), @@ -951,10 +952,12 @@ func (ts *TypeSolver) TypeLetStatement(stmt *ast.LetStatement) { } // TypeArrayExpression classifies bracket literals as rectangular arrays or -// tables. Array rank is inferred from row layout and nested array-valued cells. +// 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 { if len(al.Headers) == 0 && len(al.Rows) == 0 { - return ts.cacheArrayLiteralType(al, Array{ElemType: Empty{}, Rank: 1}, []uint64{0}) + shape := arrayLiteralLayoutShape(al) + return ts.cacheArrayLiteralType(al, Array{ElemType: Empty{}, Rank: len(shape)}, shape) } if len(al.Headers) > 0 { @@ -968,8 +971,15 @@ func (ts *TypeSolver) TypeArrayExpression(al *ast.ArrayLiteral) []Type { hasArrayCells := false hasScalarCells := false - for _, row := range cellTypes { - for _, cellType := range row { + 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 { @@ -1023,16 +1033,17 @@ func (ts *TypeSolver) typeBracketLiteralCells(al *ast.ArrayLiteral) ([][]Type, b } func (ts *TypeSolver) typeScalarBracketLiteral(al *ast.ArrayLiteral, cellTypes [][]Type) []Type { - if len(al.Rows) == 1 { + if !al.Block { elemType := Type(Unresolved{}) - shape := []uint64{uint64(len(al.Rows[0]))} + 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: 1}, shape) + return ts.cacheArrayLiteralType(al, Array{ElemType: elemType, Rank: len(layoutShape)}, shape) } numCols := len(al.Rows[0]) @@ -1041,32 +1052,32 @@ func (ts *TypeSolver) typeScalarBracketLiteral(al *ast.ArrayLiteral, cellTypes [ } colTypes := ts.initColTypes(numCols) - for rowIndex, row := range cellTypes { + for _, row := range cellTypes { for col, cellType := range row { - if ts.ExprCache[key(ts.FuncNameMangled, al.Rows[rowIndex][col])].HasRanges { - ts.Errors = append(ts.Errors, &token.CompileError{ - Token: al.Rows[rowIndex][col].Tok(), - Msg: "multidimensional array rows require statically sized cells", - }) - } colTypes[col] = ts.mergeColType(colTypes[col], cellType, col, al.Tok()) } } if elemType, ok := commonArrayElemType(colTypes); ok { - shape := []uint64{uint64(len(al.Rows)), uint64(numCols)} - return ts.cacheArrayLiteralType(al, Array{ElemType: elemType, Rank: 2}, shape) + 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 { - children = append(children, al.Rows[rowIndex][colIndex]) + child := al.Rows[rowIndex][colIndex] + children = append(children, child) childTypes = append(childTypes, cellType.(Array)) } } @@ -1102,11 +1113,13 @@ func (ts *TypeSolver) typeStackedArrayLiteral(al *ast.ArrayLiteral, cellTypes [] } } + layoutShape := arrayLiteralLayoutShape(al) + var shape []uint64 if shapeKnown && childShape != nil { - shape = append([]uint64{uint64(len(children))}, childShape...) + shape = append(layoutShape, childShape...) } - return ts.cacheArrayLiteralType(al, Array{ElemType: elemType, Rank: first.Rank + 1}, shape) + return ts.cacheArrayLiteralType(al, Array{ElemType: elemType, Rank: first.Rank + len(layoutShape)}, shape) } func (ts *TypeSolver) mergeArrayLeafType(current, next Type, tok token.Token) Type { diff --git a/docs/Pluto Array Semantics.md b/docs/Pluto Array Semantics.md index 6772b225..5aca1d01 100644 --- a/docs/Pluto Array Semantics.md +++ b/docs/Pluto Array Semantics.md @@ -12,9 +12,22 @@ 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. -- Multiple homogeneous scalar rows infer a rank-2 array. -- Array-valued cells of one rank and shape stack into an array one rank higher. +- 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: @@ -31,17 +44,39 @@ Nesting composes for higher ranks: ```pluto cube = [ - [ - 1 2 - 3 4 - ] - [ - 5 6 - 7 8 - ] + [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: @@ -53,8 +88,9 @@ arr = [ ] ``` -Ranges inside a rank-1 literal remain collectors and may determine its runtime -length. Array values are nested rather than flattened when used as cells. +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 diff --git a/docs/Pluto Range Semantics.md b/docs/Pluto Range Semantics.md index 63752b7f..c7eeef35 100644 --- a/docs/Pluto Range Semantics.md +++ b/docs/Pluto Range Semantics.md @@ -107,11 +107,11 @@ assignment root a skipped iteration keeps the destination, so the last ## One-Dimensional Array Literals -A headerless bracket literal with one scalar row materializes a rank-1 array at -the point where it appears. Rectangular literals with multiple scalar rows and -literals containing array-valued cells infer higher-rank arrays; they do not -use the collector behavior in this section. Heterogeneous rectangular literals -may infer tables instead. +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: 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/parser.go b/parser/parser.go index 8bde488b..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() @@ -1155,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 a0a5c20b..e6f2ae53 100644 --- a/parser/scriptparser_test.go +++ b/parser/scriptparser_test.go @@ -1146,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) }, }, { @@ -1156,6 +1157,7 @@ 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") @@ -1172,6 +1174,17 @@ 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: `[ @@ -1235,12 +1248,11 @@ func TestArrayLiterals(t *testing.T) { }, { 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") @@ -1263,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]`, diff --git a/runtime/array.c b/runtime/array.c index 07d0a284..36de2a6a 100644 --- a/runtime/array.c +++ b/runtime/array.c @@ -366,26 +366,27 @@ static int strbuf_array_nd(StrBuf* sb, const void* values, int32_t kind, size_t 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) { + } 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; - if (dimensions[1] == 0) { - if (strbuf_printf(sb, "[]") < 0) return -1; - continue; - } for (size_t col = 0; col < dimensions[1]; ++col) { if (col > 0 && strbuf_printf(sb, " ") < 0) return -1; - if (strbuf_array_cell(sb, values, kind, (*value_index)++) < 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 (dimensions[0] > 0 && strbuf_indent(sb, depth) < 0) return -1; - } else { - for (size_t i = 0; i < dimensions[0]; ++i) { - if (strbuf_indent(sb, depth + 1) < 0) return -1; - if (strbuf_array_nd(sb, values, kind, rank - 1, dimensions + 1, - depth + 1, value_index) < 0) return -1; - } - if (dimensions[0] > 0 && strbuf_indent(sb, depth) < 0) return -1; + if (strbuf_indent(sb, depth) < 0) return -1; } return strbuf_printf(sb, "]"); diff --git a/tests/array/array.exp b/tests/array/array.exp index f6ee4803..eaae80a5 100644 --- a/tests/array/array.exp +++ b/tests/array/array.exp @@ -15,6 +15,9 @@ 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 @@ -42,24 +45,12 @@ reset matrix: [ 5 6 ] rank 3: [ - [ - 1 2 - 3 4 - ] - [ - 5 6 - 7 8 - ] + [1 2] [3 4] + [5 6] [7 8] ] rank 3 plus: [ - [ - 2 3 - 4 5 - ] - [ - 6 7 - 8 9 - ] + [2 3] [4 5] + [6 7] [8 9] ] rank 3 cell: 6 string matrix: [ @@ -79,9 +70,9 @@ replacement table: [ ] saved table scores: [10 12] empty array: [] -rank 2 empty: [ - [] +empty matrix: [ ] +rank 2 empty: [[]] rank 2 refined: [ 9 ] diff --git a/tests/array/array.spt b/tests/array/array.spt index 7f616e78..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,13 +28,17 @@ 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 @@ -75,14 +70,8 @@ nestedMatrix = nestedMatrix ⊕ [[5 6]] "reset matrix: -nestedMatrix" rank3 = [ - [ - 1 2 - 3 4 - ] - [ - 5 6 - 7 8 - ] + [1 2] [3 4] + [5 6] [7 8] ] "rank 3: -rank3" rank3Plus = rank3 + 1 @@ -117,6 +106,9 @@ scores = emptyArray = [] "empty array: -emptyArray" +emptyMatrix = [ +] +"empty matrix: -emptyMatrix" rank2Refined = [[]] "rank 2 empty: -rank2Refined" rank2Refined = [[9]] diff --git a/tests/array/array_elementwise.exp b/tests/array/array_elementwise.exp index fbaa4265..4ca8762d 100644 --- a/tests/array/array_elementwise.exp +++ b/tests/array/array_elementwise.exp @@ -25,8 +25,5 @@ Rank 2 zip: [ 34 45 ] Rank 3 zip: [ - [ - 101 202 - 304 405 - ] + [101 202] [304 405] ] diff --git a/tests/array/array_elementwise.spt b/tests/array/array_elementwise.spt index 8a2d7885..9da1b97e 100644 --- a/tests/array/array_elementwise.spt +++ b/tests/array/array_elementwise.spt @@ -114,21 +114,13 @@ 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 - ] + [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" From fb34a0877e5f5fe715f73cf33ababa8f32e9301d Mon Sep 17 00:00:00 2001 From: Tejas Date: Mon, 20 Jul 2026 17:12:41 +0530 Subject: [PATCH 27/33] docs: clarify array whitespace semantics Limit the non-semantic whitespace statement to spacing within table rows now that the newline after an opening bracket selects block array layout. --- README.md | 3 ++- docs/Pluto Array Semantics.md | 3 ++- 2 files changed, 4 insertions(+), 2 deletions(-) diff --git a/README.md b/README.md index ab12a650..9ab0d8ff 100644 --- a/README.md +++ b/README.md @@ -226,7 +226,8 @@ 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; whitespace remains non-semantic. +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. diff --git a/docs/Pluto Array Semantics.md b/docs/Pluto Array Semantics.md index 5aca1d01..a303d56c 100644 --- a/docs/Pluto Array Semantics.md +++ b/docs/Pluto Array Semantics.md @@ -99,7 +99,8 @@ 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. Whitespace remains non-semantic: +value begin in the same column. Spacing within header and data rows is +otherwise non-semantic: ```pluto scores = [ From ab24805bbc1016d288c983068dab3251f3790ef3 Mon Sep 17 00:00:00 2001 From: Tejas Date: Mon, 20 Jul 2026 20:58:20 +0530 Subject: [PATCH 28/33] fix(compiler): unify ranged array domain lowering Route scalar-cell and stacked array literals through one domain-aware dispatcher while retaining exact allocation for fixed stacked values. Reuse stacked accumulators inside shared statement gates so rank-N collectors preserve shape and replace prior values on empty results.\n\nMake ranged guards read the same loop-carried destination slots as RHS staging, fixing self-referential max/min folds. Add leak-checked integration coverage for gated rank-N collectors and unsorted extrema. --- compiler/array.go | 72 ++++++++++++---------------- compiler/array_nd.go | 87 +++++++++++++++++++++++++--------- compiler/collect.go | 5 +- compiler/cond.go | 96 +++++++++++++++++++++++++++----------- compiler/solver.go | 2 +- tests/array/cond_accum.exp | 11 +++++ tests/array/cond_accum.spt | 26 +++++++++++ 7 files changed, 203 insertions(+), 96 deletions(-) diff --git a/compiler/array.go b/compiler/array.go index 67cb07d2..cb8e7542 100644 --- a/compiler/array.go +++ b/compiler/array.go @@ -281,7 +281,7 @@ func (c *Compiler) compileArrayExpression(e *ast.ArrayLiteral, _ []*ast.Identifi lit, info := c.resolveArrayLiteralRewrite(e) switch typ := info.OutTypes[0].(type) { case Array: - return []*Symbol{c.compileArray(lit, info, typ)} + return []*Symbol{c.compileArrayInDomain(lit, info, typ, nil, nil)} case Table: return []*Symbol{c.compileTable(lit, typ)} default: @@ -293,14 +293,22 @@ func (c *Compiler) compileArrayExpression(e *ast.ArrayLiteral, _ []*ast.Identifi } } -func (c *Compiler) compileArray(lit *ast.ArrayLiteral, info *ExprInfo, arrayType Array) *Symbol { +func (c *Compiler) compileArrayInDomain( + lit *ast.ArrayLiteral, + info *ExprInfo, + arrayType Array, + gateRanges []*RangeInfo, + condExprs []ast.Expression, +) *Symbol { + ranges := mergeUses(gateRanges, info.CollectRanges) if !arrayLiteralHasArrayCells(lit, arrayType) { - return c.compileArrayLiteralInDomain(lit, info, nil, nil) + return c.compileScalarArray(lit, info, ranges, condExprs) } - if len(info.CollectRanges) == 0 { - return c.compileStackedArrayLiteral(lit, arrayType) - } - return c.compileStackedArrayCollector(lit, info, arrayType) + 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 { @@ -365,11 +373,16 @@ func (c *Compiler) compileFixedArrayLiteral(lit *ast.ArrayLiteral, arrayType Arr 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 { @@ -377,23 +390,7 @@ func (c *Compiler) compileArrayLiteralWithLoops(lit *ast.ArrayLiteral, info *Exp 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) } @@ -402,9 +399,8 @@ 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 { +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 { @@ -415,7 +411,7 @@ func (c *Compiler) compileArrayLiteralInDomain(lit *ast.ArrayLiteral, info *Expr } 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 @@ -497,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, diff --git a/compiler/array_nd.go b/compiler/array_nd.go index 9c40520b..b58baba9 100644 --- a/compiler/array_nd.go +++ b/compiler/array_nd.go @@ -8,7 +8,14 @@ import ( "tinygo.org/x/go-llvm" ) -func (c *Compiler) compileStackedArrayLiteral(lit *ast.ArrayLiteral, arrayType Array) *Symbol { +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...) @@ -70,14 +77,26 @@ func (c *Compiler) compileArrayValuedCell(child ast.Expression) []*Symbol { return compiled } -func (c *Compiler) compileStackedArrayCollector(lit *ast.ArrayLiteral, info *ExprInfo, arrayType Array) *Symbol { - if !hasConcreteArrayElemType(arrayType.ElemType) { - return c.makeZeroValue(arrayType) +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() { + c.appendStackedArrayLiteral(acc, lit) + }) + 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. - acc := c.NewArrayAccumulator(Array{ElemType: arrayType.ElemType, Rank: 1}) outerLenSlot := c.createEntryBlockAlloca(c.mapToLLVMType(I64), "stacked_array_outer_len_mem") c.createStore(c.ConstI64(0), outerLenSlot, I64) @@ -87,27 +106,49 @@ func (c *Compiler) compileStackedArrayCollector(lit *ast.ArrayLiteral, info *Exp c.createStore(c.ConstI64(0), childDimSlots[i], I64) } - c.withCollectorLoopNest(info.CollectRanges, lit, nil, func() { - for _, row := range lit.Rows { - for _, child := range row { - c.appendStackedArrayChild(acc, outerLenSlot, childDimSlots, child) - } + 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) appendStackedArrayLiteral(acc *stackedArrayAccumulator, lit *ast.ArrayLiteral) { + for _, row := range lit.Rows { + for _, child := range row { + c.appendStackedArrayChild(acc, child) } + } +} + +func (c *Compiler) appendStackedArrayCollector(acc *stackedArrayAccumulator, lit *ast.ArrayLiteral) { + c.withPendingLiteralRanges(lit, func(resolved *ast.ArrayLiteral) { + c.appendStackedArrayLiteral(acc, resolved) }) +} - dimensions := make([]llvm.Value, 0, arrayType.Rank) - dimensions = append(dimensions, c.createLoad(outerLenSlot, I64, "stacked_array_outer_len")) - for _, slot := range childDimSlots { +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")) } - return &Symbol{Type: arrayType, Val: c.createArrayValue(acc.Vec, dimensions, arrayType)} + 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 *ArrayAccumulator, - outerLenSlot llvm.Value, - childDimSlots []llvm.Value, + acc *stackedArrayAccumulator, child ast.Expression, ) { compiled := c.compileArrayValuedCell(child) @@ -122,25 +163,25 @@ func (c *Compiler) appendStackedArrayChild( childSymbol := c.derefIfPointer(compiled[0], "stacked_array_child") childType := childSymbol.Type.(Array) childDimensions := c.arrayDimensions(childSymbol) - outerLen := c.createLoad(outerLenSlot, I64, "stacked_array_outer_len") + 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(childDimSlots[i], I64, "stacked_array_dimension") + 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"), childDimSlots[i], I64) + c.createStore(c.builder.CreateSelect(firstChild, dimension, stored, "stacked_array_dimension_value"), acc.childDimSlots[i], I64) } - if hasConcreteArrayElemType(childType.ElemType) { + 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, &Symbol{Val: value, Type: childType.ElemType}, false, acc.ElemType) + 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"), outerLenSlot, I64) + c.createStore(c.builder.CreateAdd(outerLen, c.ConstI64(1), "stacked_array_outer_len_next"), acc.outerLenSlot, I64) c.freeTemporary(child, []*Symbol{childSymbol}) } diff --git a/compiler/collect.go b/compiler/collect.go index dd6ffea8..ce544375 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.compileArrayInDomain(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/cond.go b/compiler/cond.go index 2a7e23b5..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 && !lit.Block && 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/solver.go b/compiler/solver.go index f298721f..8ba81f89 100644 --- a/compiler/solver.go +++ b/compiler/solver.go @@ -426,7 +426,7 @@ func (ts *TypeSolver) HandleArrayLiteralRanges(al *ast.ArrayLiteral) ([]*RangeIn // Only inline literals act as collectors. Block rows describe a statically // rectangular array or table even when the block contains a single row. - if al.Block || len(al.Headers) > 0 || len(al.Rows) != 1 { + if !isInlineArrayCollector(al) { info.Ranges = nil info.CollectRanges = nil info.Rewrite = al 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] From 141d7961bb2c3e3b616d17db270da80c81a0abe3 Mon Sep 17 00:00:00 2001 From: Tejas Date: Mon, 20 Jul 2026 21:07:23 +0530 Subject: [PATCH 29/33] refactor(compiler): simplify array compiler naming --- compiler/array.go | 4 ++-- compiler/collect.go | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/compiler/array.go b/compiler/array.go index cb8e7542..cd947103 100644 --- a/compiler/array.go +++ b/compiler/array.go @@ -281,7 +281,7 @@ func (c *Compiler) compileArrayExpression(e *ast.ArrayLiteral, _ []*ast.Identifi lit, info := c.resolveArrayLiteralRewrite(e) switch typ := info.OutTypes[0].(type) { case Array: - return []*Symbol{c.compileArrayInDomain(lit, info, typ, nil, nil)} + return []*Symbol{c.compileArray(lit, info, typ, nil, nil)} case Table: return []*Symbol{c.compileTable(lit, typ)} default: @@ -293,7 +293,7 @@ func (c *Compiler) compileArrayExpression(e *ast.ArrayLiteral, _ []*ast.Identifi } } -func (c *Compiler) compileArrayInDomain( +func (c *Compiler) compileArray( lit *ast.ArrayLiteral, info *ExprInfo, arrayType Array, diff --git a/compiler/collect.go b/compiler/collect.go index ce544375..719f77af 100644 --- a/compiler/collect.go +++ b/compiler/collect.go @@ -64,7 +64,7 @@ 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) arrayType := info.OutTypes[0].(Array) - sym := c.compileArrayInDomain(resolved, info, arrayType, gateRanges, condExprs) + sym := c.compileArray(resolved, info, arrayType, gateRanges, condExprs) ident, temp := c.newMaterializedCollectorTemp(sym) return ident, []string{temp} } From 07bd47c00762bd43f4347c578a3a4234014fb24c Mon Sep 17 00:00:00 2001 From: Tejas Date: Mon, 20 Jul 2026 23:45:57 +0530 Subject: [PATCH 30/33] refactor(compiler): rely on solved array cell arity --- compiler/array_nd.go | 32 ++++++-------------------------- 1 file changed, 6 insertions(+), 26 deletions(-) diff --git a/compiler/array_nd.go b/compiler/array_nd.go index b58baba9..301b3e9b 100644 --- a/compiler/array_nd.go +++ b/compiler/array_nd.go @@ -1,10 +1,7 @@ package compiler import ( - "fmt" - "github.com/thiremani/pluto/ast" - "github.com/thiremani/pluto/token" "tinygo.org/x/go-llvm" ) @@ -23,15 +20,7 @@ func (c *Compiler) compileFixedStackedArray(lit *ast.ArrayLiteral, arrayType Arr childSymbols := make([]*Symbol, 0, len(children)) for _, child := range children { - compiled := c.compileArrayValuedCell(child) - if len(compiled) != 1 { - c.Errors = append(c.Errors, &token.CompileError{ - Token: child.Tok(), - Msg: fmt.Sprintf("array cell produced %d values; expected 1", len(compiled)), - }) - return c.makeZeroValue(arrayType) - } - childSymbols = append(childSymbols, c.derefIfPointer(compiled[0], "stacked_array_child")) + childSymbols = append(childSymbols, c.compileArrayValuedCell(child)) } childType := childSymbols[0].Type.(Array) @@ -69,12 +58,12 @@ func (c *Compiler) compileFixedStackedArray(lit *ast.ArrayLiteral, arrayType Arr return &Symbol{Type: arrayType, Val: c.createArrayValue(data, dimensions, arrayType)} } -func (c *Compiler) compileArrayValuedCell(child ast.Expression) []*Symbol { - var compiled []*Symbol +func (c *Compiler) compileArrayValuedCell(child ast.Expression) *Symbol { + var compiled *Symbol c.withArrayLiteralCellMode(func() { - compiled = c.compileExpression(child, nil) + compiled = c.compileExpression(child, nil)[0] }) - return compiled + return c.derefIfPointer(compiled, "stacked_array_child") } func (c *Compiler) compileStackedArray( @@ -151,16 +140,7 @@ func (c *Compiler) appendStackedArrayChild( acc *stackedArrayAccumulator, child ast.Expression, ) { - compiled := c.compileArrayValuedCell(child) - if len(compiled) != 1 { - c.Errors = append(c.Errors, &token.CompileError{ - Token: child.Tok(), - Msg: fmt.Sprintf("array cell produced %d values; expected 1", len(compiled)), - }) - return - } - - childSymbol := c.derefIfPointer(compiled[0], "stacked_array_child") + childSymbol := c.compileArrayValuedCell(child) childType := childSymbol.Type.(Array) childDimensions := c.arrayDimensions(childSymbol) outerLen := c.createLoad(acc.outerLenSlot, I64, "stacked_array_outer_len") From f33841e28cdb6d964122b321acff33dbf343cc33 Mon Sep 17 00:00:00 2001 From: Tejas Date: Tue, 21 Jul 2026 17:56:54 +0530 Subject: [PATCH 31/33] refactor(compiler): inline stacked array traversal Traverse resolved literal children directly at each domain boundary, removing the trivial append wrapper while preserving range ownership. --- compiler/array_nd.go | 16 ++++++---------- 1 file changed, 6 insertions(+), 10 deletions(-) diff --git a/compiler/array_nd.go b/compiler/array_nd.go index 301b3e9b..91c8afb4 100644 --- a/compiler/array_nd.go +++ b/compiler/array_nd.go @@ -78,7 +78,9 @@ func (c *Compiler) compileStackedArray( acc := c.newStackedArrayAccumulator(arrayType) c.withCollectorDomain(ranges, lit, condExprs, func() { - c.appendStackedArrayLiteral(acc, lit) + for _, child := range ast.ExprChildren(lit) { + c.appendStackedArrayChild(acc, child) + } }) return c.stackedArrayAccumulatorResult(acc) } @@ -108,17 +110,11 @@ func (c *Compiler) newStackedArrayAccumulator(arrayType Array) *stackedArrayAccu } } -func (c *Compiler) appendStackedArrayLiteral(acc *stackedArrayAccumulator, lit *ast.ArrayLiteral) { - for _, row := range lit.Rows { - for _, child := range row { - c.appendStackedArrayChild(acc, child) - } - } -} - func (c *Compiler) appendStackedArrayCollector(acc *stackedArrayAccumulator, lit *ast.ArrayLiteral) { c.withPendingLiteralRanges(lit, func(resolved *ast.ArrayLiteral) { - c.appendStackedArrayLiteral(acc, resolved) + for _, child := range ast.ExprChildren(resolved) { + c.appendStackedArrayChild(acc, child) + } }) } From a547ae338ff4615bda8a962414ec2468d24b1d55 Mon Sep 17 00:00:00 2001 From: Tejas Date: Tue, 21 Jul 2026 20:17:05 +0530 Subject: [PATCH 32/33] docs: refine PIR execution and transfer model Keep PIR statement-focused with semantic domains and commits while adopting a conventional structured IR text form. Document stable outcome mappings, simultaneous swaps and carry advancement, and ownership-safe transfer across commit groups. --- docs/Pluto IR Plan.md | 314 ++++++++++++++++++++++++++++-------------- 1 file changed, 210 insertions(+), 104 deletions(-) diff --git a/docs/Pluto IR Plan.md b/docs/Pluto IR Plan.md index 8e7d2493..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. @@ -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, statement gates, value-position `&&`, 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,9 +117,8 @@ 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 | +| `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 | @@ -119,24 +127,26 @@ PIR should use structured control plus a small Pluto-specific vocabulary: | `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 @@ -157,7 +167,7 @@ Zero is never a missing-value marker. A successful comparison may yield zero, 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 `set` resolve it according +`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 @@ -166,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: @@ -182,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 @@ -197,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. @@ -239,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 @@ -257,13 +303,17 @@ 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 `value-and` or fallback, a @@ -273,9 +323,14 @@ 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. @@ -301,7 +356,7 @@ 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: @@ -319,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: @@ -355,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 @@ -383,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 - value-and - 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: @@ -437,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 @@ -459,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 `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 `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. @@ -500,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, @@ -514,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 value-and, 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. @@ -524,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. @@ -580,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 @@ -634,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 @@ -646,7 +752,7 @@ 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 @@ -663,7 +769,7 @@ The statement plan can grow without becoming a machine IR: 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 From b1b9954b97cd8c8b9bd872cfd0be31e5c62227d4 Mon Sep 17 00:00:00 2001 From: Tejas Date: Wed, 22 Jul 2026 00:24:21 +0530 Subject: [PATCH 33/33] feat(compiler): allow operations on untyped empty arrays Treat element-wise operations over Empty arrays as vacuous while validating operators once a concrete context resolves the leaf type. Lower empty arithmetic and comparison results without selecting an element runtime or emitting element loops. --- compiler/array.go | 16 ++++++++++++-- compiler/solver.go | 39 ++++++++++++++++++++++++++--------- compiler/solver_test.go | 6 +++--- docs/Pluto Array Semantics.md | 3 ++- tests/array/array.exp | 3 +++ tests/array/array.spt | 6 ++++++ 6 files changed, 57 insertions(+), 16 deletions(-) diff --git a/compiler/array.go b/compiler/array.go index cd947103..bee4c878 100644 --- a/compiler/array.go +++ b/compiler/array.go @@ -706,6 +706,11 @@ func (c *Compiler) compileArrayArrayInfix(op string, left *Symbol, right *Symbol // 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) + arrayType := Array{ElemType: resElem, Rank: leftArrType.Rank} + if leftElem.Kind() == EmptyKind || rightElem.Kind() == EmptyKind { + return c.arrayValueSymbol(llvm.Value{}, arrayType, dimensions) + } + resVec := c.CreateArrayForType(resElem, loopLen) c.forEachArrayPair(left, right, loopLen, dimensions, func(iter llvm.Value, leftSym *Symbol, rightSym *Symbol) { // compileInfix reads values and produces a new result @@ -722,7 +727,7 @@ func (c *Compiler) compileArrayArrayInfix(op string, left *Symbol, right *Symbol }) // Return result array - return c.arrayValueSymbol(resVec, Array{ElemType: resElem, Rank: leftArrType.Rank}, dimensions) + return c.arrayValueSymbol(resVec, arrayType, dimensions) } func (c *Compiler) compileArrayConcat(left *Symbol, right *Symbol, leftElem Type, rightElem Type, resElem Type) *Symbol { @@ -846,13 +851,20 @@ 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, dimensions := c.arrayPairShape(left, right) + arrayType := Array{ElemType: resElem, Rank: left.Type.(Array).Rank} + leftElem := left.Type.(Array).ElemType + rightElem := right.Type.(Array).ElemType + if leftElem.Kind() == EmptyKind || rightElem.Kind() == EmptyKind { + return c.arrayValueSymbol(llvm.Value{}, arrayType, dimensions) + } + resVec := c.CreateArrayForType(resElem, loopLen) 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.arrayValueSymbol(resVec, Array{ElemType: resElem, Rank: left.Type.(Array).Rank}, dimensions) + return c.arrayValueSymbol(resVec, arrayType, dimensions) } func (c *Compiler) compileArrayScalarMask(op string, arr *Symbol, scalar *Symbol, resElem Type, arrayOnLeft bool) *Symbol { diff --git a/compiler/solver.go b/compiler/solver.go index 8ba81f89..28a3ad90 100644 --- a/compiler/solver.go +++ b/compiler/solver.go @@ -230,10 +230,18 @@ func (ts *TypeSolver) concatArrayTypes(leftArr, rightArr Array, tok token.Token) } func (ts *TypeSolver) bindArrayOperand(expr ast.Expression, arr Array) { - // 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. + // Update the ExprCache with the result's refined array element type. + // handleInfixArrays only calls this when expr is already array-typed. info := ts.ExprCache[key(ts.FuncNameMangled, expr)] + current := info.OutTypes[0].(Array) + infix, isInfix := expr.(*ast.InfixExpression) + refinesEmptyInfix := isInfix && current.ElemType.Kind() == EmptyKind && hasConcreteArrayElemType(arr.ElemType) + if refinesEmptyInfix { + // A standalone empty operation is vacuous. If a parent supplies a leaf + // type, validate that the original operation supports that type before + // refining its operands for codegen. + ts.TypeArrayInfix(arr, arr, infix.Operator, infix.Token) + } info.OutTypes[0] = arr if ident, ok := expr.(*ast.Identifier); ok { @@ -1718,6 +1726,15 @@ func (ts *TypeSolver) typeInfixArrayMask(leftType, rightType Type, op string, to if rightType.Kind() == ArrayKind { cmpRight = rightType.(Array).ElemType } + if cmpLeft.Kind() == EmptyKind && cmpRight.Kind() == EmptyKind { + return + } + if cmpLeft.Kind() == EmptyKind { + cmpLeft = cmpRight + } + if cmpRight.Kind() == EmptyKind { + cmpRight = cmpLeft + } ts.TypeInfixOp(cmpLeft, cmpRight, op, tok) } @@ -2029,18 +2046,20 @@ func (ts *TypeSolver) resolveArrayElemTypes(leftElem, rightElem Type, op string, 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{} + return Empty{} } if leftEmpty { - return rightElem + if rightElem.Kind() == UnresolvedKind { + return rightElem + } + return ts.TypeInfixOp(rightElem, rightElem, op, tok) } if rightEmpty { - return leftElem + if leftElem.Kind() == UnresolvedKind { + return leftElem + } + return ts.TypeInfixOp(leftElem, leftElem, op, tok) } leftUnresolved := leftElem.Kind() == UnresolvedKind diff --git a/compiler/solver_test.go b/compiler/solver_test.go index 00c978fe..19aaa04b 100644 --- a/compiler/solver_test.go +++ b/compiler/solver_test.go @@ -322,9 +322,9 @@ func TestCollectionTypeErrors(t *testing.T) { expectError: "cannot index an empty array without an element type", }, { - name: "EmptyArrayOperator", - script: "result = [] + []", - expectError: `operator "+" on empty arrays requires an element type`, + name: "ContextualEmptyArrayOperator", + script: `result = ([] + []) ⊕ ["x"]`, + expectError: "for types: Str, Str", }, { name: "ArrayTypeStaysLockedAfterEmptyReset", diff --git a/docs/Pluto Array Semantics.md b/docs/Pluto Array Semantics.md index a303d56c..f6b7d7df 100644 --- a/docs/Pluto Array Semantics.md +++ b/docs/Pluto Array Semantics.md @@ -134,7 +134,8 @@ 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 +shape `[2 2]`. An operation on two untyped empty arrays yields another untyped +empty array. 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; diff --git a/tests/array/array.exp b/tests/array/array.exp index eaae80a5..a5b5de11 100644 --- a/tests/array/array.exp +++ b/tests/array/array.exp @@ -77,6 +77,9 @@ rank 2 refined: [ 9 ] empty concat: [] +empty add: [] +empty comparison: [] +refined empty add: [1] empty table: [ : Name Score ] diff --git a/tests/array/array.spt b/tests/array/array.spt index c63d462e..d25d6536 100644 --- a/tests/array/array.spt +++ b/tests/array/array.spt @@ -115,6 +115,12 @@ rank2Refined = [[9]] "rank 2 refined: -rank2Refined" emptyConcat = [] ⊕ [] "empty concat: -emptyConcat" +emptyAdd = [] + [] +"empty add: -emptyAdd" +emptyCompare = [] > [] +"empty comparison: -emptyCompare" +emptyAdd = emptyAdd ⊕ [1] +"refined empty add: -emptyAdd" emptyTable = [