diff --git a/AGENTS.md b/AGENTS.md index c5cc024..e9d5013 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -60,6 +60,18 @@ CI: GitHub Actions builds with Go 1.25, installs LLVM 21 + valgrind, and runs `p - Test/validation command details are optional in commit messages; put full verification details in the PR description when possible. - PRs: include a clear description, linked issues, unit/E2E tests for changes, and sample before/after output where relevant. +## Code Review Checklist + +When reviewing PRs or preparing code for review, check: + +1. **Modularity & readability**: Is each function focused on a single responsibility? Can a new reader follow the logic without excessive cross-referencing? +2. **Placement**: Do changes belong in the functions, arguments, and structs they touch, or should logic be moved to a more natural home? +3. **Duplication**: Is there code that duplicates existing patterns in the codebase? Extract shared logic into a helper or utility (e.g., `ast.ExprChildren` for tree-walking) rather than repeating type-switches or loop bodies. +4. **Nesting & control flow**: Can nested `if`/`for` blocks be flattened using early `return`, `continue`, or `break`? Prefer guard clauses over deep indentation. +5. **Naming**: Are new identifiers clear, consistent with existing conventions, and free of ambiguity? Avoid mixing synonyms (e.g., `tmp` vs `temp`) for the same concept. +6. **Edge cases**: Are zero-length slices, nil maps, and boundary values handled? Does the code distinguish "absent" from "empty"? +7. **Resource cleanup**: For compiler code specifically — are heap temporaries freed on all paths (true and false branches)? Are borrowed vs owned semantics respected? + ## Debugging & Configuration Tips - Quick smoke check: `./pluto tests/` to see compile/link output. - Clear cache for current version: `./pluto --clean` diff --git a/CLAUDE.md b/CLAUDE.md index 991056d..aff7c27 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -162,6 +162,18 @@ CI: GitHub Actions builds with Go 1.25, installs LLVM 21 + valgrind, and runs `p - Test/validation command details are optional in commit messages; put full verification details in the PR description when possible. - PRs: include a clear description, linked issues, unit/E2E tests for changes, and sample before/after output where relevant +## Code Review Checklist + +When reviewing PRs or preparing code for review, check: + +1. **Modularity & readability**: Is each function focused on a single responsibility? Can a new reader follow the logic without excessive cross-referencing? +2. **Placement**: Do changes belong in the functions, arguments, and structs they touch, or should logic be moved to a more natural home? +3. **Duplication**: Is there code that duplicates existing patterns in the codebase? Extract shared logic into a helper or utility (e.g., `ast.ExprChildren` for tree-walking) rather than repeating type-switches or loop bodies. +4. **Nesting & control flow**: Can nested `if`/`for` blocks be flattened using early `return`, `continue`, or `break`? Prefer guard clauses over deep indentation. +5. **Naming**: Are new identifiers clear, consistent with existing conventions, and free of ambiguity? Avoid mixing synonyms (e.g., `tmp` vs `temp`) for the same concept. +6. **Edge cases**: Are zero-length slices, nil maps, and boundary values handled? Does the code distinguish "absent" from "empty"? +7. **Resource cleanup**: For compiler code specifically — are heap temporaries freed on all paths (true and false branches)? Are borrowed vs owned semantics respected? + ## Instructions for AI Assistants - Keep changes minimal and focused; avoid reflowing or reindenting unrelated lines - Use tabs for indentation (preserve existing indentation style) diff --git a/GEMINI.md b/GEMINI.md index 2093034..a42e7a5 100644 --- a/GEMINI.md +++ b/GEMINI.md @@ -140,6 +140,18 @@ CI: GitHub Actions builds with Go 1.25, installs LLVM 21 + valgrind, and runs `p - Test/validation command details are optional in commit messages; put full verification details in the PR description when possible. - PRs: include a clear description, linked issues, unit/E2E tests for changes, and sample before/after output where relevant. +## Code Review Checklist + +When reviewing PRs or preparing code for review, check: + +1. **Modularity & readability**: Is each function focused on a single responsibility? Can a new reader follow the logic without excessive cross-referencing? +2. **Placement**: Do changes belong in the functions, arguments, and structs they touch, or should logic be moved to a more natural home? +3. **Duplication**: Is there code that duplicates existing patterns in the codebase? Extract shared logic into a helper or utility (e.g., `ast.ExprChildren` for tree-walking) rather than repeating type-switches or loop bodies. +4. **Nesting & control flow**: Can nested `if`/`for` blocks be flattened using early `return`, `continue`, or `break`? Prefer guard clauses over deep indentation. +5. **Naming**: Are new identifiers clear, consistent with existing conventions, and free of ambiguity? Avoid mixing synonyms (e.g., `tmp` vs `temp`) for the same concept. +6. **Edge cases**: Are zero-length slices, nil maps, and boundary values handled? Does the code distinguish "absent" from "empty"? +7. **Resource cleanup**: For compiler code specifically — are heap temporaries freed on all paths (true and false branches)? Are borrowed vs owned semantics respected? + ## Instructions for AI Assistants - Keep changes minimal and focused; avoid reflowing or reindenting unrelated lines. - Use tabs for indentation (preserve existing indentation style). diff --git a/ast/ast.go b/ast/ast.go index 8eb5efe..bc8833e 100644 --- a/ast/ast.go +++ b/ast/ast.go @@ -434,3 +434,32 @@ func (ce *CallExpression) String() string { return out.String() } + +// ExprChildren returns the immediate child expressions of an AST node. +// Returns nil for leaf nodes (Identifier, IntegerLiteral, FloatLiteral, +// StringLiteral, HeapStringLiteral). +func ExprChildren(expr Expression) []Expression { + switch e := expr.(type) { + case *InfixExpression: + return []Expression{e.Left, e.Right} + case *PrefixExpression: + return []Expression{e.Right} + case *CallExpression: + return e.Arguments + case *ArrayLiteral: + children := []Expression{} + for _, row := range e.Rows { + children = append(children, row...) + } + return children + case *ArrayRangeExpression: + return []Expression{e.Array, e.Range} + case *RangeLiteral: + children := []Expression{e.Start, e.Stop} + if e.Step != nil { + children = append(children, e.Step) + } + return children + } + return nil +} diff --git a/compiler/array.go b/compiler/array.go index c5ecddd..01f7389 100644 --- a/compiler/array.go +++ b/compiler/array.go @@ -515,6 +515,92 @@ func (c *Compiler) compileArrayScalarInfix(op string, arr *Symbol, scalar *Symbo return resSym } +// compileArrayFilter dispatches array filtering to array-array or array-scalar paths. +// Returns a new array containing only LHS elements where the comparison holds. +func (c *Compiler) compileArrayFilter(op string, left *Symbol, right *Symbol, expected Type) *Symbol { + l := c.derefIfPointer(left, "") + r := c.derefIfPointer(right, "") + + resArr := expected.(Array) + acc := c.NewArrayAccumulator(resArr) + + if l.Type.Kind() == ArrayKind && r.Type.Kind() == ArrayKind { + return c.compileArrayArrayFilter(op, l, r, acc) + } + if l.Type.Kind() == ArrayKind { + return c.compileArrayScalarFilter(op, l, r, acc) + } + // Fallback: LHS is not an array (shouldn't reach here if solver is correct) + return c.compileInfix(op, left, right, expected) +} + +// filterPush conditionally appends sym to acc based on cond. +// Emits a branch: if cond is true, push sym; otherwise skip. +func (c *Compiler) filterPush(acc *ArrayAccumulator, sym *Symbol, cond llvm.Value) { + fn := c.builder.GetInsertBlock().Parent() + copyBlock := c.Context.AddBasicBlock(fn, "filter_copy") + nextBlock := c.Context.AddBasicBlock(fn, "filter_next") + c.builder.CreateCondBr(cond, copyBlock, nextBlock) + + c.builder.SetInsertPointAtEnd(copyBlock) + c.PushVal(acc, sym) + c.builder.CreateBr(nextBlock) + + c.builder.SetInsertPointAtEnd(nextBlock) +} + +func (c *Compiler) compileArrayArrayFilter(op string, leftArr *Symbol, rightArr *Symbol, acc *ArrayAccumulator) *Symbol { + leftElem := leftArr.Type.(Array).ColTypes[0] + rightElem := rightArr.Type.(Array).ColTypes[0] + + leftLen := c.ArrayLen(leftArr, leftElem) + rightLen := c.ArrayLen(rightArr, rightElem) + minLen := c.builder.CreateSelect( + c.builder.CreateICmp(llvm.IntULT, leftLen, rightLen, "cmp_len"), + leftLen, rightLen, "min_len", + ) + + r := c.rangeZeroToN(minLen) + c.createLoop(r, func(iter llvm.Value) { + leftVal := c.ArrayGetBorrowed(leftArr, leftElem, iter) + rightVal := c.ArrayGetBorrowed(rightArr, rightElem, iter) + + leftSym := &Symbol{Val: leftVal, Type: leftElem} + rightSym := &Symbol{Val: rightVal, Type: rightElem} + + cmpResult := defaultOps[opKey{ + Operator: op, + LeftType: opType(leftSym.Type.Key()), + RightType: opType(rightSym.Type.Key()), + }](c, leftSym, rightSym, true) + + c.filterPush(acc, leftSym, cmpResult.Val) + }) + + return c.ArrayAccResult(acc) +} + +func (c *Compiler) compileArrayScalarFilter(op string, arr *Symbol, scalar *Symbol, acc *ArrayAccumulator) *Symbol { + arrElem := arr.Type.(Array).ColTypes[0] + lenVal := c.ArrayLen(arr, arrElem) + + r := c.rangeZeroToN(lenVal) + c.createLoop(r, func(iter llvm.Value) { + val := c.ArrayGetBorrowed(arr, arrElem, iter) + elemSym := &Symbol{Val: val, Type: arrElem} + + cmpResult := defaultOps[opKey{ + Operator: op, + LeftType: opType(elemSym.Type.Key()), + RightType: opType(scalar.Type.Key()), + }](c, elemSym, scalar, true) + + c.filterPush(acc, elemSym, cmpResult.Val) + }) + + return c.ArrayAccResult(acc) +} + func (c *Compiler) compileArrayUnaryPrefix(op string, arr *Symbol, result Array) *Symbol { arrType := arr.Type.(Array) elem := arrType.ColTypes[0] diff --git a/compiler/cfg.go b/compiler/cfg.go index b72afe8..82e6c1c 100644 --- a/compiler/cfg.go +++ b/compiler/cfg.go @@ -72,69 +72,28 @@ func NewCFG(sc *ScriptCompiler, cc *CodeCompiler) *CFG { // collectReads walks an expression tree and returns a slice of all // the identifier names it finds, put in VarEvent. This is a read-only analysis. func (cfg *CFG) collectReads(expr ast.Expression) []VarEvent { + // Leaf cases with special handling switch e := expr.(type) { - // Base cases that do NOT contain identifiers. - // We do nothing and let the function return the initial nil slice. case *ast.IntegerLiteral, *ast.FloatLiteral: return nil - case *ast.StringLiteral: - // Collect any identifiers within the format string. return cfg.collectStringReads(e.Value, e.Token) - case *ast.HeapStringLiteral: - // Collect any identifiers within the format string. return cfg.collectStringReads(e.Value, e.Token) - - case *ast.RangeLiteral: - // a range literal “start:stop[:step]” reads start, stop, and optionally step - evs := cfg.collectReads(e.Start) - evs = append(cfg.collectReads(e.Stop), evs...) - if e.Step != nil { - evs = append(cfg.collectReads(e.Step), evs...) - } - return evs - // Base case that IS an identifier. case *ast.Identifier: - // Return a new slice return []VarEvent{{Name: e.Value, Kind: Read, Token: e.Tok()}} + } - // Recursive cases: These nodes contain other expressions. - case *ast.PrefixExpression: - // The result is whatever we find in the right-hand side. - return cfg.collectReads(e.Right) - - case *ast.InfixExpression: - leftEvents := cfg.collectReads(e.Left) - rightEvents := cfg.collectReads(e.Right) - // Efficiently append the non-nil slices. - return append(leftEvents, rightEvents...) - - case *ast.CallExpression: - var evs []VarEvent // Declares a nil slice - for _, arg := range e.Arguments { - evs = append(evs, cfg.collectReads(arg)...) - } - return evs - - case *ast.ArrayLiteral: - // Collect reads from every cell expression in all rows - var evs []VarEvent - for _, row := range e.Rows { - for _, cell := range row { - evs = append(evs, cfg.collectReads(cell)...) - } - } - return evs - - case *ast.ArrayRangeExpression: - evs := cfg.collectReads(e.Array) - evs = append(evs, cfg.collectReads(e.Range)...) - return evs - - default: - panic(fmt.Sprintf("unhandled expression type: %T", e)) + // Recurse into children for composite expressions + children := ast.ExprChildren(expr) + if children == nil { + panic(fmt.Sprintf("unhandled expression type: %T", expr)) } + var evs []VarEvent + for _, child := range children { + evs = append(evs, cfg.collectReads(child)...) + } + return evs } func (cfg *CFG) collectStringReads(value string, tok token.Token) []VarEvent { diff --git a/compiler/compiler.go b/compiler/compiler.go index 4ecefa3..70b5e7d 100644 --- a/compiler/compiler.go +++ b/compiler/compiler.go @@ -89,6 +89,7 @@ type Compiler struct { ExprCache map[ExprKey]*ExprInfo FuncNameMangled string // current function's mangled name ("" for script level) Errors []*token.CompileError + condLHS map[ExprKey][]*Symbol // Statement-local: pre-extracted LHS values during cond-expr lowering } func NewCompiler(ctx llvm.Context, mangledPath string, cc *CodeCompiler) *Compiler { @@ -551,12 +552,22 @@ func (c *Compiler) captureOldValues(idents []*ast.Identifier) []*Symbol { func (c *Compiler) compileLetStatement(stmt *ast.LetStatement) { cond, hasConditions := c.compileConditions(stmt) - if !hasConditions { - c.compileAssignments(stmt.Name, stmt.Name, stmt.Value) + + // Embedded conditional expressions (comparisons in value position) + // take the most specialized path — they subsume statement conditions. + for _, expr := range stmt.Value { + if c.hasCondExprInTree(expr) { + c.compileCondExprStatement(stmt, cond) + return + } + } + + if hasConditions { + c.compileCondStatement(stmt, cond) return } - c.compileCondStatement(stmt, cond) + c.compileAssignments(stmt.Name, stmt.Name, stmt.Value) } func (c *Compiler) compileExpression(expr ast.Expression, dest []*ast.Identifier) (res []*Symbol) { @@ -788,6 +799,13 @@ func (c *Compiler) getRawSymbol(name string) (*Symbol, bool) { func (c *Compiler) compileInfixExpression(expr *ast.InfixExpression, dest []*ast.Identifier) (res []*Symbol) { info := c.ExprCache[key(c.FuncNameMangled, expr)] + + // Return pre-extracted LHS values for conditional expressions + if c.condLHS != nil { + if lhs, ok := c.condLHS[key(c.FuncNameMangled, expr)]; ok { + return lhs + } + } // Filter out ranges that are already bound (converted to scalar iterators in outer loops) pending := c.pendingLoopRanges(info.Ranges) if len(pending) == 0 { @@ -827,8 +845,8 @@ func (c *Compiler) compileInfix(op string, left *Symbol, right *Symbol, expected return defaultOps[opKey{ Operator: op, - LeftType: l.Type.Key(), - RightType: r.Type.Key(), + LeftType: opType(l.Type.Key()), + RightType: opType(r.Type.Key()), }](c, l, r, true) } @@ -839,7 +857,11 @@ func (c *Compiler) compileInfixBasic(expr *ast.InfixExpression, info *ExprInfo) right := c.compileExpression(expr.Right, nil) for i := 0; i < len(left); i++ { - res = append(res, c.compileInfix(expr.Operator, left[i], right[i], info.OutTypes[i])) + if len(info.CompareModes) > i && info.CompareModes[i] == CondArray { + res = append(res, c.compileArrayFilter(expr.Operator, left[i], right[i], info.OutTypes[i])) + } else { + res = append(res, c.compileInfix(expr.Operator, left[i], right[i], info.OutTypes[i])) + } } // Free temporary array operands (literals used in expressions) diff --git a/compiler/cond.go b/compiler/cond.go index 3eb947a..b74f25e 100644 --- a/compiler/cond.go +++ b/compiler/cond.go @@ -4,10 +4,16 @@ import ( "fmt" "github.com/thiremani/pluto/ast" - "github.com/thiremani/pluto/token" "tinygo.org/x/go-llvm" ) +// condTemp holds a pre-compiled LHS operand and its source expression, used to +// free heap temporaries on the false branch of conditional expression lowering. +type condTemp struct { + expr ast.Expression + syms []*Symbol +} + func (c *Compiler) compileConditions(stmt *ast.LetStatement) (cond llvm.Value, hasConditions bool) { if len(stmt.Condition) == 0 { hasConditions = false @@ -33,35 +39,16 @@ func (c *Compiler) compileConditions(stmt *ast.LetStatement) (cond llvm.Value, h // to memory by compileArgs, so conditional lowering pre-promotes them before // branching. func collectCallArgIdentifiers(expr ast.Expression, out map[string]struct{}) { - switch e := expr.(type) { - case *ast.CallExpression: - for _, arg := range e.Arguments { + if ce, ok := expr.(*ast.CallExpression); ok { + for _, arg := range ce.Arguments { if ident, ok := arg.(*ast.Identifier); ok { out[ident.Value] = struct{}{} } - collectCallArgIdentifiers(arg, out) - } - case *ast.InfixExpression: - collectCallArgIdentifiers(e.Left, out) - collectCallArgIdentifiers(e.Right, out) - case *ast.PrefixExpression: - collectCallArgIdentifiers(e.Right, out) - case *ast.ArrayLiteral: - for _, row := range e.Rows { - for _, cell := range row { - collectCallArgIdentifiers(cell, out) - } - } - case *ast.ArrayRangeExpression: - collectCallArgIdentifiers(e.Array, out) - collectCallArgIdentifiers(e.Range, out) - case *ast.RangeLiteral: - collectCallArgIdentifiers(e.Start, out) - collectCallArgIdentifiers(e.Stop, out) - if e.Step != nil { - collectCallArgIdentifiers(e.Step, out) } } + for _, child := range ast.ExprChildren(expr) { + collectCallArgIdentifiers(child, out) + } } // prePromoteConditionalCallArgs promotes local identifiers that are used as call @@ -84,45 +71,20 @@ func (c *Compiler) prePromoteConditionalCallArgs(exprs []ast.Expression) { } } -func (c *Compiler) collectConditionalOutTypes(stmt *ast.LetStatement) ([]Type, bool) { +func (c *Compiler) collectOutTypes(stmt *ast.LetStatement) []Type { outTypes := []Type{} for _, expr := range stmt.Value { info := c.ExprCache[key(c.FuncNameMangled, expr)] - if info == nil { - c.Errors = append(c.Errors, &token.CompileError{ - Token: stmt.Token, - Msg: fmt.Sprintf("missing type info for conditional expression %T", expr), - }) - return nil, false - } outTypes = append(outTypes, info.OutTypes...) } - return outTypes, true + return outTypes } -func (c *Compiler) createConditionalTempOutputs(stmt *ast.LetStatement) ([]*ast.Identifier, []Type, bool) { - outTypes, ok := c.collectConditionalOutTypes(stmt) - if !ok { - return nil, nil, false - } - if len(outTypes) != len(stmt.Name) { - c.Errors = append(c.Errors, &token.CompileError{ - Token: stmt.Token, - Msg: fmt.Sprintf("conditional outputs mismatch: got %d values for %d targets", len(outTypes), len(stmt.Name)), - }) - return nil, nil, false - } +func (c *Compiler) createConditionalTempOutputs(stmt *ast.LetStatement) ([]*ast.Identifier, []Type) { + outTypes := c.collectOutTypes(stmt) tempNames := make([]*ast.Identifier, len(stmt.Name)) for i, ident := range stmt.Name { - if outTypes[i].Kind() == UnresolvedKind { - c.Errors = append(c.Errors, &token.CompileError{ - Token: ident.Token, - Msg: fmt.Sprintf("conditional rhs output type for %q is unresolved", ident.Value), - }) - return nil, nil, false - } - tempName := fmt.Sprintf("condtmp_%s_%d", ident.Value, c.tmpCounter) c.tmpCounter++ tempIdent := &ast.Identifier{Value: tempName} @@ -151,7 +113,7 @@ func (c *Compiler) createConditionalTempOutputs(stmt *ast.LetStatement) ([]*ast. }) tempNames[i] = tempIdent } - return tempNames, outTypes, true + return tempNames, outTypes } func (c *Compiler) commitConditionalOutputs(dest []*ast.Identifier, tempNames []*ast.Identifier, outTypes []Type) { @@ -250,10 +212,7 @@ func (c *Compiler) compileCondStatement(stmt *ast.LetStatement, cond llvm.Value) // uninitialized memory. Pre-promote here so storage is initialized on all paths. c.prePromoteConditionalCallArgs(stmt.Value) - tempNames, outTypes, ok := c.createConditionalTempOutputs(stmt) - if !ok { - return - } + tempNames, outTypes := c.createConditionalTempOutputs(stmt) fn := c.builder.GetInsertBlock().Parent() ifBlock := c.Context.AddBasicBlock(fn, "if") @@ -268,3 +227,156 @@ func (c *Compiler) compileCondStatement(stmt *ast.LetStatement, cond llvm.Value) c.commitConditionalOutputs(stmt.Name, tempNames, outTypes) DeleteBulk(c.Scopes, tempNamesToStrings(tempNames)) } + +// hasCondExprInTree returns true if any node in the expression tree has +// CondScalar set (a scalar comparison in value position). +func (c *Compiler) hasCondExprInTree(expr ast.Expression) bool { + info := c.ExprCache[key(c.FuncNameMangled, expr)] + if info.HasCondScalar() { + return true + } + for _, child := range ast.ExprChildren(expr) { + if c.hasCondExprInTree(child) { + return true + } + } + return false +} + +// handleComparisons processes each slot of a multi-return comparison based on +// its CondMode. CondScalar slots are compared and ANDed into cond. CondArray +// slots are compiled as array filters (source freed, marked borrowed). +func (c *Compiler) handleComparisons(op string, left, right []*Symbol, info *ExprInfo, cond llvm.Value) ([]*Symbol, llvm.Value) { + lhsSyms := make([]*Symbol, len(left)) + for i := range left { + switch info.CompareModes[i] { + case CondScalar: + lSym := c.derefIfPointer(left[i], "") + rSym := c.derefIfPointer(right[i], "") + cmpResult := defaultOps[opKey{ + Operator: op, + LeftType: opType(lSym.Type.Key()), + RightType: opType(rSym.Type.Key()), + }](c, lSym, rSym, true) + if cond.IsNil() { + cond = cmpResult.Val + } else { + cond = c.builder.CreateAnd(cond, cmpResult.Val, fmt.Sprintf("and_cond_%d", i)) + } + lhsSyms[i] = lSym + case CondArray: + // compileArrayFilter handles deref internally + lhsSyms[i] = c.compileArrayFilter(op, left[i], right[i], info.OutTypes[i]) + c.freeSymbolValue(left[i], "") + left[i].Borrowed = true + } + } + return lhsSyms, cond +} + +// extractCondExprs walks the expression tree, evaluates each CondScalar +// comparison, ANDs results into cond, and stores LHS values in c.condLHS +// for substitution during later value compilation. LHS temporaries are +// appended to temps so the caller can free them on the false path. +func (c *Compiler) extractCondExprs(expr ast.Expression, cond llvm.Value, temps []condTemp) (llvm.Value, []condTemp) { + info := c.ExprCache[key(c.FuncNameMangled, expr)] + + // Handle conditional expression (comparison in value position) + if infix, ok := expr.(*ast.InfixExpression); ok && info.HasCondScalar() { + // Bottom-up: extract conditions from operands first + cond, temps = c.extractCondExprs(infix.Left, cond, temps) + cond, temps = c.extractCondExprs(infix.Right, cond, temps) + + // Compile both operands (may return pre-extracted values) + left := c.compileExpression(infix.Left, nil) + right := c.compileExpression(infix.Right, nil) + + var lhsSyms []*Symbol + lhsSyms, cond = c.handleComparisons(infix.Operator, left, right, info, cond) + + c.condLHS[key(c.FuncNameMangled, expr)] = lhsSyms + temps = append(temps, condTemp{infix.Left, left}) + // Free right-side temporaries (only used for comparison). + // Left-side values are retained in condLHS for later substitution. + c.freeTemporary(infix.Right, right) + return cond, temps + } + + // Not a conditional expression — recurse into children + for _, child := range ast.ExprChildren(expr) { + cond, temps = c.extractCondExprs(child, cond, temps) + } + return cond, temps +} + +// compileCondExprStatement handles let statements that have conditional +// expressions (comparisons) embedded in their value expressions. +// Each value expression is processed independently: its conditions are +// ANDed with statement conditions and branched on separately, so +// p, q = a > 2, d < 10 evaluates each condition independently rather +// than ANDing them all-or-nothing. +func (c *Compiler) compileCondExprStatement(stmt *ast.LetStatement, stmtCond llvm.Value) { + c.prePromoteConditionalCallArgs(stmt.Value) + + tempNames, outTypes := c.createConditionalTempOutputs(stmt) + + // Save condLHS for re-entrant calls (e.g. nested cond-expr in callee). + savedCondLHS := c.condLHS + + targetIdx := 0 + for _, expr := range stmt.Value { + info := c.ExprCache[key(c.FuncNameMangled, expr)] + numOutputs := len(info.OutTypes) + exprTempNames := tempNames[targetIdx : targetIdx+numOutputs] + exprDestNames := stmt.Name[targetIdx : targetIdx+numOutputs] + exprValues := []ast.Expression{expr} + + // Reset condLHS per expression so conditions don't leak across expressions. + c.condLHS = make(map[ExprKey][]*Symbol) + + // Extract conditions for this expression only, starting from stmtCond. + var temps []condTemp + cond := stmtCond + cond, temps = c.extractCondExprs(expr, cond, temps) + + if !cond.IsNil() { + fn := c.builder.GetInsertBlock().Parent() + ifBlock := c.Context.AddBasicBlock(fn, "if") + elseBlock := c.Context.AddBasicBlock(fn, "else") + contBlock := c.Context.AddBasicBlock(fn, "continue") + c.builder.CreateCondBr(cond, ifBlock, elseBlock) + + c.builder.SetInsertPointAtEnd(ifBlock) + c.compileCondAssignments(exprTempNames, exprDestNames, exprValues) + c.builder.CreateBr(contBlock) + + // Else: free LHS temporaries and CondArray results that won't be consumed. + c.builder.SetInsertPointAtEnd(elseBlock) + for _, tmp := range temps { + c.freeTemporary(tmp.expr, tmp.syms) + } + for exprKey, lhsSyms := range c.condLHS { + exprInfo := c.ExprCache[exprKey] + for i, mode := range exprInfo.CompareModes { + if mode == CondArray { + c.freeSymbolValue(lhsSyms[i], "") + } + } + } + c.builder.CreateBr(contBlock) + + c.builder.SetInsertPointAtEnd(contBlock) + } else { + // No condition for this expression: unconditional assignment to temps. + c.compileCondAssignments(exprTempNames, exprDestNames, exprValues) + } + + targetIdx += numOutputs + } + + c.commitConditionalOutputs(stmt.Name, tempNames, outTypes) + DeleteBulk(c.Scopes, tempNamesToStrings(tempNames)) + + // Restore previous state (supports re-entrant cond-expr compilation) + c.condLHS = savedCondLHS +} diff --git a/compiler/operators.go b/compiler/operators.go index e2bdcd7..d12b28b 100644 --- a/compiler/operators.go +++ b/compiler/operators.go @@ -17,6 +17,15 @@ type unaryOpKey struct { OperandType Type } +// opType normalizes a type for operator lookup. All string types map to Str +// so operator entries only need one registration per string combination. +func opType(t Type) Type { + if t.Kind() == StrKind { + return Str{} + } + return t +} + // opFunc defines the function signature for an operator function. // It takes two *Symbols and returns a new *Symbol. type opFunc func(c *Compiler, left, right *Symbol, compile bool) *Symbol @@ -86,6 +95,31 @@ var strConcatOp = func(c *Compiler, left, right *Symbol, compile bool) (s *Symbo return } +// strCmpOp builds a string comparison operator using libc strcmp. +// pred is the LLVM integer comparison predicate applied to the strcmp result. +func strCmpOp(pred llvm.IntPredicate) opFunc { + return func(c *Compiler, left, right *Symbol, compile bool) (s *Symbol) { + s = &Symbol{} + s.Type = Int{Width: 1} + if !compile { + return + } + + i32Type := c.Context.Int32Type() + charPtrType := llvm.PointerType(c.Context.Int8Type(), 0) + strcmpType := llvm.FunctionType(i32Type, []llvm.Type{charPtrType, charPtrType}, false) + strcmpFunc := c.Module.NamedFunction("strcmp") + if strcmpFunc.IsNil() { + strcmpFunc = llvm.AddFunction(c.Module, "strcmp", strcmpType) + } + + result := c.builder.CreateCall(strcmpType, strcmpFunc, []llvm.Value{left.Val, right.Val}, "strcmp_result") + zero := llvm.ConstInt(i32Type, 0, false) + s.Val = c.builder.CreateICmp(pred, result, zero, "str_cmp") + return + } +} + // defaultOps maps (operator, left type, right type) to the lowering function. // Keys use concrete Type values (e.g., I64, F64) instead of strings // to avoid brittleness from relying on String() formatting. @@ -701,11 +735,16 @@ var defaultOps = map[opKey]opFunc{ return }, - // --- String Concatenation (all combinations produce StrH) --- - {Operator: token.SYM_CONCAT, LeftType: StrH{}, RightType: StrH{}}: strConcatOp, - {Operator: token.SYM_CONCAT, LeftType: StrG{}, RightType: StrG{}}: strConcatOp, - {Operator: token.SYM_CONCAT, LeftType: StrG{}, RightType: StrH{}}: strConcatOp, - {Operator: token.SYM_CONCAT, LeftType: StrH{}, RightType: StrG{}}: strConcatOp, + // --- String Concatenation (Str is canonical key for all string types via opType) --- + {Operator: token.SYM_CONCAT, LeftType: Str{}, RightType: Str{}}: strConcatOp, + + // --- String Comparisons via strcmp (single entry per operator) --- + {Operator: token.SYM_EQL, LeftType: Str{}, RightType: Str{}}: strCmpOp(llvm.IntEQ), + {Operator: token.SYM_NEQ, LeftType: Str{}, RightType: Str{}}: strCmpOp(llvm.IntNE), + {Operator: token.SYM_LSS, LeftType: Str{}, RightType: Str{}}: strCmpOp(llvm.IntSLT), + {Operator: token.SYM_LEQ, LeftType: Str{}, RightType: Str{}}: strCmpOp(llvm.IntSLE), + {Operator: token.SYM_GTR, LeftType: Str{}, RightType: Str{}}: strCmpOp(llvm.IntSGT), + {Operator: token.SYM_GEQ, LeftType: Str{}, RightType: Str{}}: strCmpOp(llvm.IntSGE), } var defaultUnaryOps = map[unaryOpKey]unaryOpFunc{ diff --git a/compiler/solver.go b/compiler/solver.go index c3504d9..d08a7b9 100644 --- a/compiler/solver.go +++ b/compiler/solver.go @@ -15,13 +15,33 @@ type RangeInfo struct { ArrayType Array } +// CondMode classifies how a comparison in value position is lowered. +type CondMode int + +const ( + CondNone CondMode = iota // Normal expression (not a comparison in value position) + CondScalar // Scalar: extract LHS value, branch on condition + CondArray // Array: element-wise filter, keep LHS where condition holds +) + type ExprInfo struct { - Ranges []*RangeInfo // either value from *ast.Identifier or a newly created value from tmp identifier for *ast.RangeLiteral - Rewrite ast.Expression // expression rewritten with a literal -> tmp value. (0:11) -> tmpIter0 etc. - ExprLen int - OutTypes []Type - HasRanges bool // True if expression involves ranges (propagated upward during typing) - LoopInside bool // For CallExpression: true if function handles iteration, false if call site handles it + Ranges []*RangeInfo // either value from *ast.Identifier or a newly created value from tmp identifier for *ast.RangeLiteral + Rewrite ast.Expression // expression rewritten with a literal -> tmp value. (0:11) -> tmpIter0 etc. + ExprLen int + OutTypes []Type + HasRanges bool // True if expression involves ranges (propagated upward during typing) + LoopInside bool // For CallExpression: true if function handles iteration, false if call site handles it + CompareModes []CondMode // Per-slot lowering mode for comparisons in value position (nil for non-comparisons) +} + +// HasCondScalar returns true if any slot is a scalar conditional expression. +func (info *ExprInfo) HasCondScalar() bool { + for _, m := range info.CompareModes { + if m == CondScalar { + return true + } + } + return false } // ExprKey is the key for ExprCache, combining function context with expression. @@ -58,7 +78,8 @@ type TypeSolver struct { Converging bool Errors []*token.CompileError ExprCache map[ExprKey]*ExprInfo - TmpCounter int // tmpCounter for uniquely naming temporary variables + TmpCounter int // tmpCounter for uniquely naming temporary variables + InValueExpr bool // true when typing Value expressions of a LetStatement UnresolvedExprs map[pendingBinding][]pendingExpr } @@ -613,12 +634,28 @@ func (ts *TypeSolver) ensureScalarCallVariant(ce *ast.CallExpression) { } } +// RejectKind appends a compile error for every type in types whose Kind matches kind. +func RejectKind(errors *[]*token.CompileError, types []Type, kind Kind, tok token.Token, msg string) { + for _, t := range types { + if t.Kind() == kind { + *errors = append(*errors, &token.CompileError{Token: tok, Msg: msg}) + } + } +} + func (ts *TypeSolver) TypeLetStatement(stmt *ast.LetStatement) { - // type conditions in case there may be functions we have to type + savedInValueExpr := ts.InValueExpr + defer func() { ts.InValueExpr = savedInValueExpr }() + + // type conditions in non-value context (comparisons produce i1 as usual) + ts.InValueExpr = false for _, expr := range stmt.Condition { - ts.TypeExpression(expr, true) + condTypes := ts.TypeExpression(expr, true) + RejectKind(&ts.Errors, condTypes, ArrayKind, stmt.Token, "statement condition must produce a scalar value, not an array") } + // type values in value-expression context (comparisons become conditional extractors) + ts.InValueExpr = true types := []Type{} exprRefs := make([]ast.Expression, 0, len(stmt.Name)) exprIdxs := make([]int, 0, len(stmt.Name)) @@ -646,23 +683,26 @@ func (ts *TypeSolver) TypeLetStatement(stmt *ast.LetStatement) { ts.bindAssignment(ident.Value, exprRefs[i], exprIdxs[i], newType) typ, exists := Get(ts.Scopes, ident.Value) - if exists { - // Existing bindings with unresolved RHS are left as-is. - // Use deep resolution so container types (e.g. arrays with unresolved - // element types) are also treated as unresolved. - if !IsFullyResolvedType(newType) { - continue - } - if !CanRefineType(typ, newType) { - ce := &token.CompileError{ - Token: ident.Token, - Msg: fmt.Sprintf("cannot reassign type to identifier. Old Type: %s. New Type: %s. Identifier %q", typ, newType, ident.Token.Literal), - } - ts.Errors = append(ts.Errors, ce) - return - } + if !exists { + trueValues[ident.Value] = newType + continue } + // Existing bindings with unresolved RHS are left as-is. + // Use deep resolution so container types (e.g. arrays with unresolved + // element types) are also treated as unresolved. + if !IsFullyResolvedType(newType) { + continue + } + + if !CanRefineType(typ, newType) { + ce := &token.CompileError{ + Token: ident.Token, + Msg: fmt.Sprintf("cannot reassign type to identifier. Old Type: %s. New Type: %s. Identifier %q", typ, newType, ident.Token.Literal), + } + ts.Errors = append(ts.Errors, ce) + return + } trueValues[ident.Value] = newType } @@ -1070,6 +1110,52 @@ func (ts *TypeSolver) TypeIdentifier(ident *ast.Identifier) (t Type) { return } +// typeInfixArrayFilter validates that element-wise comparison is supported +// for an array filter (comparison in value position with array LHS). +func (ts *TypeSolver) typeInfixArrayFilter(leftType, rightType Type, op string, tok token.Token) { + elemType := leftType.(Array).ColTypes[0] + rhsElemType := rightType + if rightType.Kind() == ArrayKind { + rhsElemType = rightType.(Array).ColTypes[0] + } + ts.TypeInfixOp(elemType, rhsElemType, op, tok) +} + +// typeInfixSlot types a single slot of an infix expression, returning the +// result type and the cond-expr lowering mode for that slot. +func (ts *TypeSolver) typeInfixSlot(expr *ast.InfixExpression, leftType, rightType Type, isValueCmp bool) (Type, CondMode) { + if ptr, ok := leftType.(Ptr); ok { + leftType = ptr.Elem + } + if ptr, ok := rightType.(Ptr); ok { + rightType = ptr.Elem + } + + if leftType.Kind() == UnresolvedKind || rightType.Kind() == UnresolvedKind { + return Unresolved{}, CondNone + } + + // Array filter: comparison in value position with array LHS + if isValueCmp && leftType.Kind() == ArrayKind { + ts.typeInfixArrayFilter(leftType, rightType, expr.Operator, expr.Token) + return leftType, CondArray + } + + // Handle any expression involving arrays + if finalType, ok := ts.handleInfixArrays(expr, leftType, rightType); ok { + return finalType, CondNone + } + + resultType := ts.TypeInfixOp(leftType, rightType, expr.Operator, expr.Token) + + // Conditional expression: comparison in value position returns LHS type + if isValueCmp { + return leftType, CondScalar + } + + return resultType, CondNone +} + // TypeInfixExpression returns output types of infix expression // If either left or right operands are pointers, it will dereference them // This is because pointers are automatically dereferenced @@ -1087,39 +1173,19 @@ func (ts *TypeSolver) TypeInfixExpression(expr *ast.InfixExpression) (types []Ty return } - types = []Type{} - var ok bool - var ptr Ptr + isValueCmp := ts.InValueExpr && expr.Token.IsComparison() + types = make([]Type, len(left)) + compareModes := make([]CondMode, len(left)) for i := range left { - leftType := left[i] - if ptr, ok = leftType.(Ptr); ok { - leftType = ptr.Elem - } - - rightType := right[i] - if ptr, ok = rightType.(Ptr); ok { - rightType = ptr.Elem - } - - if leftType.Kind() == UnresolvedKind || rightType.Kind() == UnresolvedKind { - types = append(types, Unresolved{}) - continue - } - - // Handle any expression involving arrays - if finalType, ok := ts.handleInfixArrays(expr, leftType, rightType); ok { - types = append(types, finalType) - continue - } - - types = append(types, ts.TypeInfixOp(leftType, rightType, expr.Operator, expr.Token)) + types[i], compareModes[i] = ts.typeInfixSlot(expr, left[i], right[i], isValueCmp) } // Create new entry ts.ExprCache[key(ts.FuncNameMangled, expr)] = &ExprInfo{ - OutTypes: types, - ExprLen: len(types), - HasRanges: ts.ExprCache[key(ts.FuncNameMangled, expr.Left)].HasRanges || ts.ExprCache[key(ts.FuncNameMangled, expr.Right)].HasRanges, + OutTypes: types, + ExprLen: len(types), + HasRanges: ts.ExprCache[key(ts.FuncNameMangled, expr.Left)].HasRanges || ts.ExprCache[key(ts.FuncNameMangled, expr.Right)].HasRanges, + CompareModes: compareModes, } return @@ -1229,8 +1295,8 @@ func (ts *TypeSolver) typeArrayScalarInfix(left, right Type, leftIsArr bool, op func (ts *TypeSolver) TypeInfixOp(left, right Type, op string, tok token.Token) Type { key := opKey{ Operator: op, - LeftType: left.Key(), - RightType: right.Key(), + LeftType: opType(left.Key()), + RightType: opType(right.Key()), } var fn opFunc diff --git a/compiler/solver_test.go b/compiler/solver_test.go index c33a359..e534c69 100644 --- a/compiler/solver_test.go +++ b/compiler/solver_test.go @@ -268,6 +268,35 @@ func TestArrayToScalarAssignmentError(t *testing.T) { } } +func TestArrayComparisonInValuePositionIsFilter(t *testing.T) { + ctx := llvm.NewContext() + cc := NewCodeCompiler(ctx, "arrayComparisonValue", "", ast.NewCode()) + funcCache := make(map[string]*Func) + exprCache := make(map[ExprKey]*ExprInfo) + + script := "a = [1 2]\nb = [0 3]\nx = a > b" + sl := lexer.New("arrayComparisonValue.spt", script) + sp := parser.NewScriptParser(sl) + program := sp.Parse() + require.Empty(t, sp.Errors(), "unexpected parse errors: %v", sp.Errors()) + + sc := NewScriptCompiler(ctx, program, cc, funcCache, exprCache) + ts := NewTypeSolver(sc) + ts.Solve() + + require.Empty(t, ts.Errors, "unexpected solver errors: %v", ts.Errors) + + letStmt, ok := program.Statements[2].(*ast.LetStatement) + require.True(t, ok) + infix, ok := letStmt.Value[0].(*ast.InfixExpression) + require.True(t, ok) + + info := ts.ExprCache[key(ts.FuncNameMangled, infix)] + require.NotNil(t, info) + require.Len(t, info.CompareModes, 1, "should have one compare mode entry") + require.Equal(t, CondArray, info.CompareModes[0], "array comparison in value position should be tagged as filter") +} + func TestArrayLiteralRangesRecording(t *testing.T) { ctx := llvm.NewContext() cc := NewCodeCompiler(ctx, "arrayLiteralRanges", "", ast.NewCode()) diff --git a/compiler/types.go b/compiler/types.go index 8c342cc..c596ffd 100644 --- a/compiler/types.go +++ b/compiler/types.go @@ -154,6 +154,16 @@ func IsStrH(t Type) bool { return ok } +// Str is the canonical string type used as the operator lookup key. +// All string types (StrG, StrH, etc.) are char* at the LLVM level, +// so operators only need one registration using Str as the key type. +type Str struct{} + +func (s Str) String() string { return "Str" } +func (s Str) Kind() Kind { return StrKind } +func (s Str) Mangle() string { return "Str" } +func (s Str) Key() Type { return s } + type Range struct { Iter Type } diff --git a/tests/cond/value_cond_expr.exp b/tests/cond/value_cond_expr.exp new file mode 100644 index 0000000..4e8d8bf --- /dev/null +++ b/tests/cond/value_cond_expr.exp @@ -0,0 +1,45 @@ +BasicTrue: 5 +BasicFalseNew: 0 +BeforeReassign: 42 +BasicFalseExisting: 42 +BasicTrueExisting: 5 +ArithBothTrue: 10 +ArithOneFalse: 0 +CombinedBothTrue: 5 +CombinedStmtFalse: 0 +CombinedExprFalse: 0 +MultiTrue: 5 7 +MultiFalse: 0 7 +MixCondTrue: 5 7 +MixCondFalse: 0 7 +MixPlainTrue: 5 7 +MixPlainFalse: 5 0 +LessThan: 5 +LessEqual: 5 +GreaterEqual: 5 +Equal: 5 +NotEqual: 5 +FloatTrue: 3.14 +FloatFalse: 0 +CallTrue: 10 +CallFalse: 0 +CallLhsTrue: 10 +CallLhsFalse: 0 +NestedCondTrue: 10 5 +NestedCondFalse: 0 0 +PairTrue: 5 7 +PairOneFalse: 0 0 +PairFirstFalse: 0 0 +MixedFalse: [] 0 +MixedTrue: [2 3] 2 +ArrayArray: [2 6] +ArrayScalar: [5 6] +IntAddTrue: 7 +IntAddFalse: 0 +StrCmpTrue: banana +re:StrCmpFalse:\s* +ConcatTrue: hello world +re:ConcatFalse:\s* +StrEqTrue: abc +re:StrEqFalse:\s* +NonValueCmp: 1 diff --git a/tests/cond/value_cond_expr.pt b/tests/cond/value_cond_expr.pt new file mode 100644 index 0000000..28955d4 --- /dev/null +++ b/tests/cond/value_cond_expr.pt @@ -0,0 +1,17 @@ +res = Double(x) + res = x * 2 + +res = CondDouble(x) + tmp = x > 0 + res = tmp * 2 + +a, b = Pair(x, y) + a = x + b = y + +a, b = Mix(x) + a = [x x + 1] + b = x + +res = Id(x) + res = x diff --git a/tests/cond/value_cond_expr.spt b/tests/cond/value_cond_expr.spt new file mode 100644 index 0000000..dd19456 --- /dev/null +++ b/tests/cond/value_cond_expr.spt @@ -0,0 +1,185 @@ +# Basic conditional expression: true case (new var) +a = 5 +x = a > 2 +"BasicTrue: -x" + +# Basic conditional expression: false case (new var → 0) +b = 1 +y = b > 2 +"BasicFalseNew: -y" + +# False case with existing var (keeps old value) +z = 42 +"BeforeReassign: -z" +z = b > 2 +"BasicFalseExisting: -z" + +# True case with existing var (gets new value) +z = a > 2 +"BasicTrueExisting: -z" + +# Arithmetic with two conditional expressions +c = 3 +d = 7 +s = c > 2 + d < 10 +"ArithBothTrue: -s" + +# One condition fails +e = 1 +s2 = e > 2 + d < 10 +"ArithOneFalse: -s2" + +# Combined statement condition + embedded condition +w = 5 > 3 a > 2 +"CombinedBothTrue: -w" + +w2 = 1 > 3 a > 2 +"CombinedStmtFalse: -w2" + +w3 = 5 > 3 b > 2 +"CombinedExprFalse: -w3" + +# Multiple targets: independent per expression +p, q = a > 2, d < 10 +"MultiTrue: -p -q" + +p2, q2 = b > 2, d < 10 +"MultiFalse: -p2 -q2" + +# Mixed cond-expr + plain value: cond first +mc1, mc2 = a > 2, d +"MixCondTrue: -mc1 -mc2" + +mc3, mc4 = b > 2, d +"MixCondFalse: -mc3 -mc4" + +# Mixed cond-expr + plain value: plain first +mc5, mc6 = a, d < 10 +"MixPlainTrue: -mc5 -mc6" + +mc7, mc8 = a, d > 10 +"MixPlainFalse: -mc7 -mc8" + +# Different comparison operators +v1 = a < 10 +"LessThan: -v1" + +v2 = a <= 5 +"LessEqual: -v2" + +v3 = a >= 5 +"GreaterEqual: -v3" + +v4 = a == 5 +"Equal: -v4" + +v5 = a != 3 +"NotEqual: -v5" + +# Float comparison +f = 3.14 +fv = f > 2.0 +"FloatTrue: -fv" + +fv2 = f > 4.0 +"FloatFalse: -fv2" + +# Function call with conditional arg +i = 5 +res = Double(i < 10) +"CallTrue: -res" + +j = 15 +res2 = Double(j < 10) +"CallFalse: -res2" + +# Call result as LHS of conditional (exercises false-path temp cleanup) +cr = Double(5) > 8 +"CallLhsTrue: -cr" + +cr2 = Double(5) > 12 +"CallLhsFalse: -cr2" + +# Nested: function with cond-expr in body called from cond-expr context +# Tests that callee's cond-expr compilation doesn't clobber caller's state +v = 5 +m, n = CondDouble(v), v > 0 +"NestedCondTrue: -m -n" + +v2 = -3 +m2, n2 = CondDouble(v2), v2 > 0 +"NestedCondFalse: -m2 -n2" + +# Multi-return conditional expression: all true +px, py = Pair(5, 7) > Pair(1, 2) +"PairTrue: -px -py" + +# Multi-return conditional expression: one false (all-or-nothing skip) +px2, py2 = Pair(5, 7) > Pair(1, 8) +"PairOneFalse: -px2 -py2" + +# Multi-return: first slot false, second would pass (AND covers both) +px3, py3 = Pair(5, 7) > Pair(6, 2) +"PairFirstFalse: -px3 -py3" + +# Mixed array/scalar cond-expr: false case (scalar comparison fails) +r2, n2 = Mix(1) > Mix(2) +"MixedFalse: -r2 -n2" + +# Mixed array/scalar cond-expr: array slot filtered, scalar slot conditional +r, n = Mix(2) > Mix(1) +"MixedTrue: -r -n" + +# Array comparison in value position (normal infix, not cond-expr) +arr1 = [1 2 6] +arr2 = [1 1 3 5 7] +ax = arr1 > arr2 +"ArrayArray: -ax" + +# Array-scalar comparison in value position +arr3 = [1 2 3 5 6] +ay = arr3 > 3 +"ArrayScalar: -ay" + +# Heap-allocating LHS in cond-expr: (a + b) > threshold +# The addition creates a temporary; on false path it must be freed. +ia = 3 +ib = 4 +ic = (ia + ib) > 5 +"IntAddTrue: -ic" + +ic2 = (ia + ib) > 10 +"IntAddFalse: -ic2" + +# String comparison: basic +sa = "banana" +sb = "apple" +sc = sa > "avocado" +"StrCmpTrue: -sc" + +sc2 = sb > "banana" +"StrCmpFalse: -sc2" + +# String concat cond-expr: (a ⊕ b) > "threshold" +# Concat allocates a heap string; on false path it must be freed. +sl = "hello" +sm = " world" +sr = (sl ⊕ sm) > "hello w" +"ConcatTrue: -sr" + +sr2 = (sl ⊕ sm) > "hello x" +"ConcatFalse: -sr2" + +# String equality +se = "abc" +sf = "abc" +sg = se == sf +"StrEqTrue: -sg" + +sh = se == "xyz" +"StrEqFalse: -sh" + +# Comparison in non-value position (function arg) must not be treated as cond-expr +ga = 1 +gb = Id(ga > 0) +"NonValueCmp: -gb"