diff --git a/src/compiler/compiler.ts b/src/compiler/compiler.ts index 72b0f23..4e35747 100644 --- a/src/compiler/compiler.ts +++ b/src/compiler/compiler.ts @@ -17,6 +17,7 @@ import { getNamedArgParts, getPipeExprParts, getStringParts, + getTryExprParts, } from '#compiler/utils' const DEBUG = false @@ -51,6 +52,7 @@ export class Compiler { instructions: ProgramItem[] = [] fnLabelCount = 0 ifLabelCount = 0 + tryLabelCount = 0 bytecode: Bytecode pipeCounter = 0 @@ -253,7 +255,10 @@ export class Compiler { } case terms.FunctionDef: { - const { paramNames, bodyNodes } = getFunctionDefParts(node, input) + const { paramNames, bodyNodes, catchVariable, catchBody, finallyBody } = getFunctionDefParts( + node, + input + ) const instructions: ProgramItem[] = [] const functionLabel: Label = `.func_${this.fnLabelCount++}` const afterLabel: Label = `.after_${functionLabel}` @@ -261,9 +266,27 @@ export class Compiler { instructions.push(['JUMP', afterLabel]) instructions.push([`${functionLabel}:`]) - bodyNodes.forEach((bodyNode) => { - instructions.push(...this.#compileNode(bodyNode, input)) - }) + + const compileFunctionBody = () => { + const bodyInstructions: ProgramItem[] = [] + bodyNodes.forEach((bodyNode, index) => { + bodyInstructions.push(...this.#compileNode(bodyNode, input)) + if (index < bodyNodes.length - 1) { + bodyInstructions.push(['POP']) + } + }) + return bodyInstructions + } + + if (catchVariable || finallyBody) { + // If function has catch or finally, wrap body in try/catch/finally + instructions.push( + ...this.#compileTryCatchFinally(compileFunctionBody, catchVariable, catchBody, finallyBody, input) + ) + } else { + instructions.push(...compileFunctionBody()) + } + instructions.push(['RETURN']) instructions.push([`${afterLabel}:`]) @@ -317,10 +340,48 @@ export class Compiler { } case terms.ThenBlock: - case terms.SingleLineThenBlock: { - const instructions = getAllChildren(node) - .map((child) => this.#compileNode(child, input)) - .flat() + case terms.SingleLineThenBlock: + case terms.TryBlock: { + const children = getAllChildren(node) + const instructions: ProgramItem[] = [] + + children.forEach((child, index) => { + instructions.push(...this.#compileNode(child, input)) + // keep only the last expression's value + if (index < children.length - 1) { + instructions.push(['POP']) + } + }) + + return instructions + } + + case terms.TryExpr: { + const { tryBlock, catchVariable, catchBody, finallyBody } = getTryExprParts(node, input) + + return this.#compileTryCatchFinally( + () => this.#compileNode(tryBlock, input), + catchVariable, + catchBody, + finallyBody, + input + ) + } + + case terms.Throw: { + const children = getAllChildren(node) + const [_throwKeyword, expression] = children + if (!expression) { + throw new CompilerError( + `Throw expected expression, got ${children.length} children`, + node.from, + node.to + ) + } + + const instructions: ProgramItem[] = [] + instructions.push(...this.#compileNode(expression, input)) + instructions.push(['THROW']) return instructions } @@ -527,4 +588,52 @@ export class Compiler { ) } } + + #compileTryCatchFinally( + compileTryBody: () => ProgramItem[], + catchVariable: string | undefined, + catchBody: SyntaxNode | undefined, + finallyBody: SyntaxNode | undefined, + input: string + ): ProgramItem[] { + const instructions: ProgramItem[] = [] + this.tryLabelCount++ + const catchLabel: Label = `.catch_${this.tryLabelCount}` + const finallyLabel: Label = finallyBody ? `.finally_${this.tryLabelCount}` : (null as any) + const endLabel: Label = `.end_try_${this.tryLabelCount}` + + instructions.push(['PUSH_TRY', catchLabel]) + instructions.push(...compileTryBody()) + instructions.push(['POP_TRY']) + instructions.push(['JUMP', finallyBody ? finallyLabel : endLabel]) + + // catch block + instructions.push([`${catchLabel}:`]) + if (catchBody && catchVariable) { + instructions.push(['STORE', catchVariable]) + const catchInstructions = this.#compileNode(catchBody, input) + instructions.push(...catchInstructions) + instructions.push(['JUMP', finallyBody ? finallyLabel : endLabel]) + } else { + // no catch block + if (finallyBody) { + instructions.push(['JUMP', finallyLabel]) + } else { + instructions.push(['THROW']) + } + } + + // finally block + if (finallyBody) { + instructions.push([`${finallyLabel}:`]) + const finallyInstructions = this.#compileNode(finallyBody, input) + instructions.push(...finallyInstructions) + // finally doesn't return a value + instructions.push(['POP']) + } + + instructions.push([`${endLabel}:`]) + + return instructions + } } diff --git a/src/compiler/tests/exceptions.test.ts b/src/compiler/tests/exceptions.test.ts new file mode 100644 index 0000000..9a71012 --- /dev/null +++ b/src/compiler/tests/exceptions.test.ts @@ -0,0 +1,311 @@ +import { describe } from 'bun:test' +import { expect, test } from 'bun:test' + +describe('exception handling', () => { + test('try with catch - no error thrown', () => { + expect(` + try: + 42 + catch err: + 99 + end + `).toEvaluateTo(42) + }) + + test('try with catch - error thrown', () => { + expect(` + try: + throw 'something went wrong' + 99 + catch err: + err + end + `).toEvaluateTo('something went wrong') + }) + + test('try with catch - catch variable binding', () => { + expect(` + try: + throw 100 + catch my-error: + my-error + 50 + end + `).toEvaluateTo(150) + }) + + test('try with finally - no error', () => { + expect(` + x = 0 + result = try: + x = 10 + 42 + finally: + x = x + 5 + end + x + `).toEvaluateTo(15) + }) + + test('try with finally - return value from try', () => { + expect(` + x = 0 + result = try: + x = 10 + 42 + finally: + x = x + 5 + 999 + end + result + `).toEvaluateTo(42) + }) + + test('try with catch and finally - no error', () => { + expect(` + x = 0 + try: + x = 10 + 42 + catch err: + x = 999 + 0 + finally: + x = x + 5 + end + x + `).toEvaluateTo(15) + }) + + test('try with catch and finally - error thrown', () => { + expect(` + x = 0 + result = try: + x = 10 + throw 'error' + 99 + catch err: + x = 20 + err + finally: + x = x + 5 + end + x + `).toEvaluateTo(25) + }) + + test('try with catch and finally - return value from catch', () => { + expect(` + result = try: + throw 'oops' + catch err: + 'caught' + finally: + 'finally' + end + result + `).toEvaluateTo('caught') + }) + + test('throw statement with string', () => { + expect(` + try: + throw 'error message' + catch err: + err + end + `).toEvaluateTo('error message') + }) + + test('throw statement with number', () => { + expect(` + try: + throw 404 + catch err: + err + end + `).toEvaluateTo(404) + }) + + test('throw statement with dict', () => { + expect(` + try: + throw [code=500 message=failed] + catch e: + e + end + `).toEvaluateTo({ code: 500, message: 'failed' }) + }) + + test('uncaught exception fails', () => { + expect(`throw 'uncaught error'`).toFailEvaluation() + }) + + test('single-line try catch', () => { + expect(`result = try: throw 'err' catch e: 'handled' end; result`).toEvaluateTo('handled') + }) + + test('nested try blocks - inner catches', () => { + expect(` + try: + result = try: + throw 'inner error' + catch err: + err + end + result + catch outer: + 'outer' + end + `).toEvaluateTo('inner error') + }) + + test('nested try blocks - outer catches', () => { + expect(` + try: + try: + throw 'inner error' + catch err: + throw 'outer error' + end + catch outer: + outer + end + `).toEvaluateTo('outer error') + }) + + test('try as expression', () => { + expect(` + x = try: 10 catch err: 0 end + y = try: throw 'err' catch err: 20 end + x + y + `).toEvaluateTo(30) + }) +}) + +describe('function-level exception handling', () => { + test('function with catch - no error', () => { + expect(` + read-file = do path: + path + catch e: + 'default' + end + + read-file test.txt + `).toEvaluateTo('test.txt') + }) + + test('function with catch - error thrown', () => { + expect(` + read-file = do path: + throw 'file not found' + catch e: + 'default' + end + + read-file test.txt + `).toEvaluateTo('default') + }) + + test('function with catch - error variable binding', () => { + expect(` + safe-call = do: + throw 'operation failed' + catch err: + err + end + + safe-call + `).toEvaluateTo('operation failed') + }) + + test('function with finally - always runs', () => { + expect(` + counter = 0 + increment-task = do: + result = 42 + result + finally: + counter = counter + 1 + end + + x = increment-task + y = increment-task + counter + `).toEvaluateTo(2) + }) + + test('function with finally - return value from body', () => { + expect(` + get-value = do: + 100 + finally: + 999 + end + + get-value + `).toEvaluateTo(100) + }) + + test('function with catch and finally', () => { + expect(` + cleanup-count = 0 + safe-op = do should-fail: + if should-fail: + throw 'failed' + end + 'success' + catch e: + 'caught' + finally: + cleanup-count = cleanup-count + 1 + end + + result1 = safe-op false + result2 = safe-op true + cleanup-count + `).toEvaluateTo(2) + }) + + test('function with catch and finally - catch return value', () => { + expect(` + safe-fail = do: + throw 'always fails' + catch e: + 'error handled' + finally: + noop = 1 + end + + safe-fail + `).toEvaluateTo('error handled') + }) + + test('function without catch/finally still works', () => { + expect(` + regular = do x: + x + 10 + end + + regular 5 + `).toEvaluateTo(15) + }) + + test('nested functions with catch', () => { + expect(` + inner = do: + throw 'inner error' + catch e: + 'inner caught' + end + + outer = do: + inner + catch e: + 'outer caught' + end + + outer + `).toEvaluateTo('inner caught') + }) +}) diff --git a/src/compiler/utils.ts b/src/compiler/utils.ts index 82b4025..7fd9cf5 100644 --- a/src/compiler/utils.ts +++ b/src/compiler/utils.ts @@ -59,11 +59,11 @@ export const getAssignmentParts = (node: SyntaxNode) => { export const getFunctionDefParts = (node: SyntaxNode, input: string) => { const children = getAllChildren(node) - const [fnKeyword, paramsNode, colon, ...bodyNodes] = children + const [fnKeyword, paramsNode, colon, ...rest] = children - if (!fnKeyword || !paramsNode || !colon || !bodyNodes) { + if (!fnKeyword || !paramsNode || !colon || !rest) { throw new CompilerError( - `FunctionDef expected 5 children, got ${children.length}`, + `FunctionDef expected at least 4 children, got ${children.length}`, node.from, node.to ) @@ -80,8 +80,48 @@ export const getFunctionDefParts = (node: SyntaxNode, input: string) => { return input.slice(param.from, param.to) }) - const bodyWithoutEnd = bodyNodes.slice(0, -1) - return { paramNames, bodyNodes: bodyWithoutEnd } + // Separate body nodes from catch/finally/end + const bodyNodes: SyntaxNode[] = [] + let catchExpr: SyntaxNode | undefined + let catchVariable: string | undefined + let catchBody: SyntaxNode | undefined + let finallyExpr: SyntaxNode | undefined + let finallyBody: SyntaxNode | undefined + + for (const child of rest) { + if (child.type.id === terms.CatchExpr) { + catchExpr = child + const catchChildren = getAllChildren(child) + const [_catchKeyword, identifierNode, _colon, body] = catchChildren + if (!identifierNode || !body) { + throw new CompilerError( + `CatchExpr expected identifier and body, got ${catchChildren.length} children`, + child.from, + child.to + ) + } + catchVariable = input.slice(identifierNode.from, identifierNode.to) + catchBody = body + } else if (child.type.id === terms.FinallyExpr) { + finallyExpr = child + const finallyChildren = getAllChildren(child) + const [_finallyKeyword, _colon, body] = finallyChildren + if (!body) { + throw new CompilerError( + `FinallyExpr expected body, got ${finallyChildren.length} children`, + child.from, + child.to + ) + } + finallyBody = body + } else if (child.type.name === 'keyword' && input.slice(child.from, child.to) === 'end') { + // Skip the end keyword + } else { + bodyNodes.push(child) + } + } + + return { paramNames, bodyNodes, catchVariable, catchBody, finallyBody } } export const getFunctionCallParts = (node: SyntaxNode, input: string) => { @@ -231,3 +271,62 @@ export const getDotGetParts = (node: SyntaxNode, input: string) => { return { objectName, property } } + +export const getTryExprParts = (node: SyntaxNode, input: string) => { + const children = getAllChildren(node) + + // First child is always 'try' keyword, second is colon, third is TryBlock or statement + const [tryKeyword, _colon, tryBlock, ...rest] = children + + if (!tryKeyword || !tryBlock) { + throw new CompilerError( + `TryExpr expected at least 3 children, got ${children.length}`, + node.from, + node.to + ) + } + + let catchExpr: SyntaxNode | undefined + let catchVariable: string | undefined + let catchBody: SyntaxNode | undefined + let finallyExpr: SyntaxNode | undefined + let finallyBody: SyntaxNode | undefined + + rest.forEach((child) => { + if (child.type.id === terms.CatchExpr) { + catchExpr = child + const catchChildren = getAllChildren(child) + const [_catchKeyword, identifierNode, _colon, body] = catchChildren + if (!identifierNode || !body) { + throw new CompilerError( + `CatchExpr expected identifier and body, got ${catchChildren.length} children`, + child.from, + child.to + ) + } + catchVariable = input.slice(identifierNode.from, identifierNode.to) + catchBody = body + } else if (child.type.id === terms.FinallyExpr) { + finallyExpr = child + const finallyChildren = getAllChildren(child) + const [_finallyKeyword, _colon, body] = finallyChildren + if (!body) { + throw new CompilerError( + `FinallyExpr expected body, got ${finallyChildren.length} children`, + child.from, + child.to + ) + } + finallyBody = body + } + }) + + return { + tryBlock, + catchExpr, + catchVariable, + catchBody, + finallyExpr, + finallyBody, + } +} diff --git a/src/parser/shrimp.grammar b/src/parser/shrimp.grammar index 635035a..455a9b2 100644 --- a/src/parser/shrimp.grammar +++ b/src/parser/shrimp.grammar @@ -51,6 +51,8 @@ item { consumeToTerminator { PipeExpr | ambiguousFunctionCall | + TryExpr | + Throw | IfExpr | FunctionDef | Assign | @@ -97,11 +99,11 @@ FunctionDef { } singleLineFunctionDef { - Do Params colon consumeToTerminator @specialize[@name=keyword] + Do Params colon consumeToTerminator CatchExpr? FinallyExpr? @specialize[@name=keyword] } multilineFunctionDef { - Do Params colon newlineOrSemicolon block @specialize[@name=keyword] + Do Params colon newlineOrSemicolon block CatchExpr? FinallyExpr? @specialize[@name=keyword] } IfExpr { @@ -129,7 +131,35 @@ ThenBlock { } SingleLineThenBlock { - consumeToTerminator + consumeToTerminator +} + +TryExpr { + singleLineTry | multilineTry +} + +singleLineTry { + @specialize[@name=keyword] colon consumeToTerminator CatchExpr? FinallyExpr? @specialize[@name=keyword] +} + +multilineTry { + @specialize[@name=keyword] colon newlineOrSemicolon TryBlock CatchExpr? FinallyExpr? @specialize[@name=keyword] +} + +CatchExpr { + @specialize[@name=keyword] Identifier colon (newlineOrSemicolon TryBlock | consumeToTerminator) +} + +FinallyExpr { + @specialize[@name=keyword] colon (newlineOrSemicolon TryBlock | consumeToTerminator) +} + +TryBlock { + block +} + +Throw { + @specialize[@name=keyword] expression } ConditionalOp { diff --git a/src/parser/shrimp.terms.ts b/src/parser/shrimp.terms.ts index 144d69b..8a80f73 100644 --- a/src/parser/shrimp.terms.ts +++ b/src/parser/shrimp.terms.ts @@ -39,15 +39,20 @@ export const FunctionDef = 37, Params = 38, colon = 39, - keyword = 54, - Underscore = 41, - Array = 42, - Null = 43, - ConditionalOp = 44, - PositionalArg = 45, - IfExpr = 47, - SingleLineThenBlock = 49, - ThenBlock = 50, - ElseIfExpr = 51, - ElseExpr = 53, - Assign = 55 + CatchExpr = 40, + keyword = 63, + TryBlock = 42, + FinallyExpr = 43, + Underscore = 46, + Array = 47, + Null = 48, + ConditionalOp = 49, + PositionalArg = 50, + TryExpr = 52, + Throw = 54, + IfExpr = 56, + SingleLineThenBlock = 58, + ThenBlock = 59, + ElseIfExpr = 60, + ElseExpr = 62, + Assign = 64 diff --git a/src/parser/shrimp.ts b/src/parser/shrimp.ts index 6e19a7f..f1f7a33 100644 --- a/src/parser/shrimp.ts +++ b/src/parser/shrimp.ts @@ -4,14 +4,14 @@ import {operatorTokenizer} from "./operatorTokenizer" import {tokenizer, specializeKeyword} from "./tokenizer" import {trackScope} from "./scopeTracker" import {highlighting} from "./highlight" -const spec_Identifier = {__proto__:null,end:80, null:86, if:96, elseif:104, else:108} +const spec_Identifier = {__proto__:null,catch:82, finally:88, end:90, null:96, try:106, throw:110, if:114, elseif:122, else:126} export const parser = LRParser.deserialize({ version: 14, - states: "3UQYQbOOO#hQcO'#CvO$eOSO'#CxO$sQbO'#EVOOQ`'#DR'#DROOQa'#DO'#DOO%vQbO'#DWO&{QcO'#DzOOQa'#Dz'#DzO)PQcO'#DyO)xQRO'#CwO*]QcO'#DuO*tQcO'#DuO+VQbO'#CuO+}OpO'#CsOOQ`'#Dv'#DvO,SQbO'#DuO,bQbO'#E]OOQ`'#D]'#D]O-VQRO'#DeOOQ`'#Du'#DuO-[QQO'#DtOOQ`'#Dt'#DtOOQ`'#Df'#DfQYQbOOO-dQbO'#DPOOQa'#Dy'#DyOOQ`'#DZ'#DZOOQ`'#E['#E[OOQ`'#Dm'#DmO-nQbO,59^O.RQbO'#CzO.ZQWO'#C{OOOO'#D|'#D|OOOO'#Dg'#DgO.oOSO,59dOOQa,59d,59dOOQ`'#Di'#DiO.}QbO'#DSO/VQQO,5:qOOQ`'#Dh'#DhO/[QbO,59rO/cQQO,59jOOQa,59r,59rO/nQbO,59rO,bQbO,59cO,bQbO,59cO,bQbO,59cO,bQbO,59tO,bQbO,59tO,bQbO,59tO/xQRO,59aO0PQRO,59aO0bQRO,59aO0]QQO,59aO0mQQO,59aO0uObO,59_O1QQbO'#DnO1]QbO,59]O1nQRO,5:wO1uQRO,5:wO2QQbO,5:POOQ`,5:`,5:`OOQ`-E7d-E7dOOQ`,59k,59kOOQ`-E7k-E7kOOOO,59f,59fOOOO,59g,59gOOOO-E7e-E7eOOQa1G/O1G/OOOQ`-E7g-E7gO2[QbO1G0]OOQ`-E7f-E7fO2iQQO1G/UOOQa1G/^1G/^O2tQbO1G/^OOQO'#Dk'#DkO2iQQO1G/UOOQa1G/U1G/UOOQ`'#Dl'#DlO2tQbO1G/^OOQa1G.}1G.}O3gQcO1G.}O3qQcO1G.}O3{QcO1G.}OOQa1G/`1G/`O5_QcO1G/`O5fQcO1G/`O5mQcO1G/`OOQa1G.{1G.{OOQa1G.y1G.yO!ZQbO'#CvO%}QbO'#CrOOQ`,5:Y,5:YOOQ`-E7l-E7lO5tQbO1G0cOOQ`1G/k1G/kO6RQbO7+%wO6WQbO7+%xO6hQQO7+$pOOQa7+$p7+$pO6sQbO7+$xOOQa7+$x7+$xOOQO-E7i-E7iOOQ`-E7j-E7jOOQ`'#D_'#D_O6}QbO7+%}O7SQbO7+&OOOQ`<bQbO1G/_OOQ`1G/_1G/_OOQ`AN?^AN?^OOQ`AN?_AN?_O>xQbOAN?_O,{QbO'#DjOOQ`'#Dx'#DxO>}QbOAN?aO?YQQO'#DlOOQ`AN?aAN?aO?_QbOAN?aOOQ`G24rG24rOOQ`G24tG24tO?dQbOG24tO?iQbO7+$vOOQ`7+$v7+$vOOQ`7+$y7+$yOOQ`G24yG24yO@SQRO,5:UO@ZQRO,5:UOOQ`-E7v-E7vOOQ`G24{G24{O@fQbOG24{O@kQQO,5:WOOQ`LD*`LD*`OOQ`<bQbO1G/rO;^QbO7+%[OOQ`7+%^7+%^OOQ`<h#i#o7^#o#p#{#p#q@`#q;'S#{;'S;=`$d<%l~#{~O#{~~@yS$QUmSOt#{uw#{x#O#{#P;'S#{;'S;=`$d<%lO#{S$gP;=`<%l#{^$qUmS!fYOt#{uw#{x#O#{#P;'S#{;'S;=`$d<%lO#{U%[UmS!xQOt#{uw#{x#O#{#P;'S#{;'S;=`$d<%lO#{^%uZmS!gYOY%nYZ#{Zt%ntu&huw%nwx&hx#O%n#O#P&h#P;'S%n;'S;=`'P<%lO%nY&mS!gYOY&hZ;'S&h;'S;=`&y<%lO&hY&|P;=`<%l&h^'SP;=`<%l%n~'[O!q~~'aO!o~U'hUmS!lQOt#{uw#{x#O#{#P;'S#{;'S;=`$d<%lO#{U(RUmS!}QOt#{uw#{x#O#{#P;'S#{;'S;=`$d<%lO#{U(jWmSOt#{uw#{x!Q#{!Q![)S![#O#{#P;'S#{;'S;=`$d<%lO#{U)ZYmShQOt#{uw#{x!O#{!O!P)y!P!Q#{!Q![)S![#O#{#P;'S#{;'S;=`$d<%lO#{U*OWmSOt#{uw#{x!Q#{!Q![*h![#O#{#P;'S#{;'S;=`$d<%lO#{U*oWmShQOt#{uw#{x!Q#{!Q![*h![#O#{#P;'S#{;'S;=`$d<%lO#{U+^WmSOt#{uw#{x!P#{!P!Q+v!Q#O#{#P;'S#{;'S;=`$d<%lO#{U+{^mSOY,wYZ#{Zt,wtu-zuw,wwx-zx!P,w!P!Q#{!Q!},w!}#O2m#O#P0Y#P;'S,w;'S;=`3n<%lO,wU-O^mSqQOY,wYZ#{Zt,wtu-zuw,wwx-zx!P,w!P!Q0o!Q!},w!}#O2m#O#P0Y#P;'S,w;'S;=`3n<%lO,wQ.PXqQOY-zZ!P-z!P!Q.l!Q!}-z!}#O/Z#O#P0Y#P;'S-z;'S;=`0i<%lO-zQ.oP!P!Q.rQ.wUqQ#Z#[.r#]#^.r#a#b.r#g#h.r#i#j.r#m#n.rQ/^VOY/ZZ#O/Z#O#P/s#P#Q-z#Q;'S/Z;'S;=`0S<%lO/ZQ/vSOY/ZZ;'S/Z;'S;=`0S<%lO/ZQ0VP;=`<%l/ZQ0]SOY-zZ;'S-z;'S;=`0i<%lO-zQ0lP;=`<%l-zU0tWmSOt#{uw#{x!P#{!P!Q1^!Q#O#{#P;'S#{;'S;=`$d<%lO#{U1ebmSqQOt#{uw#{x#O#{#P#Z#{#Z#[1^#[#]#{#]#^1^#^#a#{#a#b1^#b#g#{#g#h1^#h#i#{#i#j1^#j#m#{#m#n1^#n;'S#{;'S;=`$d<%lO#{U2r[mSOY2mYZ#{Zt2mtu/Zuw2mwx/Zx#O2m#O#P/s#P#Q,w#Q;'S2m;'S;=`3h<%lO2mU3kP;=`<%l2mU3qP;=`<%l,wU3{UmSwQOt#{uw#{x#O#{#P;'S#{;'S;=`$d<%lO#{U4fW!wQmSOt#{uw#{x!_#{!_!`5O!`#O#{#P;'S#{;'S;=`$d<%lO#{U5TVmSOt#{uw#{x#O#{#P#Q5j#Q;'S#{;'S;=`$d<%lO#{U5qU!vQmSOt#{uw#{x#O#{#P;'S#{;'S;=`$d<%lO#{~6YO!r~U6aU!|QmSOt#{uw#{x#O#{#P;'S#{;'S;=`$d<%lO#{U6zUmSyQOt#{uw#{x#O#{#P;'S#{;'S;=`$d<%lO#{U7cYmSOt#{uw#{x!_#{!_!`8R!`#O#{#P#T#{#T#o7^#o;'S#{;'S;=`$d<%lO#{U8YUtQmSOt#{uw#{x#O#{#P;'S#{;'S;=`$d<%lO#{U8qZmSOt#{uw#{x!_#{!_!`8R!`#O#{#P#T#{#T#U9d#U#o7^#o;'S#{;'S;=`$d<%lO#{U9i[mSOt#{uw#{x!_#{!_!`8R!`#O#{#P#T#{#T#`7^#`#a:_#a#o7^#o;'S#{;'S;=`$d<%lO#{U:d[mSOt#{uw#{x!_#{!_!`8R!`#O#{#P#T#{#T#g7^#g#h;Y#h#o7^#o;'S#{;'S;=`$d<%lO#{U;_[mSOt#{uw#{x!_#{!_!`8R!`#O#{#P#T#{#T#X7^#X#Yo[!tWmSOt#{uw#{x!_#{!_!`8R!`#O#{#P#T#{#T#f7^#f#g?e#g#o7^#o;'S#{;'S;=`$d<%lO#{U?j[mSOt#{uw#{x!_#{!_!`8R!`#O#{#P#T#{#T#i7^#i#j;Y#j#o7^#o;'S#{;'S;=`$d<%lO#{U@gU!OQmSOt#{uw#{x#O#{#P;'S#{;'S;=`$d<%lO#{~AOO#R~", - tokenizers: [operatorTokenizer, 1, 2, 3, tokenizer, new LocalTokenGroup("[~RP!O!PU~ZO!k~~", 11)], + tokenData: "AO~R|OX#{XY$jYZ%TZp#{pq$jqs#{st%ntu'Vuw#{wx'[xy'ayz'zz{#{{|(e|}#{}!O(e!O!P#{!P!Q+X!Q![)S![!]3t!]!^%T!^!}#{!}#O4_#O#P6T#P#Q6Y#Q#R#{#R#S6s#S#T#{#T#Y7^#Y#Z8l#Z#b7^#b#ch#i#o7^#o#p#{#p#q@`#q;'S#{;'S;=`$d<%l~#{~O#{~~@yS$QUmSOt#{uw#{x#O#{#P;'S#{;'S;=`$d<%lO#{S$gP;=`<%l#{^$qUmS!oYOt#{uw#{x#O#{#P;'S#{;'S;=`$d<%lO#{U%[UmS#RQOt#{uw#{x#O#{#P;'S#{;'S;=`$d<%lO#{^%uZmS!pYOY%nYZ#{Zt%ntu&huw%nwx&hx#O%n#O#P&h#P;'S%n;'S;=`'P<%lO%nY&mS!pYOY&hZ;'S&h;'S;=`&y<%lO&hY&|P;=`<%l&h^'SP;=`<%l%n~'[O!z~~'aO!x~U'hUmS!uQOt#{uw#{x#O#{#P;'S#{;'S;=`$d<%lO#{U(RUmS#WQOt#{uw#{x#O#{#P;'S#{;'S;=`$d<%lO#{U(jWmSOt#{uw#{x!Q#{!Q![)S![#O#{#P;'S#{;'S;=`$d<%lO#{U)ZYmShQOt#{uw#{x!O#{!O!P)y!P!Q#{!Q![)S![#O#{#P;'S#{;'S;=`$d<%lO#{U*OWmSOt#{uw#{x!Q#{!Q![*h![#O#{#P;'S#{;'S;=`$d<%lO#{U*oWmShQOt#{uw#{x!Q#{!Q![*h![#O#{#P;'S#{;'S;=`$d<%lO#{U+^WmSOt#{uw#{x!P#{!P!Q+v!Q#O#{#P;'S#{;'S;=`$d<%lO#{U+{^mSOY,wYZ#{Zt,wtu-zuw,wwx-zx!P,w!P!Q#{!Q!},w!}#O2m#O#P0Y#P;'S,w;'S;=`3n<%lO,wU-O^mSqQOY,wYZ#{Zt,wtu-zuw,wwx-zx!P,w!P!Q0o!Q!},w!}#O2m#O#P0Y#P;'S,w;'S;=`3n<%lO,wQ.PXqQOY-zZ!P-z!P!Q.l!Q!}-z!}#O/Z#O#P0Y#P;'S-z;'S;=`0i<%lO-zQ.oP!P!Q.rQ.wUqQ#Z#[.r#]#^.r#a#b.r#g#h.r#i#j.r#m#n.rQ/^VOY/ZZ#O/Z#O#P/s#P#Q-z#Q;'S/Z;'S;=`0S<%lO/ZQ/vSOY/ZZ;'S/Z;'S;=`0S<%lO/ZQ0VP;=`<%l/ZQ0]SOY-zZ;'S-z;'S;=`0i<%lO-zQ0lP;=`<%l-zU0tWmSOt#{uw#{x!P#{!P!Q1^!Q#O#{#P;'S#{;'S;=`$d<%lO#{U1ebmSqQOt#{uw#{x#O#{#P#Z#{#Z#[1^#[#]#{#]#^1^#^#a#{#a#b1^#b#g#{#g#h1^#h#i#{#i#j1^#j#m#{#m#n1^#n;'S#{;'S;=`$d<%lO#{U2r[mSOY2mYZ#{Zt2mtu/Zuw2mwx/Zx#O2m#O#P/s#P#Q,w#Q;'S2m;'S;=`3h<%lO2mU3kP;=`<%l2mU3qP;=`<%l,wU3{UmSwQOt#{uw#{x#O#{#P;'S#{;'S;=`$d<%lO#{U4fW#QQmSOt#{uw#{x!_#{!_!`5O!`#O#{#P;'S#{;'S;=`$d<%lO#{U5TVmSOt#{uw#{x#O#{#P#Q5j#Q;'S#{;'S;=`$d<%lO#{U5qU#PQmSOt#{uw#{x#O#{#P;'S#{;'S;=`$d<%lO#{~6YO!{~U6aU#VQmSOt#{uw#{x#O#{#P;'S#{;'S;=`$d<%lO#{U6zUmS!OQOt#{uw#{x#O#{#P;'S#{;'S;=`$d<%lO#{U7cYmSOt#{uw#{x!_#{!_!`8R!`#O#{#P#T#{#T#o7^#o;'S#{;'S;=`$d<%lO#{U8YUtQmSOt#{uw#{x#O#{#P;'S#{;'S;=`$d<%lO#{U8qZmSOt#{uw#{x!_#{!_!`8R!`#O#{#P#T#{#T#U9d#U#o7^#o;'S#{;'S;=`$d<%lO#{U9i[mSOt#{uw#{x!_#{!_!`8R!`#O#{#P#T#{#T#`7^#`#a:_#a#o7^#o;'S#{;'S;=`$d<%lO#{U:d[mSOt#{uw#{x!_#{!_!`8R!`#O#{#P#T#{#T#g7^#g#h;Y#h#o7^#o;'S#{;'S;=`$d<%lO#{U;_[mSOt#{uw#{x!_#{!_!`8R!`#O#{#P#T#{#T#X7^#X#Yo[!}WmSOt#{uw#{x!_#{!_!`8R!`#O#{#P#T#{#T#f7^#f#g?e#g#o7^#o;'S#{;'S;=`$d<%lO#{U?j[mSOt#{uw#{x!_#{!_!`8R!`#O#{#P#T#{#T#i7^#i#j;Y#j#o7^#o;'S#{;'S;=`$d<%lO#{U@gU!TQmSOt#{uw#{x#O#{#P;'S#{;'S;=`$d<%lO#{~AOO#^~", + tokenizers: [operatorTokenizer, 1, 2, 3, tokenizer, new LocalTokenGroup("[~RP!O!PU~ZO!t~~", 11)], topRules: {"Program":[0,20]}, specialized: [{term: 15, get: (value: any, stack: any) => (specializeKeyword(value, stack) << 1), external: specializeKeyword},{term: 15, get: (value: keyof typeof spec_Identifier) => spec_Identifier[value] || -1}], - tokenPrec: 1135 + tokenPrec: 1463 }) diff --git a/src/parser/tests/exceptions.test.ts b/src/parser/tests/exceptions.test.ts new file mode 100644 index 0000000..039a279 --- /dev/null +++ b/src/parser/tests/exceptions.test.ts @@ -0,0 +1,278 @@ +import { expect, describe, test } from 'bun:test' + +import '../shrimp.grammar' // Importing this so changes cause it to retest! + +describe('try/catch/finally/throw', () => { + test('parses try with catch', () => { + expect(`try: + risky-operation + catch err: + handle-error err + end`).toMatchTree(` + TryExpr + keyword try + colon : + TryBlock + FunctionCallOrIdentifier + Identifier risky-operation + CatchExpr + keyword catch + Identifier err + colon : + TryBlock + FunctionCall + Identifier handle-error + PositionalArg + Identifier err + keyword end + `) + }) + + test('parses try with finally', () => { + expect(`try: + do-work + finally: + cleanup + end`).toMatchTree(` + TryExpr + keyword try + colon : + TryBlock + FunctionCallOrIdentifier + Identifier do-work + FinallyExpr + keyword finally + colon : + TryBlock + FunctionCallOrIdentifier + Identifier cleanup + keyword end + `) + }) + + test('parses try with catch and finally', () => { + expect(`try: + risky-operation + catch err: + handle-error err + finally: + cleanup + end`).toMatchTree(` + TryExpr + keyword try + colon : + TryBlock + FunctionCallOrIdentifier + Identifier risky-operation + CatchExpr + keyword catch + Identifier err + colon : + TryBlock + FunctionCall + Identifier handle-error + PositionalArg + Identifier err + FinallyExpr + keyword finally + colon : + TryBlock + FunctionCallOrIdentifier + Identifier cleanup + keyword end + `) + }) + + test('parses single-line try with catch', () => { + expect('result = try: parse-number input catch err: 0 end').toMatchTree(` + Assign + AssignableIdentifier result + Eq = + TryExpr + keyword try + colon : + FunctionCall + Identifier parse-number + PositionalArg + Identifier input + CatchExpr + keyword catch + Identifier err + colon : + Number 0 + keyword end + `) + }) + + test('parses single-line try with finally', () => { + expect('try: work catch err: 0 finally: cleanup end').toMatchTree(` + TryExpr + keyword try + colon : + FunctionCallOrIdentifier + Identifier work + CatchExpr + keyword catch + Identifier err + colon : + Number 0 + FinallyExpr + keyword finally + colon : + FunctionCallOrIdentifier + Identifier cleanup + keyword end + `) + }) + + test('parses throw statement with string', () => { + expect("throw 'error message'").toMatchTree(` + Throw + keyword throw + String + StringFragment error message + `) + }) + + test('parses throw statement with identifier', () => { + expect('throw error-object').toMatchTree(` + Throw + keyword throw + Identifier error-object + `) + }) + + test('parses throw statement with dict', () => { + expect('throw [type=validation-error message=failed]').toMatchTree(` + Throw + keyword throw + Dict + NamedArg + NamedArgPrefix type= + Identifier validation-error + NamedArg + NamedArgPrefix message= + Identifier failed + `) + }) + + test('does not parse identifiers that start with try', () => { + expect('trying = try: work catch err: 0 end').toMatchTree(` + Assign + AssignableIdentifier trying + Eq = + TryExpr + keyword try + colon : + FunctionCallOrIdentifier + Identifier work + CatchExpr + keyword catch + Identifier err + colon : + Number 0 + keyword end + `) + }) +}) + +describe('function-level exception handling', () => { + test('parses function with catch', () => { + expect(`read-file = do path: + read-data path + catch e: + empty-string + end`).toMatchTree(` + Assign + AssignableIdentifier read-file + Eq = + FunctionDef + Do do + Params + Identifier path + colon : + FunctionCall + Identifier read-data + PositionalArg + Identifier path + CatchExpr + keyword catch + Identifier e + colon : + TryBlock + FunctionCallOrIdentifier + Identifier empty-string + keyword end + `) + }) + + test('parses function with finally', () => { + expect(`cleanup-task = do x: + do-work x + finally: + close-resources + end`).toMatchTree(` + Assign + AssignableIdentifier cleanup-task + Eq = + FunctionDef + Do do + Params + Identifier x + colon : + FunctionCall + Identifier do-work + PositionalArg + Identifier x + FinallyExpr + keyword finally + colon : + TryBlock + FunctionCallOrIdentifier + Identifier close-resources + keyword end + `) + }) + + test('parses function with catch and finally', () => { + expect(`safe-operation = do x: + risky-work x + catch err: + log err + default-value + finally: + cleanup + end`).toMatchTree(` + Assign + AssignableIdentifier safe-operation + Eq = + FunctionDef + Do do + Params + Identifier x + colon : + FunctionCall + Identifier risky-work + PositionalArg + Identifier x + CatchExpr + keyword catch + Identifier err + colon : + TryBlock + FunctionCall + Identifier log + PositionalArg + Identifier err + FunctionCallOrIdentifier + Identifier default-value + FinallyExpr + keyword finally + colon : + TryBlock + FunctionCallOrIdentifier + Identifier cleanup + keyword end + `) + }) +})