From 96e057f3cbb9b50c473f49df08df17cff67f8c62 Mon Sep 17 00:00:00 2001 From: Mikael Ganehag Brorsson Date: Wed, 22 Apr 2026 13:27:09 +0200 Subject: [PATCH] Add Go-style switch-case statement Implements switch/case/default/fallthrough/break with multi-value cases, tagless switch (acts as if/else chain), and optional init statement. - token: add Switch, Case, Default, Fallthrough keywords - parser: add SwitchStmt and CaseClause AST nodes, parseSwitchStmt/parseCaseClause - parser: fix expectSemi to accept case/default as implicit terminators - parser: add OpDup opcode (duplicates stack top for tag comparisons) - compiler: implement compileSwitchStmt with two-entry-point body pattern for correct stack discipline on fallthrough - compiler: update loop struct and branch handling so break/continue/fallthrough correctly target switch vs loop contexts - vm: add OpDup handler --- compiler.go | 215 ++++++++++++++++++++++++++++++++++++++++++++-- compiler_test.go | 2 +- parser/opcodes.go | 3 + parser/parser.go | 98 +++++++++++++++++++-- parser/scanner.go | 5 +- parser/stmt.go | 59 +++++++++++++ token/token.go | 8 ++ vm.go | 3 + vm_test.go | 96 +++++++++++++++++++++ 9 files changed, 468 insertions(+), 21 deletions(-) diff --git a/compiler.go b/compiler.go index f5fc5536..8a8d4f4c 100644 --- a/compiler.go +++ b/compiler.go @@ -22,11 +22,13 @@ type compilationScope struct { SourceMap map[int]parser.Pos } -// loop represents a loop construct that the compiler uses to track the current -// loop. +// loop represents a loop or switch construct that the compiler uses to track +// break/continue/fallthrough targets. type loop struct { - Continues []int - Breaks []int + Continues []int + Breaks []int + IsSwitch bool // true when this entry represents a switch, not a for loop + Fallthroughs []int // fallthrough jump positions within the current case body } // CompilerError represents a compiler error. @@ -269,25 +271,36 @@ func (c *Compiler) Compile(node parser.Node) error { curPos := len(c.currentInstructions()) c.changeOperand(jumpPos1, curPos) } + case *parser.SwitchStmt: + return c.compileSwitchStmt(node) case *parser.ForStmt: return c.compileForStmt(node) case *parser.ForInStmt: return c.compileForInStmt(node) case *parser.BranchStmt: if node.Token == token.Break { - curLoop := c.currentLoop() - if curLoop == nil { - return c.errorf(node, "break not allowed outside loop") + // break exits the innermost loop OR switch + curTarget := c.currentBreakTarget() + if curTarget == nil { + return c.errorf(node, "break not allowed outside loop or switch") } pos := c.emit(node, parser.OpJump, 0) - curLoop.Breaks = append(curLoop.Breaks, pos) + curTarget.Breaks = append(curTarget.Breaks, pos) } else if node.Token == token.Continue { - curLoop := c.currentLoop() + // continue skips switch contexts and targets the enclosing loop + curLoop := c.currentContinueTarget() if curLoop == nil { return c.errorf(node, "continue not allowed outside loop") } pos := c.emit(node, parser.OpJump, 0) curLoop.Continues = append(curLoop.Continues, pos) + } else if node.Token == token.Fallthrough { + curSwitch := c.currentSwitchContext() + if curSwitch == nil { + return c.errorf(node, "fallthrough not allowed outside switch") + } + pos := c.emit(node, parser.OpJump, 0) + curSwitch.Fallthroughs = append(curSwitch.Fallthroughs, pos) } else { panic(fmt.Errorf("invalid branch statement: %s", node.Token.String())) @@ -1077,6 +1090,190 @@ func (c *Compiler) currentLoop() *loop { return nil } +// currentBreakTarget returns the innermost loop or switch context. +func (c *Compiler) currentBreakTarget() *loop { + if c.loopIndex >= 0 { + return c.loops[c.loopIndex] + } + return nil +} + +// currentContinueTarget returns the innermost non-switch loop context. +func (c *Compiler) currentContinueTarget() *loop { + for i := c.loopIndex; i >= 0; i-- { + if !c.loops[i].IsSwitch { + return c.loops[i] + } + } + return nil +} + +// currentSwitchContext returns the innermost switch context. +func (c *Compiler) currentSwitchContext() *loop { + for i := c.loopIndex; i >= 0; i-- { + if c.loops[i].IsSwitch { + return c.loops[i] + } + } + return nil +} + +func (c *Compiler) compileSwitchStmt(node *parser.SwitchStmt) error { + // new scope so that init variables don't leak + c.symbolTable = c.symbolTable.Fork(true) + defer func() { + c.symbolTable = c.symbolTable.Parent(false) + }() + + if node.Init != nil { + if err := c.Compile(node.Init); err != nil { + return err + } + } + + isTagged := node.Tag != nil + if isTagged { + if err := c.Compile(node.Tag); err != nil { + return err + } + } + + // Collect clauses; validate default is last + var clauses []*parser.CaseClause + for _, stmt := range node.Body.Stmts { + clause, ok := stmt.(*parser.CaseClause) + if !ok { + continue + } + clauses = append(clauses, clause) + } + for i, clause := range clauses { + if clause.List == nil && i != len(clauses)-1 { + return c.errorf(clause, "default clause must be last in switch") + } + } + + // Enter switch context (tracks breaks and per-case fallthroughs) + swCtx := &loop{IsSwitch: true} + c.loops = append(c.loops, swCtx) + c.loopIndex++ + + var exitJumps []int // all jumps to the post-switch position + var pendingFTs []int // fallthrough jumps from the previous case + + for i, clause := range clauses { + isDefault := clause.List == nil + var bodyMatchJumps []int // intermediate value matches → body start + var nextClauseJumps []int // condition miss → start of next clause + + if !isDefault { + for j, val := range clause.List { + isLastVal := j == len(clause.List)-1 + + if isTagged { + c.emit(node, parser.OpDup) + if err := c.Compile(val); err != nil { + c.loops = c.loops[:len(c.loops)-1] + c.loopIndex-- + return err + } + c.emit(node, parser.OpEqual) + } else { + if err := c.Compile(val); err != nil { + c.loops = c.loops[:len(c.loops)-1] + c.loopIndex-- + return err + } + } + + if isLastVal { + nextClauseJumps = append(nextClauseJumps, + c.emit(node, parser.OpJumpFalsy, 0)) + } else { + // not matched → try next value + tryNext := c.emit(node, parser.OpJumpFalsy, 0) + // matched → jump to body start (forward ref) + bodyMatchJumps = append(bodyMatchJumps, + c.emit(node, parser.OpJump, 0)) + c.changeOperand(tryNext, len(c.currentInstructions())) + } + } + } + + // bodyStartWithPop: where matched-condition jumps land (tag still on stack) + bodyStartWithPop := len(c.currentInstructions()) + for _, pos := range bodyMatchJumps { + c.changeOperand(pos, bodyStartWithPop) + } + + // Pop the tag; reached by matched conditions (and by "no match → default") + if isTagged { + c.emit(node, parser.OpPop) + } + + // bodyStartAfterPop: where fallthrough jumps land (tag already consumed) + bodyStartAfterPop := len(c.currentInstructions()) + for _, pos := range pendingFTs { + c.changeOperand(pos, bodyStartAfterPop) + } + pendingFTs = nil + + // Compile clause body; collect any fallthrough jumps emitted + swCtx.Fallthroughs = nil + for _, stmt := range clause.Body { + if err := c.Compile(stmt); err != nil { + c.loops = c.loops[:len(c.loops)-1] + c.loopIndex-- + return err + } + } + pendingFTs = append(pendingFTs, swCtx.Fallthroughs...) + swCtx.Fallthroughs = nil + + // Validate: fallthrough in the last clause is an error + if i == len(clauses)-1 && len(pendingFTs) > 0 { + c.loops = c.loops[:len(c.loops)-1] + c.loopIndex-- + return c.errorf(node, "cannot fallthrough final case in switch") + } + + // Jump to exit after each clause body + exitJumps = append(exitJumps, c.emit(node, parser.OpJump, 0)) + + // Backpatch condition-miss jumps to the start of the next clause + nextClauseStart := len(c.currentInstructions()) + for _, pos := range nextClauseJumps { + c.changeOperand(pos, nextClauseStart) + } + } + + // If no default and the tag wasn't consumed, pop it now + hasDefault := len(clauses) > 0 && clauses[len(clauses)-1].List == nil + if isTagged && !hasDefault { + c.emit(node, parser.OpPop) + } + + // Backpatch any stray fallthroughs (empty clause list edge case) + exitPos := len(c.currentInstructions()) + for _, pos := range pendingFTs { + c.changeOperand(pos, exitPos) + } + + // Backpatch all exit jumps and break jumps to here + for _, pos := range exitJumps { + c.changeOperand(pos, exitPos) + } + for _, pos := range swCtx.Breaks { + c.changeOperand(pos, exitPos) + } + + // Leave switch context + c.loops = c.loops[:len(c.loops)-1] + c.loopIndex-- + + return nil +} + func (c *Compiler) currentInstructions() []byte { return c.scopes[c.scopeIndex].Instructions } diff --git a/compiler_test.go b/compiler_test.go index e47d08c8..8fec3b5d 100644 --- a/compiler_test.go +++ b/compiler_test.go @@ -1036,7 +1036,7 @@ func TestCompilerErrorReport(t *testing.T) { expectCompileError(t, `return 5`, "Compile Error: return not allowed outside function\n\tat test:1:1") expectCompileError(t, `func() { break }`, - "Compile Error: break not allowed outside loop\n\tat test:1:10") + "Compile Error: break not allowed outside loop or switch\n\tat test:1:10") expectCompileError(t, `func() { continue }`, "Compile Error: continue not allowed outside loop\n\tat test:1:10") expectCompileError(t, `func() { export 5 }`, diff --git a/parser/opcodes.go b/parser/opcodes.go index c1e94c1f..12bfc509 100644 --- a/parser/opcodes.go +++ b/parser/opcodes.go @@ -47,6 +47,7 @@ const ( OpIteratorValue // Iterator value OpBinaryOp // Binary operation OpSuspend // Suspend VM + OpDup // Duplicate top of stack ) // OpcodeNames are string representation of opcodes. @@ -93,6 +94,7 @@ var OpcodeNames = [...]string{ OpIteratorValue: "ITVAL", OpBinaryOp: "BINARYOP", OpSuspend: "SUSPEND", + OpDup: "DUP", } // OpcodeOperands is the number of operands. @@ -139,6 +141,7 @@ var OpcodeOperands = [...][]int{ OpIteratorValue: {}, OpBinaryOp: {1}, OpSuspend: {}, + OpDup: {}, } // ReadOperands reads operands from the bytecode. diff --git a/parser/parser.go b/parser/parser.go index a12af614..7ca07f89 100644 --- a/parser/parser.go +++ b/parser/parser.go @@ -12,12 +12,14 @@ import ( type bailout struct{} var stmtStart = map[token.Token]bool{ - token.Break: true, - token.Continue: true, - token.For: true, - token.If: true, - token.Return: true, - token.Export: true, + token.Break: true, + token.Continue: true, + token.Fallthrough: true, + token.For: true, + token.If: true, + token.Return: true, + token.Export: true, + token.Switch: true, } // Error represents a parser error. @@ -697,8 +699,10 @@ func (p *Parser) parseStmt() (stmt Stmt) { return p.parseIfStmt() case token.For: return p.parseForStmt() - case token.Break, token.Continue: + case token.Break, token.Continue, token.Fallthrough: return p.parseBranchStmt(p.token) + case token.Switch: + return p.parseSwitchStmt() case token.Semicolon: s := &EmptyStmt{Semicolon: p.pos, Implicit: p.tokenLit == "\n"} p.next() @@ -836,6 +840,82 @@ func (p *Parser) parseIfStmt() Stmt { } } +func (p *Parser) parseSwitchStmt() Stmt { + if p.trace { + defer untracep(tracep(p, "SwitchStmt")) + } + + pos := p.expect(token.Switch) + + var init Stmt + var tag Expr + + if p.token != token.LBrace { + outer := p.exprLevel + p.exprLevel = -1 + + s := p.parseSimpleStmt(false) + if p.token == token.Semicolon { + p.next() + init = s + if p.token != token.LBrace { + tag = p.makeExpr(p.parseSimpleStmt(false), "switch expression") + } + } else { + tag = p.makeExpr(s, "switch expression") + } + + p.exprLevel = outer + } + + lbrace := p.expect(token.LBrace) + var list []Stmt + for p.token == token.Case || p.token == token.Default { + list = append(list, p.parseCaseClause()) + } + rbrace := p.expect(token.RBrace) + p.expectSemi() + + body := &BlockStmt{LBrace: lbrace, RBrace: rbrace, Stmts: list} + return &SwitchStmt{ + SwitchPos: pos, + Init: init, + Tag: tag, + Body: body, + } +} + +func (p *Parser) parseCaseClause() *CaseClause { + if p.trace { + defer untracep(tracep(p, "CaseClause")) + } + + pos := p.pos + var list []Expr + + if p.token == token.Case { + p.next() + list = p.parseExprList() + } else { + p.expect(token.Default) + } + + p.expect(token.Colon) + + var body []Stmt + for p.token != token.Case && p.token != token.Default && + p.token != token.RBrace && p.token != token.EOF { + body = append(body, p.parseStmt()) + } + + return &CaseClause{ + CasePos: pos, + List: list, + Body: body, + EndPos: p.pos, + } +} + func (p *Parser) parseBlockStmt() *BlockStmt { if p.trace { defer untracep(tracep(p, "BlockStmt")) @@ -1098,8 +1178,8 @@ func (p *Parser) expect(token token.Token) Pos { func (p *Parser) expectSemi() { switch p.token { - case token.RParen, token.RBrace: - // semicolon is optional before a closing ')' or '}' + case token.RParen, token.RBrace, token.Case, token.Default: + // semicolon is optional before a closing ')' or '}', or before a switch clause case token.Comma: // permit a ',' instead of a ';' but complain p.errorExpected(p.pos, "';'") diff --git a/parser/scanner.go b/parser/scanner.go index 5f797706..0152456d 100644 --- a/parser/scanner.go +++ b/parser/scanner.go @@ -89,8 +89,9 @@ func (s *Scanner) Scan() ( literal = s.scanIdentifier() tok = token.Lookup(literal) switch tok { - case token.Ident, token.Break, token.Continue, token.Return, - token.Export, token.True, token.False, token.Undefined: + case token.Ident, token.Break, token.Continue, token.Fallthrough, + token.Return, token.Export, token.True, token.False, + token.Undefined: insertSemi = true } case ('0' <= ch && ch <= '9') || (ch == '.' && '0' <= s.peek() && s.peek() <= '9'): diff --git a/parser/stmt.go b/parser/stmt.go index c0848c48..6e73f457 100644 --- a/parser/stmt.go +++ b/parser/stmt.go @@ -347,3 +347,62 @@ func (s *ReturnStmt) String() string { } return "return" } + +// CaseClause represents a case or default clause in a switch statement. +type CaseClause struct { + CasePos Pos // position of "case" or "default" keyword + List []Expr // nil means default clause + Body []Stmt + EndPos Pos +} + +func (s *CaseClause) stmtNode() {} + +// Pos returns the position of first character belonging to the node. +func (s *CaseClause) Pos() Pos { return s.CasePos } + +// End returns the position of first character immediately after the node. +func (s *CaseClause) End() Pos { return s.EndPos } + +func (s *CaseClause) String() string { + var bodyList []string + for _, stmt := range s.Body { + bodyList = append(bodyList, stmt.String()) + } + body := strings.Join(bodyList, "; ") + if s.List == nil { + return "default: " + body + } + var list []string + for _, e := range s.List { + list = append(list, e.String()) + } + return "case " + strings.Join(list, ", ") + ": " + body +} + +// SwitchStmt represents a switch statement. +type SwitchStmt struct { + SwitchPos Pos + Init Stmt // optional init statement; may be nil + Tag Expr // optional tag expression; nil means tagless switch + Body *BlockStmt // list of CaseClause statements +} + +func (s *SwitchStmt) stmtNode() {} + +// Pos returns the position of first character belonging to the node. +func (s *SwitchStmt) Pos() Pos { return s.SwitchPos } + +// End returns the position of first character immediately after the node. +func (s *SwitchStmt) End() Pos { return s.Body.End() } + +func (s *SwitchStmt) String() string { + var initStr, tagStr string + if s.Init != nil { + initStr = s.Init.String() + "; " + } + if s.Tag != nil { + tagStr = s.Tag.String() + " " + } + return "switch " + initStr + tagStr + s.Body.String() +} diff --git a/token/token.go b/token/token.go index 4e6aa80a..9238d945 100644 --- a/token/token.go +++ b/token/token.go @@ -72,6 +72,7 @@ const ( Break Continue Else + Fallthrough For Func Error @@ -84,6 +85,9 @@ const ( In Undefined Import + Case + Default + Switch _keywordEnd ) @@ -146,6 +150,7 @@ var tokens = [...]string{ Break: "break", Continue: "continue", Else: "else", + Fallthrough: "fallthrough", For: "for", Func: "func", Error: "error", @@ -158,6 +163,9 @@ var tokens = [...]string{ In: "in", Undefined: "undefined", Import: "import", + Case: "case", + Default: "default", + Switch: "switch", } func (tok Token) String() string { diff --git a/vm.go b/vm.go index 74b7742e..293b5841 100644 --- a/vm.go +++ b/vm.go @@ -869,6 +869,9 @@ func (v *VM) run() { v.sp++ case parser.OpSuspend: return + case parser.OpDup: + v.stack[v.sp] = v.stack[v.sp-1] + v.sp++ default: v.err = fmt.Errorf("unknown opcode: %d", v.curInsts[v.ip]) return diff --git a/vm_test.go b/vm_test.go index 0828865a..8889140e 100644 --- a/vm_test.go +++ b/vm_test.go @@ -3649,6 +3649,102 @@ func TestSliceIndex(t *testing.T) { expectError(t, `a := 123[-1:2] ; a += 1`, nil, "Runtime Error: not indexable") } +func TestSwitch(t *testing.T) { + // tagged switch — basic match + expectRun(t, `x := 1; switch x { case 1: out = "one" case 2: out = "two" }`, nil, "one") + expectRun(t, `x := 2; switch x { case 1: out = "one" case 2: out = "two" }`, nil, "two") + + // no match, no default + expectRun(t, `switch 99 { case 1: out = "one" }`, nil, tengo.UndefinedValue) + + // default clause + expectRun(t, `switch 99 { case 1: out = "one" default: out = "other" }`, nil, "other") + expectRun(t, `switch 1 { case 1: out = "one" default: out = "other" }`, nil, "one") + + // default only + expectRun(t, `switch 1 { default: out = 42 }`, nil, 42) + + // empty switch + expectRun(t, `switch 1 {}`, nil, tengo.UndefinedValue) + + // multi-value case + expectRun(t, `switch 2 { case 1, 2, 3: out = "low" case 4, 5, 6: out = "high" }`, nil, "low") + expectRun(t, `switch 5 { case 1, 2, 3: out = "low" case 4, 5, 6: out = "high" }`, nil, "high") + expectRun(t, `switch 9 { case 1, 2, 3: out = "low" case 4, 5, 6: out = "high" default: out = "?" }`, nil, "?") + + // string tag + expectRun(t, `switch "hello" { case "hello": out = 1 case "world": out = 2 }`, nil, 1) + + // expression as tag + expectRun(t, `switch 2 + 3 { case 5: out = "five" case 6: out = "six" }`, nil, "five") + + // tagless switch (boolean conditions) + expectRun(t, `x := 3; switch { case x < 2: out = "small" case x > 4: out = "large" default: out = "mid" }`, nil, "mid") + expectRun(t, `x := 1; switch { case x < 2: out = "small" case x > 4: out = "large" default: out = "mid" }`, nil, "small") + expectRun(t, `x := 9; switch { case x < 2: out = "small" case x > 4: out = "large" default: out = "mid" }`, nil, "large") + + // init statement + expectRun(t, `switch x := 5; x { case 5: out = "five" default: out = "other" }`, nil, "five") + expectRun(t, `switch x := 9; x { case 5: out = "five" default: out = "other" }`, nil, "other") + + // init with tagless switch + expectRun(t, `switch x := 3; { case x > 2: out = "big" default: out = "small" }`, nil, "big") + + // break exits switch early + expectRun(t, `switch 1 { case 1: out = 1; break; out = 2 }`, nil, 1) + + // break in switch does not affect enclosing loop + expectRun(t, ` + out = 0 + for i := 0; i < 3; i++ { + switch i { + case 1: out = i; break + } + }`, nil, 1) + + // fallthrough executes next case's body (skipping its condition) + expectRun(t, `switch 1 { case 1: out = 1; fallthrough case 2: out = 2 }`, nil, 2) + expectRun(t, `switch 2 { case 1: out = 1; fallthrough case 2: out = 2 }`, nil, 2) + + // fallthrough chain + expectRun(t, ` + a := [] + switch 1 { + case 1: + a = append(a, 1) + fallthrough + case 2: + a = append(a, 2) + fallthrough + case 3: + a = append(a, 3) + } + out = a`, nil, ARR{1, 2, 3}) + + // continue targets the enclosing loop, not the switch + expectRun(t, ` + out = 0 + for i := 0; i < 5; i++ { + switch i { + case 2: continue + } + out += i + }`, nil, 1+3+4) // 0+1+3+4 = 8; i=2 is skipped + + // switch tag evaluated once (not per-case) + expectRun(t, ` + n := 0 + f := func() { n++; return 2 } + switch f() { + case 1: out = "one" + case 2: out = "two" + } + out = string(out) + ":" + string(n)`, nil, "two:1") + + // error: default must be last clause + expectError(t, `switch 1 { default: out = 1 case 2: out = 2 }`, nil, "default") +} + func expectRun( t *testing.T, input string,