Merge remote-tracking branch 'origin/main' into compound-assignment
This commit is contained in:
commit
0d73789a25
2
bun.lock
2
bun.lock
|
|
@ -62,7 +62,7 @@
|
||||||
|
|
||||||
"hono": ["hono@4.9.8", "", {}, "sha512-JW8Bb4RFWD9iOKxg5PbUarBYGM99IcxFl2FPBo2gSJO11jjUDqlP1Bmfyqt8Z/dGhIQ63PMA9LdcLefXyIasyg=="],
|
"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#c69b172c78853756ec8acba5bc33d93eb6a571c6", { "peerDependencies": { "typescript": "^5" } }, "c69b172c78853756ec8acba5bc33d93eb6a571c6"],
|
||||||
|
|
||||||
"style-mod": ["style-mod@4.1.2", "", {}, "sha512-wnD1HyVqpJUI2+eKZ+eo1UwghftP6yuFheBqqe+bWCotBjC2K1YnteJILRMs3SM4V/0dLEW1SC27MWP5y+mwmw=="],
|
"style-mod": ["style-mod@4.1.2", "", {}, "sha512-wnD1HyVqpJUI2+eKZ+eo1UwghftP6yuFheBqqe+bWCotBjC2K1YnteJILRMs3SM4V/0dLEW1SC27MWP5y+mwmw=="],
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -7,7 +7,7 @@
|
||||||
"dev": "bun generate-parser && bun --hot src/server/server.tsx",
|
"dev": "bun generate-parser && bun --hot src/server/server.tsx",
|
||||||
"generate-parser": "lezer-generator src/parser/shrimp.grammar --typeScript -o src/parser/shrimp.ts",
|
"generate-parser": "lezer-generator src/parser/shrimp.grammar --typeScript -o src/parser/shrimp.ts",
|
||||||
"repl": "bun generate-parser && bun bin/repl",
|
"repl": "bun generate-parser && bun bin/repl",
|
||||||
"update-reef": "cd packages/ReefVM && git pull origin main"
|
"update-reef": "rm -rf ~/.bun/install/cache/ && bun update reefvm"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@codemirror/view": "^6.38.3",
|
"@codemirror/view": "^6.38.3",
|
||||||
|
|
|
||||||
|
|
@ -18,6 +18,7 @@ import {
|
||||||
getNamedArgParts,
|
getNamedArgParts,
|
||||||
getPipeExprParts,
|
getPipeExprParts,
|
||||||
getStringParts,
|
getStringParts,
|
||||||
|
getTryExprParts,
|
||||||
} from '#compiler/utils'
|
} from '#compiler/utils'
|
||||||
|
|
||||||
const DEBUG = false
|
const DEBUG = false
|
||||||
|
|
@ -52,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
|
||||||
|
|
||||||
|
|
@ -282,7 +284,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}`
|
||||||
|
|
@ -290,9 +295,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}:`])
|
||||||
|
|
@ -346,10 +369,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
|
||||||
}
|
}
|
||||||
|
|
@ -556,4 +617,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
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
311
src/compiler/tests/exceptions.test.ts
Normal file
311
src/compiler/tests/exceptions.test.ts
Normal 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')
|
||||||
|
})
|
||||||
|
})
|
||||||
292
src/compiler/tests/native-exceptions.test.ts
Normal file
292
src/compiler/tests/native-exceptions.test.ts
Normal 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' })
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
@ -80,11 +80,11 @@ export const getCompoundAssignmentParts = (node: SyntaxNode) => {
|
||||||
|
|
||||||
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
|
||||||
)
|
)
|
||||||
|
|
@ -101,8 +101,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) => {
|
||||||
|
|
@ -217,7 +257,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) => {
|
||||||
|
|
@ -252,3 +297,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,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -51,6 +51,8 @@ item {
|
||||||
consumeToTerminator {
|
consumeToTerminator {
|
||||||
PipeExpr |
|
PipeExpr |
|
||||||
ambiguousFunctionCall |
|
ambiguousFunctionCall |
|
||||||
|
TryExpr |
|
||||||
|
Throw |
|
||||||
IfExpr |
|
IfExpr |
|
||||||
FunctionDef |
|
FunctionDef |
|
||||||
CompoundAssign |
|
CompoundAssign |
|
||||||
|
|
@ -98,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 {
|
||||||
|
|
@ -130,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 {
|
||||||
|
|
|
||||||
278
src/parser/tests/exceptions.test.ts
Normal file
278
src/parser/tests/exceptions.test.ts
Normal 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
|
||||||
|
`)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
@ -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),
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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'),
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue
Block a user