From 701ca98401e04bc8310f247b0b1035de28a02418 Mon Sep 17 00:00:00 2001 From: Chris Wanstrath Date: Wed, 29 Oct 2025 14:22:57 -0700 Subject: [PATCH 01/10] try/catch/throw/finally --- src/compiler/compiler.ts | 87 +++++++++++- src/compiler/tests/exceptions.test.ts | 183 ++++++++++++++++++++++++++ src/compiler/utils.ts | 59 +++++++++ src/parser/shrimp.grammar | 32 ++++- src/parser/shrimp.terms.ts | 19 ++- src/parser/shrimp.ts | 18 +-- src/parser/tests/exceptions.test.ts | 177 +++++++++++++++++++++++++ 7 files changed, 554 insertions(+), 21 deletions(-) create mode 100644 src/compiler/tests/exceptions.test.ts create mode 100644 src/parser/tests/exceptions.test.ts diff --git a/src/compiler/compiler.ts b/src/compiler/compiler.ts index 72b0f23..fb3dd9e 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 @@ -317,10 +319,87 @@ 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) + + 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]) + + const tryInstructions = this.#compileNode(tryBlock, input) + instructions.push(...tryInstructions) + + instructions.push(['POP_TRY']) + if (finallyBody) { + instructions.push(['JUMP', finallyLabel]) + } else { + instructions.push(['JUMP', endLabel]) + } + + instructions.push([`${catchLabel}:`]) + if (catchBody && catchVariable) { + // catch block + 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 + } + + case terms.ThrowStatement: { + const children = getAllChildren(node) + const [_throwKeyword, expression] = children + if (!expression) { + throw new CompilerError( + `ThrowStatement expected expression, got ${children.length} children`, + node.from, + node.to + ) + } + + const instructions: ProgramItem[] = [] + instructions.push(...this.#compileNode(expression, input)) + instructions.push(['THROW']) return instructions } diff --git a/src/compiler/tests/exceptions.test.ts b/src/compiler/tests/exceptions.test.ts new file mode 100644 index 0000000..19e3503 --- /dev/null +++ b/src/compiler/tests/exceptions.test.ts @@ -0,0 +1,183 @@ +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) + }) +}) diff --git a/src/compiler/utils.ts b/src/compiler/utils.ts index 82b4025..96de20f 100644 --- a/src/compiler/utils.ts +++ b/src/compiler/utils.ts @@ -231,3 +231,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..297948c 100644 --- a/src/parser/shrimp.grammar +++ b/src/parser/shrimp.grammar @@ -51,6 +51,8 @@ item { consumeToTerminator { PipeExpr | ambiguousFunctionCall | + TryExpr | + ThrowStatement | IfExpr | FunctionDef | Assign | @@ -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 +} + +ThrowStatement { + @specialize[@name=keyword] expression } ConditionalOp { diff --git a/src/parser/shrimp.terms.ts b/src/parser/shrimp.terms.ts index 144d69b..98bc49c 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, + keyword = 63, Underscore = 41, Array = 42, Null = 43, ConditionalOp = 44, PositionalArg = 45, - IfExpr = 47, - SingleLineThenBlock = 49, - ThenBlock = 50, - ElseIfExpr = 51, - ElseExpr = 53, - Assign = 55 + TryExpr = 47, + CatchExpr = 49, + TryBlock = 51, + FinallyExpr = 52, + ThrowStatement = 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..14d2ece 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,end:80, null:86, try:96, catch:100, finally: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`<QQbOAN?aO>]QQO'#DlOOQ`AN?aAN?aO>bQbOAN?aO>gQbO7+%POOQ`7+%P7+%POOQ`7+%S7+%SOOQ`G24yG24yO?QQRO,5:UO?XQRO,5:UOOQ`-E7v-E7vOOQ`G24{G24{O?dQbOG24{O?iQQO,5:WOOQ`<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#{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[!}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!OQmSOt#{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: 1415 }) diff --git a/src/parser/tests/exceptions.test.ts b/src/parser/tests/exceptions.test.ts new file mode 100644 index 0000000..aafdcf9 --- /dev/null +++ b/src/parser/tests/exceptions.test.ts @@ -0,0 +1,177 @@ +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(` + ThrowStatement + keyword throw + String + StringFragment error message + `) + }) + + test('parses throw statement with identifier', () => { + expect('throw error-object').toMatchTree(` + ThrowStatement + keyword throw + Identifier error-object + `) + }) + + test('parses throw statement with dict', () => { + expect('throw [type=validation-error message=failed]').toMatchTree(` + ThrowStatement + 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 + `) + }) +}) From 9bc514a78211514fe862e2ddac94949c514a6523 Mon Sep 17 00:00:00 2001 From: Chris Wanstrath Date: Wed, 29 Oct 2025 14:38:23 -0700 Subject: [PATCH 02/10] implicit function-level try blocks --- src/compiler/compiler.ts | 134 ++++++++++++++++---------- src/compiler/tests/exceptions.test.ts | 128 ++++++++++++++++++++++++ src/compiler/utils.ts | 50 +++++++++- src/parser/shrimp.grammar | 8 +- src/parser/shrimp.terms.ts | 20 ++-- src/parser/shrimp.ts | 14 +-- src/parser/tests/exceptions.test.ts | 107 +++++++++++++++++++- 7 files changed, 380 insertions(+), 81 deletions(-) diff --git a/src/compiler/compiler.ts b/src/compiler/compiler.ts index fb3dd9e..4e35747 100644 --- a/src/compiler/compiler.ts +++ b/src/compiler/compiler.ts @@ -255,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}` @@ -263,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}:`]) @@ -338,60 +359,21 @@ export class Compiler { case terms.TryExpr: { const { tryBlock, catchVariable, catchBody, finallyBody } = getTryExprParts(node, input) - 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]) - - const tryInstructions = this.#compileNode(tryBlock, input) - instructions.push(...tryInstructions) - - instructions.push(['POP_TRY']) - if (finallyBody) { - instructions.push(['JUMP', finallyLabel]) - } else { - instructions.push(['JUMP', endLabel]) - } - - instructions.push([`${catchLabel}:`]) - if (catchBody && catchVariable) { - // catch block - 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 + return this.#compileTryCatchFinally( + () => this.#compileNode(tryBlock, input), + catchVariable, + catchBody, + finallyBody, + input + ) } - case terms.ThrowStatement: { + case terms.Throw: { const children = getAllChildren(node) const [_throwKeyword, expression] = children if (!expression) { throw new CompilerError( - `ThrowStatement expected expression, got ${children.length} children`, + `Throw expected expression, got ${children.length} children`, node.from, node.to ) @@ -606,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 index 19e3503..9a71012 100644 --- a/src/compiler/tests/exceptions.test.ts +++ b/src/compiler/tests/exceptions.test.ts @@ -181,3 +181,131 @@ describe('exception handling', () => { `).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 96de20f..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) => { diff --git a/src/parser/shrimp.grammar b/src/parser/shrimp.grammar index 297948c..455a9b2 100644 --- a/src/parser/shrimp.grammar +++ b/src/parser/shrimp.grammar @@ -52,7 +52,7 @@ consumeToTerminator { PipeExpr | ambiguousFunctionCall | TryExpr | - ThrowStatement | + Throw | IfExpr | FunctionDef | Assign | @@ -99,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 { @@ -158,7 +158,7 @@ TryBlock { block } -ThrowStatement { +Throw { @specialize[@name=keyword] expression } diff --git a/src/parser/shrimp.terms.ts b/src/parser/shrimp.terms.ts index 98bc49c..8a80f73 100644 --- a/src/parser/shrimp.terms.ts +++ b/src/parser/shrimp.terms.ts @@ -39,17 +39,17 @@ export const FunctionDef = 37, Params = 38, colon = 39, + CatchExpr = 40, keyword = 63, - Underscore = 41, - Array = 42, - Null = 43, - ConditionalOp = 44, - PositionalArg = 45, - TryExpr = 47, - CatchExpr = 49, - TryBlock = 51, - FinallyExpr = 52, - ThrowStatement = 54, + TryBlock = 42, + FinallyExpr = 43, + Underscore = 46, + Array = 47, + Null = 48, + ConditionalOp = 49, + PositionalArg = 50, + TryExpr = 52, + Throw = 54, IfExpr = 56, SingleLineThenBlock = 58, ThenBlock = 59, diff --git a/src/parser/shrimp.ts b/src/parser/shrimp.ts index 14d2ece..f1f7a33 100644 --- a/src/parser/shrimp.ts +++ b/src/parser/shrimp.ts @@ -4,13 +4,13 @@ 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, try:96, catch:100, finally:106, throw:110, if:114, elseif:122, else:126} +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: "7^QYQbOOO#tQcO'#CvO$qOSO'#CxO%PQbO'#E`OOQ`'#DR'#DROOQa'#DO'#DOO&SQbO'#DWO'XQcO'#ETOOQa'#ET'#ETO)cQcO'#ESO*bQRO'#CwO+WQcO'#EOO+hQcO'#EOO+rQbO'#CuO,jOpO'#CsOOQ`'#EP'#EPO,oQbO'#EOO,vQQO'#EfOOQ`'#D]'#D]O,{QbO'#DdO,{QbO'#EhOOQ`'#Df'#DfO-pQRO'#DnOOQ`'#EO'#EOO-uQQO'#D}OOQ`'#D}'#D}OOQ`'#Do'#DoQYQbOOO-}QbO'#DPOOQa'#ES'#ESOOQ`'#DZ'#DZOOQ`'#Ee'#EeOOQ`'#Dv'#DvO.XQbO,59^O.rQbO'#CzO.zQWO'#C{OOOO'#EV'#EVOOOO'#Dp'#DpO/`OSO,59dOOQa,59d,59dOOQ`'#Dr'#DrO/nQbO'#DSO/vQQO,5:zOOQ`'#Dq'#DqO/{QbO,59rO0SQQO,59jOOQa,59r,59rO0_QbO,59rO,{QbO,59cO,{QbO,59cO,{QbO,59cO,{QbO,59tO,{QbO,59tO,{QbO,59tO0iQRO,59aO0pQRO,59aO1RQRO,59aO0|QQO,59aO1^QQO,59aO1fObO,59_O1qQbO'#DwO1|QbO,59]O2eQbO,5;QOOQ`,5:O,5:OO2xQRO,5;SO3PQRO,5;SO3[QbO,5:YOOQ`,5:i,5:iOOQ`-E7m-E7mOOQ`,59k,59kOOQ`-E7t-E7tOOOO,59f,59fOOOO,59g,59gOOOO-E7n-E7nOOQa1G/O1G/OOOQ`-E7p-E7pO3lQbO1G0fOOQ`-E7o-E7oO4PQQO1G/UOOQa1G/^1G/^O4[QbO1G/^OOQO'#Dt'#DtO4PQQO1G/UOOQa1G/U1G/UOOQ`'#Du'#DuO4[QbO1G/^OOQa1G.}1G.}O5TQcO1G.}O5_QcO1G.}O5iQcO1G.}OOQa1G/`1G/`O7XQcO1G/`O7`QcO1G/`O7gQcO1G/`OOQa1G.{1G.{OOQa1G.y1G.yO!aQbO'#CvO&ZQbO'#CrOOQ`,5:c,5:cOOQ`-E7u-E7uO7nQbO1G0lO7yQbO1G0mO8gQbO1G0nOOQ`1G/t1G/tO8zQbO7+&QO9PQbO7+&RO9gQQO7+$pOOQa7+$p7+$pO9rQbO7+$xOOQa7+$x7+$xOOQO-E7r-E7rOOQ`-E7s-E7sO9|QbO'#D_O:RQQO'#DbOOQ`7+&W7+&WO:WQbO7+&WO:]QbO7+&WOOQ`'#Ds'#DsO:eQQO'#DsO:jQbO'#EbOOQ`'#Da'#DaO;^QbO7+&XOOQ`'#Dh'#DhO;iQbO7+&YO;nQbO7+&ZOOQ`<QQbOAN?aO>]QQO'#DlOOQ`AN?aAN?aO>bQbOAN?aO>gQbO7+%POOQ`7+%P7+%POOQ`7+%S7+%SOOQ`G24yG24yO?QQRO,5:UO?XQRO,5:UOOQ`-E7v-E7vOOQ`G24{G24{O?dQbOG24{O?iQQO,5:WOOQ`<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!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#{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[!}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!OQmSOt#{uw#{x#O#{#P;'S#{;'S;=`$d<%lO#{~AOO#^~", + 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: 1415 + tokenPrec: 1463 }) diff --git a/src/parser/tests/exceptions.test.ts b/src/parser/tests/exceptions.test.ts index aafdcf9..039a279 100644 --- a/src/parser/tests/exceptions.test.ts +++ b/src/parser/tests/exceptions.test.ts @@ -127,7 +127,7 @@ describe('try/catch/finally/throw', () => { test('parses throw statement with string', () => { expect("throw 'error message'").toMatchTree(` - ThrowStatement + Throw keyword throw String StringFragment error message @@ -136,7 +136,7 @@ describe('try/catch/finally/throw', () => { test('parses throw statement with identifier', () => { expect('throw error-object').toMatchTree(` - ThrowStatement + Throw keyword throw Identifier error-object `) @@ -144,7 +144,7 @@ describe('try/catch/finally/throw', () => { test('parses throw statement with dict', () => { expect('throw [type=validation-error message=failed]').toMatchTree(` - ThrowStatement + Throw keyword throw Dict NamedArg @@ -175,3 +175,104 @@ describe('try/catch/finally/throw', () => { `) }) }) + +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 + `) + }) +}) From d957675ac81d24aa918847f317b94a89aaede11d Mon Sep 17 00:00:00 2001 From: Chris Wanstrath Date: Wed, 29 Oct 2025 15:15:35 -0700 Subject: [PATCH 03/10] there are always StringFragments --- src/compiler/utils.ts | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/src/compiler/utils.ts b/src/compiler/utils.ts index 7fd9cf5..c5fb786 100644 --- a/src/compiler/utils.ts +++ b/src/compiler/utils.ts @@ -236,7 +236,12 @@ export const getStringParts = (node: SyntaxNode, input: string) => { } }) - return { parts, hasInterpolation: parts.length > 0 } + // hasInterpolation means the string has interpolation ($var) or escape sequences (\n) + // A simple string like 'hello' has one StringFragment but no interpolation + const hasInterpolation = parts.some( + (p) => p.type.id === terms.Interpolation || p.type.id === terms.EscapeSeq + ) + return { parts, hasInterpolation } } export const getDotGetParts = (node: SyntaxNode, input: string) => { From 4f961d3039f20cb79bc568d9c2ea08367326f614 Mon Sep 17 00:00:00 2001 From: Chris Wanstrath Date: Wed, 29 Oct 2025 15:29:07 -0700 Subject: [PATCH 04/10] test native exceptions --- bun.lock | 2 +- src/compiler/tests/native-exceptions.test.ts | 292 +++++++++++++++++++ src/parser/shrimp.grammar | 2 +- src/parser/shrimp.ts | 8 +- 4 files changed, 298 insertions(+), 6 deletions(-) create mode 100644 src/compiler/tests/native-exceptions.test.ts diff --git a/bun.lock b/bun.lock index 005ca60..e8b8c92 100644 --- a/bun.lock +++ b/bun.lock @@ -62,7 +62,7 @@ "hono": ["hono@4.9.8", "", {}, "sha512-JW8Bb4RFWD9iOKxg5PbUarBYGM99IcxFl2FPBo2gSJO11jjUDqlP1Bmfyqt8Z/dGhIQ63PMA9LdcLefXyIasyg=="], - "reefvm": ["reefvm@git+https://git.nose.space/defunkt/reefvm#030eb7487165b3ba502965a8b7fa09c4b5fdb0da", { "peerDependencies": { "typescript": "^5" } }, "030eb7487165b3ba502965a8b7fa09c4b5fdb0da"], + "reefvm": ["reefvm@git+https://git.nose.space/defunkt/reefvm#9618dd64148ccf6f0cdfd8a80a0f58efe3e0819d", { "peerDependencies": { "typescript": "^5" } }, "9618dd64148ccf6f0cdfd8a80a0f58efe3e0819d"], "style-mod": ["style-mod@4.1.2", "", {}, "sha512-wnD1HyVqpJUI2+eKZ+eo1UwghftP6yuFheBqqe+bWCotBjC2K1YnteJILRMs3SM4V/0dLEW1SC27MWP5y+mwmw=="], diff --git a/src/compiler/tests/native-exceptions.test.ts b/src/compiler/tests/native-exceptions.test.ts new file mode 100644 index 0000000..a301fae --- /dev/null +++ b/src/compiler/tests/native-exceptions.test.ts @@ -0,0 +1,292 @@ +import { describe, test, expect } from 'bun:test' +import { Compiler } from '#compiler/compiler' +import { VM } from 'reefvm' + +describe('Native Function Exceptions', () => { + test('native function error caught by try/catch', async () => { + const code = ` + result = try: + failing-fn + catch e: + 'caught: ' + e + end + + result + ` + + const compiler = new Compiler(code) + const vm = new VM(compiler.bytecode) + + vm.set('failing-fn', () => { + throw new Error('native function failed') + }) + + const result = await vm.run() + expect(result).toEqual({ type: 'string', value: 'caught: native function failed' }) + }) + + test('async native function error caught by try/catch', async () => { + const code = ` + result = try: + async-fail + catch e: + 'async caught: ' + e + end + + result + ` + + const compiler = new Compiler(code) + const vm = new VM(compiler.bytecode) + + vm.set('async-fail', async () => { + await new Promise(resolve => setTimeout(resolve, 1)) + throw new Error('async error') + }) + + const result = await vm.run() + expect(result).toEqual({ type: 'string', value: 'async caught: async error' }) + }) + + test('native function with arguments throwing error', async () => { + const code = ` + result = try: + read-file missing.txt + catch e: + 'default content' + end + + result + ` + + const compiler = new Compiler(code) + const vm = new VM(compiler.bytecode) + + vm.set('read-file', (path: string) => { + if (path === 'missing.txt') { + throw new Error('file not found') + } + return 'file contents' + }) + + const result = await vm.run() + expect(result).toEqual({ type: 'string', value: 'default content' }) + }) + + test('native function error with finally block', async () => { + const code = ` + cleanup-count = 0 + + result = try: + failing-fn + catch e: + 'error handled' + finally: + cleanup-count = cleanup-count + 1 + end + + cleanup-count + ` + + const compiler = new Compiler(code) + const vm = new VM(compiler.bytecode) + + vm.set('failing-fn', () => { + throw new Error('native error') + }) + + const result = await vm.run() + expect(result).toEqual({ type: 'number', value: 1 }) + }) + + test('native function error without catch propagates', async () => { + const code = ` + failing-fn + ` + + const compiler = new Compiler(code) + const vm = new VM(compiler.bytecode) + + vm.set('failing-fn', () => { + throw new Error('uncaught error') + }) + + await expect(vm.run()).rejects.toThrow('Uncaught exception in native function: uncaught error') + }) + + test('native function in function-level catch', async () => { + const code = ` + safe-read = do path: + read-file path + catch e: + 'default: ' + e + end + + result = safe-read missing.txt + result + ` + + const compiler = new Compiler(code) + const vm = new VM(compiler.bytecode) + + vm.set('read-file', (path: string) => { + throw new Error('file not found: ' + path) + }) + + const result = await vm.run() + expect(result).toEqual({ type: 'string', value: 'default: file not found: missing.txt' }) + }) + + test('nested native function errors', async () => { + const code = ` + result = try: + try: + inner-fail + catch e: + throw 'wrapped: ' + e + end + catch e: + 'outer caught: ' + e + end + + result + ` + + const compiler = new Compiler(code) + const vm = new VM(compiler.bytecode) + + vm.set('inner-fail', () => { + throw new Error('inner error') + }) + + const result = await vm.run() + expect(result).toEqual({ type: 'string', value: 'outer caught: wrapped: inner error' }) + }) + + test('native function error with multiple named args', async () => { + const code = ` + result = try: + process-file path=missing.txt mode=strict + catch e: + 'error: ' + e + end + + result + ` + + const compiler = new Compiler(code) + const vm = new VM(compiler.bytecode) + + vm.set('process-file', (path: string, mode: string = 'lenient') => { + if (mode === 'strict' && path === 'missing.txt') { + throw new Error('strict mode: file required') + } + return 'processed' + }) + + const result = await vm.run() + expect(result).toEqual({ type: 'string', value: 'error: strict mode: file required' }) + }) + + test('native function returning normally after other functions threw', async () => { + const code = ` + result1 = try: + failing-fn + catch e: + 'caught' + end + + result2 = success-fn + + result1 + ' then ' + result2 + ` + + const compiler = new Compiler(code) + const vm = new VM(compiler.bytecode) + + vm.set('failing-fn', () => { + throw new Error('error') + }) + + vm.set('success-fn', () => { + return 'success' + }) + + const result = await vm.run() + expect(result).toEqual({ type: 'string', value: 'caught then success' }) + }) + + test('native function error message preserved', async () => { + const code = ` + result = try: + throw-custom-message + catch e: + e + end + + result + ` + + const compiler = new Compiler(code) + const vm = new VM(compiler.bytecode) + + vm.set('throw-custom-message', () => { + throw new Error('This is a very specific error message with details') + }) + + const result = await vm.run() + expect(result).toEqual({ + type: 'string', + value: 'This is a very specific error message with details' + }) + }) + + test('native function throwing non-Error value', async () => { + const code = ` + result = try: + throw-string + catch e: + 'caught: ' + e + end + + result + ` + + const compiler = new Compiler(code) + const vm = new VM(compiler.bytecode) + + vm.set('throw-string', () => { + throw 'plain string error' + }) + + const result = await vm.run() + expect(result).toEqual({ type: 'string', value: 'caught: plain string error' }) + }) + + test('multiple native function calls with mixed success/failure', async () => { + const code = ` + r1 = try: success-fn catch e: 'error' end + r2 = try: failing-fn catch e: 'caught' end + r3 = try: success-fn catch e: 'error' end + + results = [r1 r2 r3] + results + ` + + const compiler = new Compiler(code) + const vm = new VM(compiler.bytecode) + + vm.set('success-fn', () => 'ok') + vm.set('failing-fn', () => { + throw new Error('failed') + }) + + const result = await vm.run() + expect(result.type).toBe('array') + const arr = result.value as any[] + expect(arr.length).toBe(3) + expect(arr[0]).toEqual({ type: 'string', value: 'ok' }) + expect(arr[1]).toEqual({ type: 'string', value: 'caught' }) + expect(arr[2]).toEqual({ type: 'string', value: 'ok' }) + }) +}) diff --git a/src/parser/shrimp.grammar b/src/parser/shrimp.grammar index 455a9b2..80cdef5 100644 --- a/src/parser/shrimp.grammar +++ b/src/parser/shrimp.grammar @@ -159,7 +159,7 @@ TryBlock { } Throw { - @specialize[@name=keyword] expression + @specialize[@name=keyword] (BinOp | ConditionalOp | expression) } ConditionalOp { diff --git a/src/parser/shrimp.ts b/src/parser/shrimp.ts index f1f7a33..ec84f41 100644 --- a/src/parser/shrimp.ts +++ b/src/parser/shrimp.ts @@ -7,9 +7,9 @@ import {highlighting} from "./highlight" 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: "8lQYQbOOO#tQcO'#CvO$qOSO'#CxO%PQbO'#E`OOQ`'#DR'#DROOQa'#DO'#DOO&SQbO'#D]O'XQcO'#ETOOQa'#ET'#ETO)cQcO'#ESO*bQRO'#CwO+WQcO'#EOO+hQcO'#EOO+rQbO'#CuO,jOpO'#CsOOQ`'#EP'#EPO,oQbO'#EOO,vQQO'#EfOOQ`'#Db'#DbO,{QbO'#DdO,{QbO'#EhOOQ`'#Df'#DfO-pQRO'#DnOOQ`'#EO'#EOO-uQQO'#D}OOQ`'#D}'#D}OOQ`'#Do'#DoQYQbOOO-}QbO'#DPOOQa'#ES'#ESOOQ`'#D`'#D`OOQ`'#Ee'#EeOOQ`'#Dv'#DvO.XQbO,59^O.rQbO'#CzO.zQWO'#C{OOOO'#EV'#EVOOOO'#Dp'#DpO/`OSO,59dOOQa,59d,59dOOQ`'#Dr'#DrO/nQbO'#DSO/vQQO,5:zOOQ`'#Dq'#DqO/{QbO,59wO0SQQO,59jOOQa,59w,59wO0_QbO,59wO,{QbO,59cO,{QbO,59cO,{QbO,59cO,{QbO,59yO,{QbO,59yO,{QbO,59yO0iQRO,59aO0pQRO,59aO1RQRO,59aO0|QQO,59aO1^QQO,59aO1fObO,59_O1qQbO'#DwO1|QbO,59]O2eQbO,5;QOOQ`,5:O,5:OO2xQRO,5;SO3PQRO,5;SO3[QbO,5:YOOQ`,5:i,5:iOOQ`-E7m-E7mOOQ`,59k,59kOOQ`-E7t-E7tOOOO,59f,59fOOOO,59g,59gOOOO-E7n-E7nOOQa1G/O1G/OOOQ`-E7p-E7pO3lQbO1G0fOOQ`-E7o-E7oO4PQQO1G/UOOQa1G/c1G/cO4[QbO1G/cOOQO'#Dt'#DtO4PQQO1G/UOOQa1G/U1G/UOOQ`'#Du'#DuO4[QbO1G/cOOQa1G.}1G.}O5TQcO1G.}O5_QcO1G.}O5iQcO1G.}OOQa1G/e1G/eO7XQcO1G/eO7`QcO1G/eO7gQcO1G/eOOQa1G.{1G.{OOQa1G.y1G.yO!aQbO'#CvO&ZQbO'#CrOOQ`,5:c,5:cOOQ`-E7u-E7uO7nQbO1G0lO7yQbO1G0mO8gQbO1G0nOOQ`1G/t1G/tO8zQbO7+&QO7yQbO7+&SO9VQQO7+$pOOQa7+$p7+$pO9bQbO7+$}OOQa7+$}7+$}OOQO-E7r-E7rOOQ`-E7s-E7sO9lQbO'#DUO9qQQO'#DXOOQ`7+&W7+&WO9vQbO7+&WO9{QbO7+&WOOQ`'#Ds'#DsO:TQQO'#DsO:YQbO'#EaOOQ`'#DW'#DWO:|QbO7+&XOOQ`'#Dh'#DhO;XQbO7+&YO;^QbO7+&ZOOQ`<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`<PQbO<[QQO,59pO>aQbO,59sOOQ`<tQbO<yQbO< (specializeKeyword(value, stack) << 1), external: specializeKeyword},{term: 15, get: (value: keyof typeof spec_Identifier) => spec_Identifier[value] || -1}], - tokenPrec: 1463 + tokenPrec: 1547 }) From f8d2236292deeb14bddbe33e8d96024b276eac64 Mon Sep 17 00:00:00 2001 From: Chris Wanstrath Date: Wed, 29 Oct 2025 15:36:18 -0700 Subject: [PATCH 05/10] add exceptions to prelude functions --- src/prelude/index.ts | 21 +++++++++++++++++++-- src/prelude/list.ts | 10 ++++++++-- src/prelude/math.ts | 20 ++++++++++++++++---- src/prelude/str.ts | 6 +++++- src/prelude/tests/prelude.test.ts | 9 ++++++--- 5 files changed, 54 insertions(+), 12 deletions(-) diff --git a/src/prelude/index.ts b/src/prelude/index.ts index facf4b8..cc46ead 100644 --- a/src/prelude/index.ts +++ b/src/prelude/index.ts @@ -39,7 +39,7 @@ export const globals = { switch (value.type) { case 'string': case 'array': return value.value.length case 'dict': return value.value.size - default: return 0 + default: throw new Error(`length: expected string, array, or dict, got ${value.type}`) } }, @@ -65,7 +65,24 @@ export const globals = { identity: (v: any) => v, // collections - at: (collection: any, index: number | string) => collection[index], + at: (collection: any, index: number | string) => { + const value = toValue(collection) + if (value.type === 'string' || value.type === 'array') { + const idx = typeof index === 'number' ? index : parseInt(index as string) + if (idx < 0 || idx >= value.value.length) { + throw new Error(`at: index ${idx} out of bounds for ${value.type} of length ${value.value.length}`) + } + return value.value[idx] + } else if (value.type === 'dict') { + const key = String(index) + if (!value.value.has(key)) { + throw new Error(`at: key '${key}' not found in dict`) + } + return value.value.get(key) + } else { + throw new Error(`at: expected string, array, or dict, got ${value.type}`) + } + }, range: (start: number, end: number | null) => { if (end === null) { end = start diff --git a/src/prelude/list.ts b/src/prelude/list.ts index eb013ef..8c0452d 100644 --- a/src/prelude/list.ts +++ b/src/prelude/list.ts @@ -52,8 +52,14 @@ export const list = { first: (list: any[]) => list[0] ?? null, last: (list: any[]) => list[list.length - 1] ?? null, rest: (list: any[]) => list.slice(1), - take: (list: any[], n: number) => list.slice(0, n), - drop: (list: any[], n: number) => list.slice(n), + take: (list: any[], n: number) => { + if (n < 0) throw new Error(`take: count must be non-negative, got ${n}`) + return list.slice(0, n) + }, + drop: (list: any[], n: number) => { + if (n < 0) throw new Error(`drop: count must be non-negative, got ${n}`) + return list.slice(n) + }, append: (list: any[], item: any) => [...list, item], prepend: (list: any[], item: any) => [item, ...list], 'index-of': (list: any[], item: any) => list.indexOf(item), diff --git a/src/prelude/math.ts b/src/prelude/math.ts index 21f2f57..148cde9 100644 --- a/src/prelude/math.ts +++ b/src/prelude/math.ts @@ -3,12 +3,24 @@ export const math = { floor: (n: number) => Math.floor(n), ceil: (n: number) => Math.ceil(n), round: (n: number) => Math.round(n), - min: (...nums: number[]) => Math.min(...nums), - max: (...nums: number[]) => Math.max(...nums), + min: (...nums: number[]) => { + if (nums.length === 0) throw new Error('min: expected at least one argument') + return Math.min(...nums) + }, + max: (...nums: number[]) => { + if (nums.length === 0) throw new Error('max: expected at least one argument') + return Math.max(...nums) + }, pow: (base: number, exp: number) => Math.pow(base, exp), - sqrt: (n: number) => Math.sqrt(n), + sqrt: (n: number) => { + if (n < 0) throw new Error(`sqrt: cannot take square root of negative number ${n}`) + return Math.sqrt(n) + }, random: () => Math.random(), - clamp: (n: number, min: number, max: number) => Math.min(Math.max(n, min), max), + clamp: (n: number, min: number, max: number) => { + if (min > max) throw new Error(`clamp: min (${min}) must be less than or equal to max (${max})`) + return Math.min(Math.max(n, min), max) + }, sign: (n: number) => Math.sign(n), trunc: (n: number) => Math.trunc(n), diff --git a/src/prelude/str.ts b/src/prelude/str.ts index fa0d657..5aede56 100644 --- a/src/prelude/str.ts +++ b/src/prelude/str.ts @@ -21,7 +21,11 @@ export const str = { 'replace-all': (str: string, search: string, replacement: string) => str.replaceAll(search, replacement), slice: (str: string, start: number, end?: number | null) => str.slice(start, end ?? undefined), substring: (str: string, start: number, end?: number | null) => str.substring(start, end ?? undefined), - repeat: (str: string, count: number) => str.repeat(count), + repeat: (str: string, count: number) => { + if (count < 0) throw new Error(`repeat: count must be non-negative, got ${count}`) + if (!Number.isInteger(count)) throw new Error(`repeat: count must be an integer, got ${count}`) + return str.repeat(count) + }, 'pad-start': (str: string, length: number, pad: string = ' ') => str.padStart(length, pad), 'pad-end': (str: string, length: number, pad: string = ' ') => str.padEnd(length, pad), lines: (str: string) => str.split('\n'), diff --git a/src/prelude/tests/prelude.test.ts b/src/prelude/tests/prelude.test.ts index ef7d8d6..a073d86 100644 --- a/src/prelude/tests/prelude.test.ts +++ b/src/prelude/tests/prelude.test.ts @@ -176,9 +176,12 @@ describe('introspection', () => { await expect(`length 'hello'`).toEvaluateTo(5, globals) await expect(`length [1 2 3]`).toEvaluateTo(3, globals) await expect(`length [a=1 b=2]`).toEvaluateTo(2, globals) - await expect(`length 42`).toEvaluateTo(0, globals) - await expect(`length true`).toEvaluateTo(0, globals) - await expect(`length null`).toEvaluateTo(0, globals) + }) + + test('length throws on invalid types', async () => { + await expect(`try: length 42 catch e: 'error' end`).toEvaluateTo('error', globals) + await expect(`try: length true catch e: 'error' end`).toEvaluateTo('error', globals) + await expect(`try: length null catch e: 'error' end`).toEvaluateTo('error', globals) }) test('inspect formats values', async () => { From e60e3184faa46f384e5f0e4647e390330a843775 Mon Sep 17 00:00:00 2001 From: Chris Wanstrath Date: Wed, 29 Oct 2025 15:37:39 -0700 Subject: [PATCH 06/10] less chatty --- src/compiler/tests/native-exceptions.test.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/compiler/tests/native-exceptions.test.ts b/src/compiler/tests/native-exceptions.test.ts index a301fae..f7e2e37 100644 --- a/src/compiler/tests/native-exceptions.test.ts +++ b/src/compiler/tests/native-exceptions.test.ts @@ -111,7 +111,7 @@ describe('Native Function Exceptions', () => { throw new Error('uncaught error') }) - await expect(vm.run()).rejects.toThrow('Uncaught exception in native function: uncaught error') + await expect(vm.run()).rejects.toThrow('uncaught error') }) test('native function in function-level catch', async () => { From bc0684185a1179c7d48dcc360fbc486a99358266 Mon Sep 17 00:00:00 2001 From: Chris Wanstrath Date: Wed, 29 Oct 2025 15:49:59 -0700 Subject: [PATCH 07/10] Add += and friends --- src/compiler/compiler.ts | 29 +++++++++++ src/compiler/tests/compiler.test.ts | 32 ++++++++++++ src/compiler/utils.ts | 21 ++++++++ src/parser/operatorTokenizer.ts | 9 +++- src/parser/shrimp.grammar | 7 ++- src/parser/shrimp.terms.ts | 80 ++++++++++++++++------------- src/parser/shrimp.ts | 24 ++++----- src/parser/tests/basics.test.ts | 66 ++++++++++++++++++++++++ src/parser/tokenizer.ts | 11 ++++ 9 files changed, 228 insertions(+), 51 deletions(-) diff --git a/src/compiler/compiler.ts b/src/compiler/compiler.ts index 72b0f23..02eb635 100644 --- a/src/compiler/compiler.ts +++ b/src/compiler/compiler.ts @@ -9,6 +9,7 @@ import { checkTreeForErrors, getAllChildren, getAssignmentParts, + getCompoundAssignmentParts, getBinaryParts, getDotGetParts, getFunctionCallParts, @@ -245,6 +246,34 @@ export class Compiler { return instructions } + case terms.CompoundAssign: { + const { identifier, operator, right } = getCompoundAssignmentParts(node) + const identifierName = input.slice(identifier.from, identifier.to) + const instructions: ProgramItem[] = [] + + // will throw if undefined + instructions.push(['LOAD', identifierName]) + + instructions.push(...this.#compileNode(right, input)) + + const opValue = input.slice(operator.from, operator.to) + switch (opValue) { + case '+=': instructions.push(['ADD']); break + case '-=': instructions.push(['SUB']); break + case '*=': instructions.push(['MUL']); break + case '/=': instructions.push(['DIV']); break + case '%=': instructions.push(['MOD']); break + default: + throw new CompilerError(`Unknown compound operator: ${opValue}`, operator.from, operator.to) + } + + // DUP and store (same as regular assignment) + instructions.push(['DUP']) + instructions.push(['STORE', identifierName]) + + return instructions + } + case terms.ParenExpr: { const child = node.firstChild if (!child) return [] // I guess it is empty parentheses? diff --git a/src/compiler/tests/compiler.test.ts b/src/compiler/tests/compiler.test.ts index 4d662c3..7a67a9e 100644 --- a/src/compiler/tests/compiler.test.ts +++ b/src/compiler/tests/compiler.test.ts @@ -64,6 +64,38 @@ describe('compiler', () => { expect('sum = 2 + 3; sum').toEvaluateTo(5) }) + test('compound assignment +=', () => { + expect('x = 10; x += 5; x').toEvaluateTo(15) + }) + + test('compound assignment -= ', () => { + expect('x = 10; x -= 3; x').toEvaluateTo(7) + }) + + test('compound assignment *=', () => { + expect('x = 5; x *= 3; x').toEvaluateTo(15) + }) + + test('compound assignment /=', () => { + expect('x = 20; x /= 4; x').toEvaluateTo(5) + }) + + test('compound assignment %=', () => { + expect('x = 17; x %= 5; x').toEvaluateTo(2) + }) + + test('compound assignment with expression', () => { + expect('x = 10; x += 2 + 3; x').toEvaluateTo(15) + }) + + test('compound assignment returns value', () => { + expect('x = 5; x += 10; x').toEvaluateTo(15) + }) + + test('compound assignment fails on undefined variable', () => { + expect('undefined-var += 5').toFailEvaluation() + }) + test('parentheses', () => { expect('(2 + 3) * 4').toEvaluateTo(20) }) diff --git a/src/compiler/utils.ts b/src/compiler/utils.ts index 82b4025..60ab836 100644 --- a/src/compiler/utils.ts +++ b/src/compiler/utils.ts @@ -57,6 +57,27 @@ export const getAssignmentParts = (node: SyntaxNode) => { return { identifier: left, right } } +export const getCompoundAssignmentParts = (node: SyntaxNode) => { + const children = getAllChildren(node) + const [left, operator, right] = children + + if (!left || left.type.id !== terms.AssignableIdentifier) { + throw new CompilerError( + `CompoundAssign left child must be an AssignableIdentifier, got ${left ? left.type.name : 'none'}`, + node.from, + node.to + ) + } else if (!operator || !right) { + throw new CompilerError( + `CompoundAssign expected 3 children, got ${children.length}`, + node.from, + node.to + ) + } + + return { identifier: left, operator, right } +} + export const getFunctionDefParts = (node: SyntaxNode, input: string) => { const children = getAllChildren(node) const [fnKeyword, paramsNode, colon, ...bodyNodes] = children diff --git a/src/parser/operatorTokenizer.ts b/src/parser/operatorTokenizer.ts index ee1dc44..3c85400 100644 --- a/src/parser/operatorTokenizer.ts +++ b/src/parser/operatorTokenizer.ts @@ -10,7 +10,14 @@ const operators: Array = [ { str: '!=', tokenName: 'Neq' }, { str: '==', tokenName: 'EqEq' }, - // // Single-char operators + // Compound assignment operators (must come before single-char operators) + { str: '+=', tokenName: 'PlusEq' }, + { str: '-=', tokenName: 'MinusEq' }, + { str: '*=', tokenName: 'StarEq' }, + { str: '/=', tokenName: 'SlashEq' }, + { str: '%=', tokenName: 'ModuloEq' }, + + // Single-char operators { str: '*', tokenName: 'Star' }, { str: '=', tokenName: 'Eq' }, { str: '/', tokenName: 'Slash' }, diff --git a/src/parser/shrimp.grammar b/src/parser/shrimp.grammar index 635035a..641bf02 100644 --- a/src/parser/shrimp.grammar +++ b/src/parser/shrimp.grammar @@ -6,7 +6,7 @@ @top Program { item* } -@external tokens operatorTokenizer from "./operatorTokenizer" { Star, Slash, Plus, Minus, And, Or, Eq, EqEq, Neq, Lt, Lte, Gt, Gte, Modulo } +@external tokens operatorTokenizer from "./operatorTokenizer" { Star, Slash, Plus, Minus, And, Or, Eq, EqEq, Neq, Lt, Lte, Gt, Gte, Modulo, PlusEq, MinusEq, StarEq, SlashEq, ModuloEq } @tokens { @precedence { Number Regex } @@ -53,6 +53,7 @@ consumeToTerminator { ambiguousFunctionCall | IfExpr | FunctionDef | + CompoundAssign | Assign | BinOp | ConditionalOp | @@ -151,6 +152,10 @@ Assign { AssignableIdentifier Eq consumeToTerminator } +CompoundAssign { + AssignableIdentifier (PlusEq | MinusEq | StarEq | SlashEq | ModuloEq) consumeToTerminator +} + BinOp { expression !multiplicative Modulo expression | (expression | BinOp) !multiplicative Star (expression | BinOp) | diff --git a/src/parser/shrimp.terms.ts b/src/parser/shrimp.terms.ts index 144d69b..54fae8a 100644 --- a/src/parser/shrimp.terms.ts +++ b/src/parser/shrimp.terms.ts @@ -14,40 +14,46 @@ export const Gt = 12, Gte = 13, Modulo = 14, - Identifier = 15, - AssignableIdentifier = 16, - Word = 17, - IdentifierBeforeDot = 18, - Do = 19, - Program = 20, - PipeExpr = 21, - FunctionCall = 22, - DotGet = 23, - Number = 24, - ParenExpr = 25, - FunctionCallOrIdentifier = 26, - BinOp = 27, - String = 28, - StringFragment = 29, - Interpolation = 30, - EscapeSeq = 31, - Boolean = 32, - Regex = 33, - Dict = 34, - NamedArg = 35, - NamedArgPrefix = 36, - 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 + PlusEq = 15, + MinusEq = 16, + StarEq = 17, + SlashEq = 18, + ModuloEq = 19, + Identifier = 20, + AssignableIdentifier = 21, + Word = 22, + IdentifierBeforeDot = 23, + Do = 24, + Program = 25, + PipeExpr = 26, + FunctionCall = 27, + DotGet = 28, + Number = 29, + ParenExpr = 30, + FunctionCallOrIdentifier = 31, + BinOp = 32, + String = 33, + StringFragment = 34, + Interpolation = 35, + EscapeSeq = 36, + Boolean = 37, + Regex = 38, + Dict = 39, + NamedArg = 40, + NamedArgPrefix = 41, + FunctionDef = 42, + Params = 43, + colon = 44, + keyword = 59, + Underscore = 46, + Array = 47, + Null = 48, + ConditionalOp = 49, + PositionalArg = 50, + IfExpr = 52, + SingleLineThenBlock = 54, + ThenBlock = 55, + ElseIfExpr = 56, + ElseExpr = 58, + CompoundAssign = 60, + Assign = 61 diff --git a/src/parser/shrimp.ts b/src/parser/shrimp.ts index 6e19a7f..2f79c7c 100644 --- a/src/parser/shrimp.ts +++ b/src/parser/shrimp.ts @@ -4,24 +4,24 @@ 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,end:90, null:96, if:106, elseif:114, else:118} 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`<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)], - 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 + 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$QUrSOt#{uw#{x#O#{#P;'S#{;'S;=`$d<%lO#{S$gP;=`<%l#{^$qUrS!lYOt#{uw#{x#O#{#P;'S#{;'S;=`$d<%lO#{U%[UrS#OQOt#{uw#{x#O#{#P;'S#{;'S;=`$d<%lO#{^%uZrS!mYOY%nYZ#{Zt%ntu&huw%nwx&hx#O%n#O#P&h#P;'S%n;'S;=`'P<%lO%nY&mS!mYOY&hZ;'S&h;'S;=`&y<%lO&hY&|P;=`<%l&h^'SP;=`<%l%n~'[O!w~~'aO!u~U'hUrS!rQOt#{uw#{x#O#{#P;'S#{;'S;=`$d<%lO#{U(RUrS#TQOt#{uw#{x#O#{#P;'S#{;'S;=`$d<%lO#{U(jWrSOt#{uw#{x!Q#{!Q![)S![#O#{#P;'S#{;'S;=`$d<%lO#{U)ZYrSmQOt#{uw#{x!O#{!O!P)y!P!Q#{!Q![)S![#O#{#P;'S#{;'S;=`$d<%lO#{U*OWrSOt#{uw#{x!Q#{!Q![*h![#O#{#P;'S#{;'S;=`$d<%lO#{U*oWrSmQOt#{uw#{x!Q#{!Q![*h![#O#{#P;'S#{;'S;=`$d<%lO#{U+^WrSOt#{uw#{x!P#{!P!Q+v!Q#O#{#P;'S#{;'S;=`$d<%lO#{U+{^rSOY,wYZ#{Zt,wtu-zuw,wwx-zx!P,w!P!Q#{!Q!},w!}#O2m#O#P0Y#P;'S,w;'S;=`3n<%lO,wU-O^rSvQOY,wYZ#{Zt,wtu-zuw,wwx-zx!P,w!P!Q0o!Q!},w!}#O2m#O#P0Y#P;'S,w;'S;=`3n<%lO,wQ.PXvQOY-zZ!P-z!P!Q.l!Q!}-z!}#O/Z#O#P0Y#P;'S-z;'S;=`0i<%lO-zQ.oP!P!Q.rQ.wUvQ#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-zU0tWrSOt#{uw#{x!P#{!P!Q1^!Q#O#{#P;'S#{;'S;=`$d<%lO#{U1ebrSvQOt#{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[rSOY2mYZ#{Zt2mtu/Zuw2mwx/Zx#O2m#O#P/s#P#Q,w#Q;'S2m;'S;=`3h<%lO2mU3kP;=`<%l2mU3qP;=`<%l,wU3{UrS|QOt#{uw#{x#O#{#P;'S#{;'S;=`$d<%lO#{U4fW!}QrSOt#{uw#{x!_#{!_!`5O!`#O#{#P;'S#{;'S;=`$d<%lO#{U5TVrSOt#{uw#{x#O#{#P#Q5j#Q;'S#{;'S;=`$d<%lO#{U5qU!|QrSOt#{uw#{x#O#{#P;'S#{;'S;=`$d<%lO#{~6YO!x~U6aU#SQrSOt#{uw#{x#O#{#P;'S#{;'S;=`$d<%lO#{U6zUrS!OQOt#{uw#{x#O#{#P;'S#{;'S;=`$d<%lO#{U7cYrSOt#{uw#{x!_#{!_!`8R!`#O#{#P#T#{#T#o7^#o;'S#{;'S;=`$d<%lO#{U8YUyQrSOt#{uw#{x#O#{#P;'S#{;'S;=`$d<%lO#{U8qZrSOt#{uw#{x!_#{!_!`8R!`#O#{#P#T#{#T#U9d#U#o7^#o;'S#{;'S;=`$d<%lO#{U9i[rSOt#{uw#{x!_#{!_!`8R!`#O#{#P#T#{#T#`7^#`#a:_#a#o7^#o;'S#{;'S;=`$d<%lO#{U:d[rSOt#{uw#{x!_#{!_!`8R!`#O#{#P#T#{#T#g7^#g#h;Y#h#o7^#o;'S#{;'S;=`$d<%lO#{U;_[rSOt#{uw#{x!_#{!_!`8R!`#O#{#P#T#{#T#X7^#X#Yo[!zWrSOt#{uw#{x!_#{!_!`8R!`#O#{#P#T#{#T#f7^#f#g?e#g#o7^#o;'S#{;'S;=`$d<%lO#{U?j[rSOt#{uw#{x!_#{!_!`8R!`#O#{#P#T#{#T#i7^#i#j;Y#j#o7^#o;'S#{;'S;=`$d<%lO#{U@gU!TQrSOt#{uw#{x#O#{#P;'S#{;'S;=`$d<%lO#{~AOO#X~", + tokenizers: [operatorTokenizer, 1, 2, 3, tokenizer, new LocalTokenGroup("[~RP!O!PU~ZO!q~~", 11)], + topRules: {"Program":[0,25]}, + specialized: [{term: 20, get: (value: any, stack: any) => (specializeKeyword(value, stack) << 1), external: specializeKeyword},{term: 20, get: (value: keyof typeof spec_Identifier) => spec_Identifier[value] || -1}], + tokenPrec: 1150 }) diff --git a/src/parser/tests/basics.test.ts b/src/parser/tests/basics.test.ts index da6d4bb..86e4a97 100644 --- a/src/parser/tests/basics.test.ts +++ b/src/parser/tests/basics.test.ts @@ -532,6 +532,72 @@ describe('Assign', () => { }) }) +describe('CompoundAssign', () => { + test('parses += operator', () => { + expect('x += 5').toMatchTree(` + CompoundAssign + AssignableIdentifier x + PlusEq += + Number 5`) + }) + + test('parses -= operator', () => { + expect('count -= 1').toMatchTree(` + CompoundAssign + AssignableIdentifier count + MinusEq -= + Number 1`) + }) + + test('parses *= operator', () => { + expect('total *= 2').toMatchTree(` + CompoundAssign + AssignableIdentifier total + StarEq *= + Number 2`) + }) + + test('parses /= operator', () => { + expect('value /= 10').toMatchTree(` + CompoundAssign + AssignableIdentifier value + SlashEq /= + Number 10`) + }) + + test('parses %= operator', () => { + expect('remainder %= 3').toMatchTree(` + CompoundAssign + AssignableIdentifier remainder + ModuloEq %= + Number 3`) + }) + + test('parses compound assignment with expression', () => { + expect('x += 1 + 2').toMatchTree(` + CompoundAssign + AssignableIdentifier x + PlusEq += + BinOp + Number 1 + Plus + + Number 2`) + }) + + test('parses compound assignment with function call', () => { + expect('total += add 5 3').toMatchTree(` + CompoundAssign + AssignableIdentifier total + PlusEq += + FunctionCall + Identifier add + PositionalArg + Number 5 + PositionalArg + Number 3`) + }) +}) + describe('DotGet whitespace sensitivity', () => { test('no whitespace - DotGet works when identifier in scope', () => { expect('basename = 5; basename.prop').toMatchTree(` diff --git a/src/parser/tokenizer.ts b/src/parser/tokenizer.ts index e4fc895..cbaac67 100644 --- a/src/parser/tokenizer.ts +++ b/src/parser/tokenizer.ts @@ -184,6 +184,17 @@ const chooseIdentifierToken = (input: InputStream, stack: Stack): number => { } const nextCh = getFullCodePoint(input, peekPos) + const nextCh2 = getFullCodePoint(input, peekPos + 1) + + // Check for compound assignment operators: +=, -=, *=, /=, %= + if ([43/* + */, 45/* - */, 42/* * */, 47/* / */, 37/* % */].includes(nextCh) && nextCh2 === 61/* = */) { + // Found compound operator, check if it's followed by whitespace + const charAfterOp = getFullCodePoint(input, peekPos + 2) + if (isWhiteSpace(charAfterOp) || charAfterOp === -1 /* EOF */) { + return AssignableIdentifier + } + } + if (nextCh === 61 /* = */) { // Found '=', but check if it's followed by whitespace // If '=' is followed by non-whitespace (like '=cool*'), it won't be tokenized as Eq From a8fd79a990607fe8f3c8d7975b021df7dd0f1320 Mon Sep 17 00:00:00 2001 From: Chris Wanstrath Date: Wed, 29 Oct 2025 19:05:49 -0700 Subject: [PATCH 08/10] push/pop/shift/unshift (and fix sort() --- src/prelude/list.ts | 47 ++++++++++++++++++- src/prelude/tests/prelude.test.ts | 78 +++++++++++++++++++++++++++++++ 2 files changed, 123 insertions(+), 2 deletions(-) diff --git a/src/prelude/list.ts b/src/prelude/list.ts index eb013ef..ec38e23 100644 --- a/src/prelude/list.ts +++ b/src/prelude/list.ts @@ -1,3 +1,5 @@ +import { type Value, toValue, toNull } from 'reefvm' + export const list = { slice: (list: any[], start: number, end?: number) => list.slice(start, end), map: async (list: any[], cb: Function) => { @@ -40,9 +42,41 @@ export const list = { return true }, + // mutating + push: (list: Value, item: Value) => { + if (list.type !== 'array') return toNull() + return toValue(list.value.push(item)) + }, + pop: (list: Value) => { + if (list.type !== 'array') return toNull() + return toValue(list.value.pop()) + }, + shift: (list: Value) => { + if (list.type !== 'array') return toNull() + return toValue(list.value.shift()) + }, + unshift: (list: Value, item: Value) => { + if (list.type !== 'array') return toNull() + return toValue(list.value.unshift(item)) + }, + splice: (list: Value, start: Value, deleteCount: Value, ...items: Value[]) => { + const realList = list.value as any[] + const realStart = start.value as number + const realDeleteCount = deleteCount.value as number + const realItems = items.map(item => item.value) + return toValue(realList.splice(realStart, realDeleteCount, ...realItems)) + }, + // sequence operations reverse: (list: any[]) => list.slice().reverse(), - sort: (list: any[], cb?: (a: any, b: any) => number) => list.slice().sort(cb), + sort: async (list: any[], cb?: (a: any, b: any) => number) => { + const arr = [...list] + if (!cb) return arr.sort() + for (let i = 0; i < arr.length; i++) + for (let j = i + 1; j < arr.length; j++) + if ((await cb(arr[i], arr[j])) > 0) [arr[i], arr[j]] = [arr[j], arr[i]] + return arr + }, concat: (...lists: any[][]) => lists.flat(1), flatten: (list: any[], depth: number = 1) => list.flat(depth), unique: (list: any[]) => Array.from(new Set(list)), @@ -86,4 +120,13 @@ export const list = { } return groups }, -} \ No newline at end of file +} + + + // raw functions deal directly in Value types, meaning we can modify collection + // careful - they MUST return a Value! + ; (list.splice as any).raw = true + ; (list.push as any).raw = true + ; (list.pop as any).raw = true + ; (list.shift as any).raw = true + ; (list.unshift as any).raw = true \ No newline at end of file diff --git a/src/prelude/tests/prelude.test.ts b/src/prelude/tests/prelude.test.ts index ef7d8d6..f061e29 100644 --- a/src/prelude/tests/prelude.test.ts +++ b/src/prelude/tests/prelude.test.ts @@ -341,6 +341,84 @@ describe('collections', () => { await expect(`list.index-of [1 2 3] 5`).toEvaluateTo(-1, globals) }) + test('list.push adds to end and mutates array', async () => { + await expect(`arr = [1 2]; list.push arr 3; arr`).toEvaluateTo([1, 2, 3], globals) + }) + + test('list.push returns the size of the array', async () => { + await expect(`arr = [1 2]; arr | list.push 3`).toEvaluateTo(3, globals) + }) + + test('list.pop removes from end and mutates array', async () => { + await expect(`arr = [1 2 3]; list.pop arr; arr`).toEvaluateTo([1, 2], globals) + }) + + test('list.pop returns removed element', async () => { + await expect(`list.pop [1 2 3]`).toEvaluateTo(3, globals) + }) + + test('list.pop returns null for empty array', async () => { + await expect(`list.pop []`).toEvaluateTo(null, globals) + }) + + test('list.shift removes from start and mutates array', async () => { + await expect(`arr = [1 2 3]; list.shift arr; arr`).toEvaluateTo([2, 3], globals) + }) + + test('list.shift returns removed element', async () => { + await expect(`list.shift [1 2 3]`).toEvaluateTo(1, globals) + }) + + test('list.shift returns null for empty array', async () => { + await expect(`list.shift []`).toEvaluateTo(null, globals) + }) + + test('list.unshift adds to start and mutates array', async () => { + await expect(`arr = [2 3]; list.unshift arr 1; arr`).toEvaluateTo([1, 2, 3], globals) + }) + + test('list.unshift returns the length of the array', async () => { + await expect(`arr = [2 3]; arr | list.unshift 1`).toEvaluateTo(3, globals) + }) + + test('list.splice removes elements and mutates array', async () => { + await expect(`arr = [1 2 3 4 5]; list.splice arr 1 2; arr`).toEvaluateTo([1, 4, 5], globals) + }) + + test('list.splice returns removed elements', async () => { + await expect(`list.splice [1 2 3 4 5] 1 2`).toEvaluateTo([2, 3], globals) + }) + + test('list.splice from start', async () => { + await expect(`list.splice [1 2 3 4 5] 0 2`).toEvaluateTo([1, 2], globals) + }) + + test('list.splice to end', async () => { + await expect(`arr = [1 2 3 4 5]; list.splice arr 3 2; arr`).toEvaluateTo([1, 2, 3], globals) + }) + + test('list.sort with no callback sorts ascending', async () => { + await expect(`list.sort [3 1 4 1 5] null`).toEvaluateTo([1, 1, 3, 4, 5], globals) + }) + + test('list.sort with callback sorts using comparator', async () => { + await expect(` + desc = do a b: + b - a + end + list.sort [3 1 4 1 5] desc + `).toEvaluateTo([5, 4, 3, 1, 1], globals) + }) + + test('list.sort with callback for strings by length', async () => { + await expect(` + by-length = do a b: + (length a) - (length b) + end + list.sort ['cat' 'a' 'dog' 'elephant'] by-length + `).toEvaluateTo(['a', 'cat', 'dog', 'elephant'], globals) + }) + test('list.any? checks if any element matches', async () => { await expect(` gt-three = do x: x > 3 end From 2329a2ebb685c80796a04cc35c56bf1ab1430270 Mon Sep 17 00:00:00 2001 From: Corey Johnson Date: Fri, 31 Oct 2025 10:04:43 -0700 Subject: [PATCH 09/10] Update bun.lock --- bun.lock | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/bun.lock b/bun.lock index e8b8c92..afb8aaa 100644 --- a/bun.lock +++ b/bun.lock @@ -62,7 +62,7 @@ "hono": ["hono@4.9.8", "", {}, "sha512-JW8Bb4RFWD9iOKxg5PbUarBYGM99IcxFl2FPBo2gSJO11jjUDqlP1Bmfyqt8Z/dGhIQ63PMA9LdcLefXyIasyg=="], - "reefvm": ["reefvm@git+https://git.nose.space/defunkt/reefvm#9618dd64148ccf6f0cdfd8a80a0f58efe3e0819d", { "peerDependencies": { "typescript": "^5" } }, "9618dd64148ccf6f0cdfd8a80a0f58efe3e0819d"], + "reefvm": ["reefvm@git+https://git.nose.space/defunkt/reefvm#c69b172c78853756ec8acba5bc33d93eb6a571c6", { "peerDependencies": { "typescript": "^5" } }, "c69b172c78853756ec8acba5bc33d93eb6a571c6"], "style-mod": ["style-mod@4.1.2", "", {}, "sha512-wnD1HyVqpJUI2+eKZ+eo1UwghftP6yuFheBqqe+bWCotBjC2K1YnteJILRMs3SM4V/0dLEW1SC27MWP5y+mwmw=="], From 887be4124809c84c7f6d7b276ac2fa4a4175836c Mon Sep 17 00:00:00 2001 From: Corey Johnson Date: Fri, 31 Oct 2025 10:06:47 -0700 Subject: [PATCH 10/10] Update generated files --- src/parser/shrimp.terms.ts | 31 ++++++++++++++++++------------- src/parser/shrimp.ts | 18 +++++++++--------- 2 files changed, 27 insertions(+), 22 deletions(-) diff --git a/src/parser/shrimp.terms.ts b/src/parser/shrimp.terms.ts index 54fae8a..ab4011b 100644 --- a/src/parser/shrimp.terms.ts +++ b/src/parser/shrimp.terms.ts @@ -44,16 +44,21 @@ export const FunctionDef = 42, Params = 43, colon = 44, - keyword = 59, - Underscore = 46, - Array = 47, - Null = 48, - ConditionalOp = 49, - PositionalArg = 50, - IfExpr = 52, - SingleLineThenBlock = 54, - ThenBlock = 55, - ElseIfExpr = 56, - ElseExpr = 58, - CompoundAssign = 60, - Assign = 61 + CatchExpr = 45, + keyword = 68, + TryBlock = 47, + FinallyExpr = 48, + Underscore = 51, + Array = 52, + Null = 53, + ConditionalOp = 54, + PositionalArg = 55, + TryExpr = 57, + Throw = 59, + IfExpr = 61, + SingleLineThenBlock = 63, + ThenBlock = 64, + ElseIfExpr = 65, + ElseExpr = 67, + CompoundAssign = 69, + Assign = 70 diff --git a/src/parser/shrimp.ts b/src/parser/shrimp.ts index 2f79c7c..172f6a7 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:90, null:96, if:106, elseif:114, else:118} +const spec_Identifier = {__proto__:null,catch:92, finally:98, end:100, null:106, try:116, throw:120, if:124, elseif:132, else:136} export const parser = LRParser.deserialize({ version: 14, - states: "3bQYQbOOO#hQcO'#C{O$eOSO'#C}O$sQbO'#E]OOQ`'#DW'#DWOOQa'#DT'#DTO%vQbO'#D]O&{QcO'#EQOOQa'#EQ'#EQO)PQcO'#EPO)xQRO'#C|O*]QcO'#D{O*tQcO'#D{O+VQbO'#CzO+}OpO'#CxOOQ`'#D|'#D|O,SQbO'#D{O,bQbO'#EcOOQ`'#Db'#DbO-VQRO'#DjOOQ`'#D{'#D{O-kQQO'#DzOOQ`'#Dz'#DzOOQ`'#Dl'#DlQYQbOOO-sQbO'#DUOOQa'#EP'#EPOOQ`'#D`'#D`OOQ`'#Eb'#EbOOQ`'#Ds'#DsO-}QbO,59cO.bQbO'#DPO.jQWO'#DQOOOO'#ES'#ESOOOO'#Dm'#DmO/OOSO,59iOOQa,59i,59iOOQ`'#Do'#DoO/^QbO'#DXO/fQQO,5:wOOQ`'#Dn'#DnO/kQbO,59wO/rQQO,59oOOQa,59w,59wO/}QbO,59wO,bQbO,59hO,bQbO,59hO,bQbO,59hO,bQbO,59yO,bQbO,59yO,bQbO,59yO0XQRO,59fO0`QRO,59fO0qQRO,59fO0lQQO,59fO0|QQO,59fO1UObO,59dO1aQbO'#DtO1lQbO,59bO1}QRO,5:}O2UQRO,5:}O2aQbO,5:UO2aQbO,5:VOOQ`,5:f,5:fOOQ`-E7j-E7jOOQ`,59p,59pOOQ`-E7q-E7qOOOO,59k,59kOOOO,59l,59lOOOO-E7k-E7kOOQa1G/T1G/TOOQ`-E7m-E7mO2kQbO1G0cOOQ`-E7l-E7lO2xQQO1G/ZOOQa1G/c1G/cO3TQbO1G/cOOQO'#Dq'#DqO2xQQO1G/ZOOQa1G/Z1G/ZOOQ`'#Dr'#DrO3TQbO1G/cOOQa1G/S1G/SO3vQcO1G/SO4QQcO1G/SO4[QcO1G/SOOQa1G/e1G/eO5nQcO1G/eO5uQcO1G/eO5|QcO1G/eOOQa1G/Q1G/QOOQa1G/O1G/OO!ZQbO'#C{O%}QbO'#CwOOQ`,5:`,5:`OOQ`-E7r-E7rO6TQbO1G0iOOQ`1G/p1G/pOOQ`1G/q1G/qO6bQbO7+%}O6gQbO7+&OO6wQQO7+$uOOQa7+$u7+$uO7SQbO7+$}OOQa7+$}7+$}OOQO-E7o-E7oOOQ`-E7p-E7pOOQ`'#Dd'#DdO7^QbO7+&TO7cQbO7+&UOOQ`<RQbO<WQbO<`QbO<kQQO,59uO>pQbO,59xOOQ`<h#i#o7^#o#p#{#p#q@`#q;'S#{;'S;=`$d<%l~#{~O#{~~@yS$QUrSOt#{uw#{x#O#{#P;'S#{;'S;=`$d<%lO#{S$gP;=`<%l#{^$qUrS!lYOt#{uw#{x#O#{#P;'S#{;'S;=`$d<%lO#{U%[UrS#OQOt#{uw#{x#O#{#P;'S#{;'S;=`$d<%lO#{^%uZrS!mYOY%nYZ#{Zt%ntu&huw%nwx&hx#O%n#O#P&h#P;'S%n;'S;=`'P<%lO%nY&mS!mYOY&hZ;'S&h;'S;=`&y<%lO&hY&|P;=`<%l&h^'SP;=`<%l%n~'[O!w~~'aO!u~U'hUrS!rQOt#{uw#{x#O#{#P;'S#{;'S;=`$d<%lO#{U(RUrS#TQOt#{uw#{x#O#{#P;'S#{;'S;=`$d<%lO#{U(jWrSOt#{uw#{x!Q#{!Q![)S![#O#{#P;'S#{;'S;=`$d<%lO#{U)ZYrSmQOt#{uw#{x!O#{!O!P)y!P!Q#{!Q![)S![#O#{#P;'S#{;'S;=`$d<%lO#{U*OWrSOt#{uw#{x!Q#{!Q![*h![#O#{#P;'S#{;'S;=`$d<%lO#{U*oWrSmQOt#{uw#{x!Q#{!Q![*h![#O#{#P;'S#{;'S;=`$d<%lO#{U+^WrSOt#{uw#{x!P#{!P!Q+v!Q#O#{#P;'S#{;'S;=`$d<%lO#{U+{^rSOY,wYZ#{Zt,wtu-zuw,wwx-zx!P,w!P!Q#{!Q!},w!}#O2m#O#P0Y#P;'S,w;'S;=`3n<%lO,wU-O^rSvQOY,wYZ#{Zt,wtu-zuw,wwx-zx!P,w!P!Q0o!Q!},w!}#O2m#O#P0Y#P;'S,w;'S;=`3n<%lO,wQ.PXvQOY-zZ!P-z!P!Q.l!Q!}-z!}#O/Z#O#P0Y#P;'S-z;'S;=`0i<%lO-zQ.oP!P!Q.rQ.wUvQ#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-zU0tWrSOt#{uw#{x!P#{!P!Q1^!Q#O#{#P;'S#{;'S;=`$d<%lO#{U1ebrSvQOt#{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[rSOY2mYZ#{Zt2mtu/Zuw2mwx/Zx#O2m#O#P/s#P#Q,w#Q;'S2m;'S;=`3h<%lO2mU3kP;=`<%l2mU3qP;=`<%l,wU3{UrS|QOt#{uw#{x#O#{#P;'S#{;'S;=`$d<%lO#{U4fW!}QrSOt#{uw#{x!_#{!_!`5O!`#O#{#P;'S#{;'S;=`$d<%lO#{U5TVrSOt#{uw#{x#O#{#P#Q5j#Q;'S#{;'S;=`$d<%lO#{U5qU!|QrSOt#{uw#{x#O#{#P;'S#{;'S;=`$d<%lO#{~6YO!x~U6aU#SQrSOt#{uw#{x#O#{#P;'S#{;'S;=`$d<%lO#{U6zUrS!OQOt#{uw#{x#O#{#P;'S#{;'S;=`$d<%lO#{U7cYrSOt#{uw#{x!_#{!_!`8R!`#O#{#P#T#{#T#o7^#o;'S#{;'S;=`$d<%lO#{U8YUyQrSOt#{uw#{x#O#{#P;'S#{;'S;=`$d<%lO#{U8qZrSOt#{uw#{x!_#{!_!`8R!`#O#{#P#T#{#T#U9d#U#o7^#o;'S#{;'S;=`$d<%lO#{U9i[rSOt#{uw#{x!_#{!_!`8R!`#O#{#P#T#{#T#`7^#`#a:_#a#o7^#o;'S#{;'S;=`$d<%lO#{U:d[rSOt#{uw#{x!_#{!_!`8R!`#O#{#P#T#{#T#g7^#g#h;Y#h#o7^#o;'S#{;'S;=`$d<%lO#{U;_[rSOt#{uw#{x!_#{!_!`8R!`#O#{#P#T#{#T#X7^#X#Yo[!zWrSOt#{uw#{x!_#{!_!`8R!`#O#{#P#T#{#T#f7^#f#g?e#g#o7^#o;'S#{;'S;=`$d<%lO#{U?j[rSOt#{uw#{x!_#{!_!`8R!`#O#{#P#T#{#T#i7^#i#j;Y#j#o7^#o;'S#{;'S;=`$d<%lO#{U@gU!TQrSOt#{uw#{x#O#{#P;'S#{;'S;=`$d<%lO#{~AOO#X~", - tokenizers: [operatorTokenizer, 1, 2, 3, tokenizer, new LocalTokenGroup("[~RP!O!PU~ZO!q~~", 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$QUrSOt#{uw#{x#O#{#P;'S#{;'S;=`$d<%lO#{S$gP;=`<%l#{^$qUrS!uYOt#{uw#{x#O#{#P;'S#{;'S;=`$d<%lO#{U%[UrS#XQOt#{uw#{x#O#{#P;'S#{;'S;=`$d<%lO#{^%uZrS!vYOY%nYZ#{Zt%ntu&huw%nwx&hx#O%n#O#P&h#P;'S%n;'S;=`'P<%lO%nY&mS!vYOY&hZ;'S&h;'S;=`&y<%lO&hY&|P;=`<%l&h^'SP;=`<%l%n~'[O#Q~~'aO#O~U'hUrS!{QOt#{uw#{x#O#{#P;'S#{;'S;=`$d<%lO#{U(RUrS#^QOt#{uw#{x#O#{#P;'S#{;'S;=`$d<%lO#{U(jWrSOt#{uw#{x!Q#{!Q![)S![#O#{#P;'S#{;'S;=`$d<%lO#{U)ZYrSmQOt#{uw#{x!O#{!O!P)y!P!Q#{!Q![)S![#O#{#P;'S#{;'S;=`$d<%lO#{U*OWrSOt#{uw#{x!Q#{!Q![*h![#O#{#P;'S#{;'S;=`$d<%lO#{U*oWrSmQOt#{uw#{x!Q#{!Q![*h![#O#{#P;'S#{;'S;=`$d<%lO#{U+^WrSOt#{uw#{x!P#{!P!Q+v!Q#O#{#P;'S#{;'S;=`$d<%lO#{U+{^rSOY,wYZ#{Zt,wtu-zuw,wwx-zx!P,w!P!Q#{!Q!},w!}#O2m#O#P0Y#P;'S,w;'S;=`3n<%lO,wU-O^rSvQOY,wYZ#{Zt,wtu-zuw,wwx-zx!P,w!P!Q0o!Q!},w!}#O2m#O#P0Y#P;'S,w;'S;=`3n<%lO,wQ.PXvQOY-zZ!P-z!P!Q.l!Q!}-z!}#O/Z#O#P0Y#P;'S-z;'S;=`0i<%lO-zQ.oP!P!Q.rQ.wUvQ#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-zU0tWrSOt#{uw#{x!P#{!P!Q1^!Q#O#{#P;'S#{;'S;=`$d<%lO#{U1ebrSvQOt#{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[rSOY2mYZ#{Zt2mtu/Zuw2mwx/Zx#O2m#O#P/s#P#Q,w#Q;'S2m;'S;=`3h<%lO2mU3kP;=`<%l2mU3qP;=`<%l,wU3{UrS|QOt#{uw#{x#O#{#P;'S#{;'S;=`$d<%lO#{U4fW#WQrSOt#{uw#{x!_#{!_!`5O!`#O#{#P;'S#{;'S;=`$d<%lO#{U5TVrSOt#{uw#{x#O#{#P#Q5j#Q;'S#{;'S;=`$d<%lO#{U5qU#VQrSOt#{uw#{x#O#{#P;'S#{;'S;=`$d<%lO#{~6YO#R~U6aU#]QrSOt#{uw#{x#O#{#P;'S#{;'S;=`$d<%lO#{U6zUrS!TQOt#{uw#{x#O#{#P;'S#{;'S;=`$d<%lO#{U7cYrSOt#{uw#{x!_#{!_!`8R!`#O#{#P#T#{#T#o7^#o;'S#{;'S;=`$d<%lO#{U8YUyQrSOt#{uw#{x#O#{#P;'S#{;'S;=`$d<%lO#{U8qZrSOt#{uw#{x!_#{!_!`8R!`#O#{#P#T#{#T#U9d#U#o7^#o;'S#{;'S;=`$d<%lO#{U9i[rSOt#{uw#{x!_#{!_!`8R!`#O#{#P#T#{#T#`7^#`#a:_#a#o7^#o;'S#{;'S;=`$d<%lO#{U:d[rSOt#{uw#{x!_#{!_!`8R!`#O#{#P#T#{#T#g7^#g#h;Y#h#o7^#o;'S#{;'S;=`$d<%lO#{U;_[rSOt#{uw#{x!_#{!_!`8R!`#O#{#P#T#{#T#X7^#X#Yo[#TWrSOt#{uw#{x!_#{!_!`8R!`#O#{#P#T#{#T#f7^#f#g?e#g#o7^#o;'S#{;'S;=`$d<%lO#{U?j[rSOt#{uw#{x!_#{!_!`8R!`#O#{#P#T#{#T#i7^#i#j;Y#j#o7^#o;'S#{;'S;=`$d<%lO#{U@gU!YQrSOt#{uw#{x#O#{#P;'S#{;'S;=`$d<%lO#{~AOO#d~", + tokenizers: [operatorTokenizer, 1, 2, 3, tokenizer, new LocalTokenGroup("[~RP!O!PU~ZO!z~~", 11)], topRules: {"Program":[0,25]}, specialized: [{term: 20, get: (value: any, stack: any) => (specializeKeyword(value, stack) << 1), external: specializeKeyword},{term: 20, get: (value: keyof typeof spec_Identifier) => spec_Identifier[value] || -1}], - tokenPrec: 1150 + tokenPrec: 1562 })