diff --git a/src/compiler/compiler.ts b/src/compiler/compiler.ts index 7581f17..efcff49 100644 --- a/src/compiler/compiler.ts +++ b/src/compiler/compiler.ts @@ -9,6 +9,7 @@ import { checkTreeForErrors, getAllChildren, getAssignmentParts, + getCompoundAssignmentParts, getBinaryParts, getDotGetParts, getFunctionCallParts, @@ -17,6 +18,7 @@ import { getNamedArgParts, getPipeExprParts, getStringParts, + getTryExprParts, } from '#compiler/utils' const DEBUG = false @@ -51,6 +53,7 @@ export class Compiler { instructions: ProgramItem[] = [] fnLabelCount = 0 ifLabelCount = 0 + tryLabelCount = 0 bytecode: Bytecode pipeCounter = 0 @@ -265,6 +268,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? @@ -273,7 +304,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}` @@ -281,9 +315,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}:`]) @@ -337,10 +389,48 @@ export class Compiler { } case terms.ThenBlock: - case terms.SingleLineThenBlock: { - const instructions = getAllChildren(node) - .map((child) => this.#compileNode(child, input)) - .flat() + case terms.SingleLineThenBlock: + case terms.TryBlock: { + const children = getAllChildren(node) + const instructions: ProgramItem[] = [] + + children.forEach((child, index) => { + instructions.push(...this.#compileNode(child, input)) + // keep only the last expression's value + if (index < children.length - 1) { + instructions.push(['POP']) + } + }) + + return instructions + } + + case terms.TryExpr: { + const { tryBlock, catchVariable, catchBody, finallyBody } = getTryExprParts(node, input) + + return this.#compileTryCatchFinally( + () => this.#compileNode(tryBlock, input), + catchVariable, + catchBody, + finallyBody, + input + ) + } + + case terms.Throw: { + const children = getAllChildren(node) + const [_throwKeyword, expression] = children + if (!expression) { + throw new CompilerError( + `Throw expected expression, got ${children.length} children`, + node.from, + node.to + ) + } + + const instructions: ProgramItem[] = [] + instructions.push(...this.#compileNode(expression, input)) + instructions.push(['THROW']) return instructions } @@ -547,4 +637,52 @@ export class Compiler { ) } } + + #compileTryCatchFinally( + compileTryBody: () => ProgramItem[], + catchVariable: string | undefined, + catchBody: SyntaxNode | undefined, + finallyBody: SyntaxNode | undefined, + input: string + ): ProgramItem[] { + const instructions: ProgramItem[] = [] + this.tryLabelCount++ + const catchLabel: Label = `.catch_${this.tryLabelCount}` + const finallyLabel: Label = finallyBody ? `.finally_${this.tryLabelCount}` : (null as any) + const endLabel: Label = `.end_try_${this.tryLabelCount}` + + instructions.push(['PUSH_TRY', catchLabel]) + instructions.push(...compileTryBody()) + instructions.push(['POP_TRY']) + instructions.push(['JUMP', finallyBody ? finallyLabel : endLabel]) + + // catch block + instructions.push([`${catchLabel}:`]) + if (catchBody && catchVariable) { + instructions.push(['STORE', catchVariable]) + const catchInstructions = this.#compileNode(catchBody, input) + instructions.push(...catchInstructions) + instructions.push(['JUMP', finallyBody ? finallyLabel : endLabel]) + } else { + // no catch block + if (finallyBody) { + instructions.push(['JUMP', finallyLabel]) + } else { + instructions.push(['THROW']) + } + } + + // finally block + if (finallyBody) { + instructions.push([`${finallyLabel}:`]) + const finallyInstructions = this.#compileNode(finallyBody, input) + instructions.push(...finallyInstructions) + // finally doesn't return a value + instructions.push(['POP']) + } + + instructions.push([`${endLabel}:`]) + + return instructions + } } diff --git a/src/compiler/tests/exceptions.test.ts b/src/compiler/tests/exceptions.test.ts new file mode 100644 index 0000000..9a71012 --- /dev/null +++ b/src/compiler/tests/exceptions.test.ts @@ -0,0 +1,311 @@ +import { describe } from 'bun:test' +import { expect, test } from 'bun:test' + +describe('exception handling', () => { + test('try with catch - no error thrown', () => { + expect(` + try: + 42 + catch err: + 99 + end + `).toEvaluateTo(42) + }) + + test('try with catch - error thrown', () => { + expect(` + try: + throw 'something went wrong' + 99 + catch err: + err + end + `).toEvaluateTo('something went wrong') + }) + + test('try with catch - catch variable binding', () => { + expect(` + try: + throw 100 + catch my-error: + my-error + 50 + end + `).toEvaluateTo(150) + }) + + test('try with finally - no error', () => { + expect(` + x = 0 + result = try: + x = 10 + 42 + finally: + x = x + 5 + end + x + `).toEvaluateTo(15) + }) + + test('try with finally - return value from try', () => { + expect(` + x = 0 + result = try: + x = 10 + 42 + finally: + x = x + 5 + 999 + end + result + `).toEvaluateTo(42) + }) + + test('try with catch and finally - no error', () => { + expect(` + x = 0 + try: + x = 10 + 42 + catch err: + x = 999 + 0 + finally: + x = x + 5 + end + x + `).toEvaluateTo(15) + }) + + test('try with catch and finally - error thrown', () => { + expect(` + x = 0 + result = try: + x = 10 + throw 'error' + 99 + catch err: + x = 20 + err + finally: + x = x + 5 + end + x + `).toEvaluateTo(25) + }) + + test('try with catch and finally - return value from catch', () => { + expect(` + result = try: + throw 'oops' + catch err: + 'caught' + finally: + 'finally' + end + result + `).toEvaluateTo('caught') + }) + + test('throw statement with string', () => { + expect(` + try: + throw 'error message' + catch err: + err + end + `).toEvaluateTo('error message') + }) + + test('throw statement with number', () => { + expect(` + try: + throw 404 + catch err: + err + end + `).toEvaluateTo(404) + }) + + test('throw statement with dict', () => { + expect(` + try: + throw [code=500 message=failed] + catch e: + e + end + `).toEvaluateTo({ code: 500, message: 'failed' }) + }) + + test('uncaught exception fails', () => { + expect(`throw 'uncaught error'`).toFailEvaluation() + }) + + test('single-line try catch', () => { + expect(`result = try: throw 'err' catch e: 'handled' end; result`).toEvaluateTo('handled') + }) + + test('nested try blocks - inner catches', () => { + expect(` + try: + result = try: + throw 'inner error' + catch err: + err + end + result + catch outer: + 'outer' + end + `).toEvaluateTo('inner error') + }) + + test('nested try blocks - outer catches', () => { + expect(` + try: + try: + throw 'inner error' + catch err: + throw 'outer error' + end + catch outer: + outer + end + `).toEvaluateTo('outer error') + }) + + test('try as expression', () => { + expect(` + x = try: 10 catch err: 0 end + y = try: throw 'err' catch err: 20 end + x + y + `).toEvaluateTo(30) + }) +}) + +describe('function-level exception handling', () => { + test('function with catch - no error', () => { + expect(` + read-file = do path: + path + catch e: + 'default' + end + + read-file test.txt + `).toEvaluateTo('test.txt') + }) + + test('function with catch - error thrown', () => { + expect(` + read-file = do path: + throw 'file not found' + catch e: + 'default' + end + + read-file test.txt + `).toEvaluateTo('default') + }) + + test('function with catch - error variable binding', () => { + expect(` + safe-call = do: + throw 'operation failed' + catch err: + err + end + + safe-call + `).toEvaluateTo('operation failed') + }) + + test('function with finally - always runs', () => { + expect(` + counter = 0 + increment-task = do: + result = 42 + result + finally: + counter = counter + 1 + end + + x = increment-task + y = increment-task + counter + `).toEvaluateTo(2) + }) + + test('function with finally - return value from body', () => { + expect(` + get-value = do: + 100 + finally: + 999 + end + + get-value + `).toEvaluateTo(100) + }) + + test('function with catch and finally', () => { + expect(` + cleanup-count = 0 + safe-op = do should-fail: + if should-fail: + throw 'failed' + end + 'success' + catch e: + 'caught' + finally: + cleanup-count = cleanup-count + 1 + end + + result1 = safe-op false + result2 = safe-op true + cleanup-count + `).toEvaluateTo(2) + }) + + test('function with catch and finally - catch return value', () => { + expect(` + safe-fail = do: + throw 'always fails' + catch e: + 'error handled' + finally: + noop = 1 + end + + safe-fail + `).toEvaluateTo('error handled') + }) + + test('function without catch/finally still works', () => { + expect(` + regular = do x: + x + 10 + end + + regular 5 + `).toEvaluateTo(15) + }) + + test('nested functions with catch', () => { + expect(` + inner = do: + throw 'inner error' + catch e: + 'inner caught' + end + + outer = do: + inner + catch e: + 'outer caught' + end + + outer + `).toEvaluateTo('inner caught') + }) +}) diff --git a/src/compiler/tests/native-exceptions.test.ts b/src/compiler/tests/native-exceptions.test.ts new file mode 100644 index 0000000..f7e2e37 --- /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 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/compiler/utils.ts b/src/compiler/utils.ts index 99b56c6..1f8f0c1 100644 --- a/src/compiler/utils.ts +++ b/src/compiler/utils.ts @@ -65,13 +65,34 @@ 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 + 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 ) @@ -88,8 +109,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) => { @@ -204,7 +265,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) => { @@ -239,3 +305,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/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 01c82f0..af3069d 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 } @@ -51,8 +51,11 @@ item { consumeToTerminator { PipeExpr | ambiguousFunctionCall | + TryExpr | + Throw | IfExpr | FunctionDef | + CompoundAssign | Assign | BinOp | ConditionalOp | @@ -97,11 +100,11 @@ FunctionDef { } singleLineFunctionDef { - Do Params colon consumeToTerminator @specialize[@name=keyword] + Do Params colon consumeToTerminator CatchExpr? FinallyExpr? @specialize[@name=keyword] } multilineFunctionDef { - Do Params colon newlineOrSemicolon block @specialize[@name=keyword] + Do Params colon newlineOrSemicolon block CatchExpr? FinallyExpr? @specialize[@name=keyword] } IfExpr { @@ -129,7 +132,35 @@ ThenBlock { } SingleLineThenBlock { - consumeToTerminator + consumeToTerminator +} + +TryExpr { + singleLineTry | multilineTry +} + +singleLineTry { + @specialize[@name=keyword] colon consumeToTerminator CatchExpr? FinallyExpr? @specialize[@name=keyword] +} + +multilineTry { + @specialize[@name=keyword] colon newlineOrSemicolon TryBlock CatchExpr? FinallyExpr? @specialize[@name=keyword] +} + +CatchExpr { + @specialize[@name=keyword] Identifier colon (newlineOrSemicolon TryBlock | consumeToTerminator) +} + +FinallyExpr { + @specialize[@name=keyword] colon (newlineOrSemicolon TryBlock | consumeToTerminator) +} + +TryBlock { + block +} + +Throw { + @specialize[@name=keyword] (BinOp | ConditionalOp | expression) } ConditionalOp { @@ -151,6 +182,10 @@ Assign { (AssignableIdentifier | Array) 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..ab4011b 100644 --- a/src/parser/shrimp.terms.ts +++ b/src/parser/shrimp.terms.ts @@ -14,40 +14,51 @@ 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, + 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/tests/basics.test.ts b/src/parser/tests/basics.test.ts index a9fac46..d890f91 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/tests/exceptions.test.ts b/src/parser/tests/exceptions.test.ts new file mode 100644 index 0000000..039a279 --- /dev/null +++ b/src/parser/tests/exceptions.test.ts @@ -0,0 +1,278 @@ +import { expect, describe, test } from 'bun:test' + +import '../shrimp.grammar' // Importing this so changes cause it to retest! + +describe('try/catch/finally/throw', () => { + test('parses try with catch', () => { + expect(`try: + risky-operation + catch err: + handle-error err + end`).toMatchTree(` + TryExpr + keyword try + colon : + TryBlock + FunctionCallOrIdentifier + Identifier risky-operation + CatchExpr + keyword catch + Identifier err + colon : + TryBlock + FunctionCall + Identifier handle-error + PositionalArg + Identifier err + keyword end + `) + }) + + test('parses try with finally', () => { + expect(`try: + do-work + finally: + cleanup + end`).toMatchTree(` + TryExpr + keyword try + colon : + TryBlock + FunctionCallOrIdentifier + Identifier do-work + FinallyExpr + keyword finally + colon : + TryBlock + FunctionCallOrIdentifier + Identifier cleanup + keyword end + `) + }) + + test('parses try with catch and finally', () => { + expect(`try: + risky-operation + catch err: + handle-error err + finally: + cleanup + end`).toMatchTree(` + TryExpr + keyword try + colon : + TryBlock + FunctionCallOrIdentifier + Identifier risky-operation + CatchExpr + keyword catch + Identifier err + colon : + TryBlock + FunctionCall + Identifier handle-error + PositionalArg + Identifier err + FinallyExpr + keyword finally + colon : + TryBlock + FunctionCallOrIdentifier + Identifier cleanup + keyword end + `) + }) + + test('parses single-line try with catch', () => { + expect('result = try: parse-number input catch err: 0 end').toMatchTree(` + Assign + AssignableIdentifier result + Eq = + TryExpr + keyword try + colon : + FunctionCall + Identifier parse-number + PositionalArg + Identifier input + CatchExpr + keyword catch + Identifier err + colon : + Number 0 + keyword end + `) + }) + + test('parses single-line try with finally', () => { + expect('try: work catch err: 0 finally: cleanup end').toMatchTree(` + TryExpr + keyword try + colon : + FunctionCallOrIdentifier + Identifier work + CatchExpr + keyword catch + Identifier err + colon : + Number 0 + FinallyExpr + keyword finally + colon : + FunctionCallOrIdentifier + Identifier cleanup + keyword end + `) + }) + + test('parses throw statement with string', () => { + expect("throw 'error message'").toMatchTree(` + Throw + keyword throw + String + StringFragment error message + `) + }) + + test('parses throw statement with identifier', () => { + expect('throw error-object').toMatchTree(` + Throw + keyword throw + Identifier error-object + `) + }) + + test('parses throw statement with dict', () => { + expect('throw [type=validation-error message=failed]').toMatchTree(` + Throw + keyword throw + Dict + NamedArg + NamedArgPrefix type= + Identifier validation-error + NamedArg + NamedArgPrefix message= + Identifier failed + `) + }) + + test('does not parse identifiers that start with try', () => { + expect('trying = try: work catch err: 0 end').toMatchTree(` + Assign + AssignableIdentifier trying + Eq = + TryExpr + keyword try + colon : + FunctionCallOrIdentifier + Identifier work + CatchExpr + keyword catch + Identifier err + colon : + Number 0 + keyword end + `) + }) +}) + +describe('function-level exception handling', () => { + test('parses function with catch', () => { + expect(`read-file = do path: + read-data path + catch e: + empty-string + end`).toMatchTree(` + Assign + AssignableIdentifier read-file + Eq = + FunctionDef + Do do + Params + Identifier path + colon : + FunctionCall + Identifier read-data + PositionalArg + Identifier path + CatchExpr + keyword catch + Identifier e + colon : + TryBlock + FunctionCallOrIdentifier + Identifier empty-string + keyword end + `) + }) + + test('parses function with finally', () => { + expect(`cleanup-task = do x: + do-work x + finally: + close-resources + end`).toMatchTree(` + Assign + AssignableIdentifier cleanup-task + Eq = + FunctionDef + Do do + Params + Identifier x + colon : + FunctionCall + Identifier do-work + PositionalArg + Identifier x + FinallyExpr + keyword finally + colon : + TryBlock + FunctionCallOrIdentifier + Identifier close-resources + keyword end + `) + }) + + test('parses function with catch and finally', () => { + expect(`safe-operation = do x: + risky-work x + catch err: + log err + default-value + finally: + cleanup + end`).toMatchTree(` + Assign + AssignableIdentifier safe-operation + Eq = + FunctionDef + Do do + Params + Identifier x + colon : + FunctionCall + Identifier risky-work + PositionalArg + Identifier x + CatchExpr + keyword catch + Identifier err + colon : + TryBlock + FunctionCall + Identifier log + PositionalArg + Identifier err + FunctionCallOrIdentifier + Identifier default-value + FinallyExpr + keyword finally + colon : + TryBlock + FunctionCallOrIdentifier + Identifier cleanup + keyword end + `) + }) +}) 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 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..1f0ec76 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)), @@ -52,8 +86,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), @@ -86,4 +126,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/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..40b7809 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 () => { @@ -341,6 +344,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