Merge remote-tracking branch 'origin/main' into array-destructuring

This commit is contained in:
Corey Johnson 2025-10-31 10:08:03 -07:00
commit 78849c7d36
15 changed files with 1507 additions and 70 deletions

View File

@ -9,6 +9,7 @@ import {
checkTreeForErrors, checkTreeForErrors,
getAllChildren, getAllChildren,
getAssignmentParts, getAssignmentParts,
getCompoundAssignmentParts,
getBinaryParts, getBinaryParts,
getDotGetParts, getDotGetParts,
getFunctionCallParts, getFunctionCallParts,
@ -17,6 +18,7 @@ import {
getNamedArgParts, getNamedArgParts,
getPipeExprParts, getPipeExprParts,
getStringParts, getStringParts,
getTryExprParts,
} from '#compiler/utils' } from '#compiler/utils'
const DEBUG = false const DEBUG = false
@ -51,6 +53,7 @@ export class Compiler {
instructions: ProgramItem[] = [] instructions: ProgramItem[] = []
fnLabelCount = 0 fnLabelCount = 0
ifLabelCount = 0 ifLabelCount = 0
tryLabelCount = 0
bytecode: Bytecode bytecode: Bytecode
pipeCounter = 0 pipeCounter = 0
@ -265,6 +268,34 @@ export class Compiler {
return instructions 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: { case terms.ParenExpr: {
const child = node.firstChild const child = node.firstChild
if (!child) return [] // I guess it is empty parentheses? if (!child) return [] // I guess it is empty parentheses?
@ -273,7 +304,10 @@ export class Compiler {
} }
case terms.FunctionDef: { case terms.FunctionDef: {
const { paramNames, bodyNodes } = getFunctionDefParts(node, input) const { paramNames, bodyNodes, catchVariable, catchBody, finallyBody } = getFunctionDefParts(
node,
input
)
const instructions: ProgramItem[] = [] const instructions: ProgramItem[] = []
const functionLabel: Label = `.func_${this.fnLabelCount++}` const functionLabel: Label = `.func_${this.fnLabelCount++}`
const afterLabel: Label = `.after_${functionLabel}` const afterLabel: Label = `.after_${functionLabel}`
@ -281,9 +315,27 @@ export class Compiler {
instructions.push(['JUMP', afterLabel]) instructions.push(['JUMP', afterLabel])
instructions.push([`${functionLabel}:`]) 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(['RETURN'])
instructions.push([`${afterLabel}:`]) instructions.push([`${afterLabel}:`])
@ -337,10 +389,48 @@ export class Compiler {
} }
case terms.ThenBlock: case terms.ThenBlock:
case terms.SingleLineThenBlock: { case terms.SingleLineThenBlock:
const instructions = getAllChildren(node) case terms.TryBlock: {
.map((child) => this.#compileNode(child, input)) const children = getAllChildren(node)
.flat() 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 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
}
} }

View File

@ -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')
})
})

View File

@ -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' })
})
})

View File

@ -65,13 +65,34 @@ export const getAssignmentParts = (node: SyntaxNode) => {
return { identifier: left, right } 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) => { export const getFunctionDefParts = (node: SyntaxNode, input: string) => {
const children = getAllChildren(node) 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( throw new CompilerError(
`FunctionDef expected 5 children, got ${children.length}`, `FunctionDef expected at least 4 children, got ${children.length}`,
node.from, node.from,
node.to node.to
) )
@ -88,8 +109,48 @@ export const getFunctionDefParts = (node: SyntaxNode, input: string) => {
return input.slice(param.from, param.to) return input.slice(param.from, param.to)
}) })
const bodyWithoutEnd = bodyNodes.slice(0, -1) // Separate body nodes from catch/finally/end
return { paramNames, bodyNodes: bodyWithoutEnd } 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) => { 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) => { export const getDotGetParts = (node: SyntaxNode, input: string) => {
@ -239,3 +305,62 @@ export const getDotGetParts = (node: SyntaxNode, input: string) => {
return { objectName, property } 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,
}
}

View File

@ -10,7 +10,14 @@ const operators: Array<Operator> = [
{ str: '!=', tokenName: 'Neq' }, { str: '!=', tokenName: 'Neq' },
{ str: '==', tokenName: 'EqEq' }, { 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: 'Star' },
{ str: '=', tokenName: 'Eq' }, { str: '=', tokenName: 'Eq' },
{ str: '/', tokenName: 'Slash' }, { str: '/', tokenName: 'Slash' },

View File

@ -6,7 +6,7 @@
@top Program { item* } @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 { @tokens {
@precedence { Number Regex } @precedence { Number Regex }
@ -51,8 +51,11 @@ item {
consumeToTerminator { consumeToTerminator {
PipeExpr | PipeExpr |
ambiguousFunctionCall | ambiguousFunctionCall |
TryExpr |
Throw |
IfExpr | IfExpr |
FunctionDef | FunctionDef |
CompoundAssign |
Assign | Assign |
BinOp | BinOp |
ConditionalOp | ConditionalOp |
@ -97,11 +100,11 @@ FunctionDef {
} }
singleLineFunctionDef { singleLineFunctionDef {
Do Params colon consumeToTerminator @specialize[@name=keyword]<Identifier, "end"> Do Params colon consumeToTerminator CatchExpr? FinallyExpr? @specialize[@name=keyword]<Identifier, "end">
} }
multilineFunctionDef { multilineFunctionDef {
Do Params colon newlineOrSemicolon block @specialize[@name=keyword]<Identifier, "end"> Do Params colon newlineOrSemicolon block CatchExpr? FinallyExpr? @specialize[@name=keyword]<Identifier, "end">
} }
IfExpr { IfExpr {
@ -129,7 +132,35 @@ ThenBlock {
} }
SingleLineThenBlock { SingleLineThenBlock {
consumeToTerminator consumeToTerminator
}
TryExpr {
singleLineTry | multilineTry
}
singleLineTry {
@specialize[@name=keyword]<Identifier, "try"> colon consumeToTerminator CatchExpr? FinallyExpr? @specialize[@name=keyword]<Identifier, "end">
}
multilineTry {
@specialize[@name=keyword]<Identifier, "try"> colon newlineOrSemicolon TryBlock CatchExpr? FinallyExpr? @specialize[@name=keyword]<Identifier, "end">
}
CatchExpr {
@specialize[@name=keyword]<Identifier, "catch"> Identifier colon (newlineOrSemicolon TryBlock | consumeToTerminator)
}
FinallyExpr {
@specialize[@name=keyword]<Identifier, "finally"> colon (newlineOrSemicolon TryBlock | consumeToTerminator)
}
TryBlock {
block
}
Throw {
@specialize[@name=keyword]<Identifier, "throw"> (BinOp | ConditionalOp | expression)
} }
ConditionalOp { ConditionalOp {
@ -151,6 +182,10 @@ Assign {
(AssignableIdentifier | Array) Eq consumeToTerminator (AssignableIdentifier | Array) Eq consumeToTerminator
} }
CompoundAssign {
AssignableIdentifier (PlusEq | MinusEq | StarEq | SlashEq | ModuloEq) consumeToTerminator
}
BinOp { BinOp {
expression !multiplicative Modulo expression | expression !multiplicative Modulo expression |
(expression | BinOp) !multiplicative Star (expression | BinOp) | (expression | BinOp) !multiplicative Star (expression | BinOp) |

View File

@ -14,40 +14,51 @@ export const
Gt = 12, Gt = 12,
Gte = 13, Gte = 13,
Modulo = 14, Modulo = 14,
Identifier = 15, PlusEq = 15,
AssignableIdentifier = 16, MinusEq = 16,
Word = 17, StarEq = 17,
IdentifierBeforeDot = 18, SlashEq = 18,
Do = 19, ModuloEq = 19,
Program = 20, Identifier = 20,
PipeExpr = 21, AssignableIdentifier = 21,
FunctionCall = 22, Word = 22,
DotGet = 23, IdentifierBeforeDot = 23,
Number = 24, Do = 24,
ParenExpr = 25, Program = 25,
FunctionCallOrIdentifier = 26, PipeExpr = 26,
BinOp = 27, FunctionCall = 27,
String = 28, DotGet = 28,
StringFragment = 29, Number = 29,
Interpolation = 30, ParenExpr = 30,
EscapeSeq = 31, FunctionCallOrIdentifier = 31,
Boolean = 32, BinOp = 32,
Regex = 33, String = 33,
Dict = 34, StringFragment = 34,
NamedArg = 35, Interpolation = 35,
NamedArgPrefix = 36, EscapeSeq = 36,
FunctionDef = 37, Boolean = 37,
Params = 38, Regex = 38,
colon = 39, Dict = 39,
keyword = 54, NamedArg = 40,
Underscore = 41, NamedArgPrefix = 41,
Array = 42, FunctionDef = 42,
Null = 43, Params = 43,
ConditionalOp = 44, colon = 44,
PositionalArg = 45, CatchExpr = 45,
IfExpr = 47, keyword = 68,
SingleLineThenBlock = 49, TryBlock = 47,
ThenBlock = 50, FinallyExpr = 48,
ElseIfExpr = 51, Underscore = 51,
ElseExpr = 53, Array = 52,
Assign = 55 Null = 53,
ConditionalOp = 54,
PositionalArg = 55,
TryExpr = 57,
Throw = 59,
IfExpr = 61,
SingleLineThenBlock = 63,
ThenBlock = 64,
ElseIfExpr = 65,
ElseExpr = 67,
CompoundAssign = 69,
Assign = 70

View File

@ -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', () => { describe('DotGet whitespace sensitivity', () => {
test('no whitespace - DotGet works when identifier in scope', () => { test('no whitespace - DotGet works when identifier in scope', () => {
expect('basename = 5; basename.prop').toMatchTree(` expect('basename = 5; basename.prop').toMatchTree(`

View File

@ -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
`)
})
})

View File

@ -184,6 +184,17 @@ const chooseIdentifierToken = (input: InputStream, stack: Stack): number => {
} }
const nextCh = getFullCodePoint(input, peekPos) 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 /* = */) { if (nextCh === 61 /* = */) {
// Found '=', but check if it's followed by whitespace // Found '=', but check if it's followed by whitespace
// If '=' is followed by non-whitespace (like '=cool*'), it won't be tokenized as Eq // If '=' is followed by non-whitespace (like '=cool*'), it won't be tokenized as Eq

View File

@ -39,7 +39,7 @@ export const globals = {
switch (value.type) { switch (value.type) {
case 'string': case 'array': return value.value.length case 'string': case 'array': return value.value.length
case 'dict': return value.value.size 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, identity: (v: any) => v,
// collections // 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) => { range: (start: number, end: number | null) => {
if (end === null) { if (end === null) {
end = start end = start

View File

@ -1,3 +1,5 @@
import { type Value, toValue, toNull } from 'reefvm'
export const list = { export const list = {
slice: (list: any[], start: number, end?: number) => list.slice(start, end), slice: (list: any[], start: number, end?: number) => list.slice(start, end),
map: async (list: any[], cb: Function) => { map: async (list: any[], cb: Function) => {
@ -40,9 +42,41 @@ export const list = {
return true 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 // sequence operations
reverse: (list: any[]) => list.slice().reverse(), 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), concat: (...lists: any[][]) => lists.flat(1),
flatten: (list: any[], depth: number = 1) => list.flat(depth), flatten: (list: any[], depth: number = 1) => list.flat(depth),
unique: (list: any[]) => Array.from(new Set(list)), unique: (list: any[]) => Array.from(new Set(list)),
@ -52,8 +86,14 @@ export const list = {
first: (list: any[]) => list[0] ?? null, first: (list: any[]) => list[0] ?? null,
last: (list: any[]) => list[list.length - 1] ?? null, last: (list: any[]) => list[list.length - 1] ?? null,
rest: (list: any[]) => list.slice(1), rest: (list: any[]) => list.slice(1),
take: (list: any[], n: number) => list.slice(0, n), take: (list: any[], n: number) => {
drop: (list: any[], n: number) => list.slice(n), 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], append: (list: any[], item: any) => [...list, item],
prepend: (list: any[], item: any) => [item, ...list], prepend: (list: any[], item: any) => [item, ...list],
'index-of': (list: any[], item: any) => list.indexOf(item), 'index-of': (list: any[], item: any) => list.indexOf(item),
@ -86,4 +126,13 @@ export const list = {
} }
return groups return groups
}, },
} }
// 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

View File

@ -3,12 +3,24 @@ export const math = {
floor: (n: number) => Math.floor(n), floor: (n: number) => Math.floor(n),
ceil: (n: number) => Math.ceil(n), ceil: (n: number) => Math.ceil(n),
round: (n: number) => Math.round(n), round: (n: number) => Math.round(n),
min: (...nums: number[]) => Math.min(...nums), min: (...nums: number[]) => {
max: (...nums: number[]) => Math.max(...nums), 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), 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(), 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), sign: (n: number) => Math.sign(n),
trunc: (n: number) => Math.trunc(n), trunc: (n: number) => Math.trunc(n),

View File

@ -21,7 +21,11 @@ export const str = {
'replace-all': (str: string, search: string, replacement: string) => str.replaceAll(search, replacement), '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), 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), 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-start': (str: string, length: number, pad: string = ' ') => str.padStart(length, pad),
'pad-end': (str: string, length: number, pad: string = ' ') => str.padEnd(length, pad), 'pad-end': (str: string, length: number, pad: string = ' ') => str.padEnd(length, pad),
lines: (str: string) => str.split('\n'), lines: (str: string) => str.split('\n'),

View File

@ -176,9 +176,12 @@ describe('introspection', () => {
await expect(`length 'hello'`).toEvaluateTo(5, globals) await expect(`length 'hello'`).toEvaluateTo(5, globals)
await expect(`length [1 2 3]`).toEvaluateTo(3, globals) await expect(`length [1 2 3]`).toEvaluateTo(3, globals)
await expect(`length [a=1 b=2]`).toEvaluateTo(2, 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 () => { test('inspect formats values', async () => {
@ -341,6 +344,84 @@ describe('collections', () => {
await expect(`list.index-of [1 2 3] 5`).toEvaluateTo(-1, globals) 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 () => { test('list.any? checks if any element matches', async () => {
await expect(` await expect(`
gt-three = do x: x > 3 end gt-three = do x: x > 3 end