Merge remote-tracking branch 'origin/main' into array-destructuring
This commit is contained in:
commit
78849c7d36
|
|
@ -9,6 +9,7 @@ import {
|
|||
checkTreeForErrors,
|
||||
getAllChildren,
|
||||
getAssignmentParts,
|
||||
getCompoundAssignmentParts,
|
||||
getBinaryParts,
|
||||
getDotGetParts,
|
||||
getFunctionCallParts,
|
||||
|
|
@ -17,6 +18,7 @@ import {
|
|||
getNamedArgParts,
|
||||
getPipeExprParts,
|
||||
getStringParts,
|
||||
getTryExprParts,
|
||||
} from '#compiler/utils'
|
||||
|
||||
const DEBUG = false
|
||||
|
|
@ -51,6 +53,7 @@ export class Compiler {
|
|||
instructions: ProgramItem[] = []
|
||||
fnLabelCount = 0
|
||||
ifLabelCount = 0
|
||||
tryLabelCount = 0
|
||||
bytecode: Bytecode
|
||||
pipeCounter = 0
|
||||
|
||||
|
|
@ -265,6 +268,34 @@ export class Compiler {
|
|||
return instructions
|
||||
}
|
||||
|
||||
case terms.CompoundAssign: {
|
||||
const { identifier, operator, right } = getCompoundAssignmentParts(node)
|
||||
const identifierName = input.slice(identifier.from, identifier.to)
|
||||
const instructions: ProgramItem[] = []
|
||||
|
||||
// will throw if undefined
|
||||
instructions.push(['LOAD', identifierName])
|
||||
|
||||
instructions.push(...this.#compileNode(right, input))
|
||||
|
||||
const opValue = input.slice(operator.from, operator.to)
|
||||
switch (opValue) {
|
||||
case '+=': instructions.push(['ADD']); break
|
||||
case '-=': instructions.push(['SUB']); break
|
||||
case '*=': instructions.push(['MUL']); break
|
||||
case '/=': instructions.push(['DIV']); break
|
||||
case '%=': instructions.push(['MOD']); break
|
||||
default:
|
||||
throw new CompilerError(`Unknown compound operator: ${opValue}`, operator.from, operator.to)
|
||||
}
|
||||
|
||||
// DUP and store (same as regular assignment)
|
||||
instructions.push(['DUP'])
|
||||
instructions.push(['STORE', identifierName])
|
||||
|
||||
return instructions
|
||||
}
|
||||
|
||||
case terms.ParenExpr: {
|
||||
const child = node.firstChild
|
||||
if (!child) return [] // I guess it is empty parentheses?
|
||||
|
|
@ -273,7 +304,10 @@ export class Compiler {
|
|||
}
|
||||
|
||||
case terms.FunctionDef: {
|
||||
const { paramNames, bodyNodes } = getFunctionDefParts(node, input)
|
||||
const { paramNames, bodyNodes, catchVariable, catchBody, finallyBody } = getFunctionDefParts(
|
||||
node,
|
||||
input
|
||||
)
|
||||
const instructions: ProgramItem[] = []
|
||||
const functionLabel: Label = `.func_${this.fnLabelCount++}`
|
||||
const afterLabel: Label = `.after_${functionLabel}`
|
||||
|
|
@ -281,9 +315,27 @@ export class Compiler {
|
|||
instructions.push(['JUMP', afterLabel])
|
||||
|
||||
instructions.push([`${functionLabel}:`])
|
||||
bodyNodes.forEach((bodyNode) => {
|
||||
instructions.push(...this.#compileNode(bodyNode, input))
|
||||
})
|
||||
|
||||
const compileFunctionBody = () => {
|
||||
const bodyInstructions: ProgramItem[] = []
|
||||
bodyNodes.forEach((bodyNode, index) => {
|
||||
bodyInstructions.push(...this.#compileNode(bodyNode, input))
|
||||
if (index < bodyNodes.length - 1) {
|
||||
bodyInstructions.push(['POP'])
|
||||
}
|
||||
})
|
||||
return bodyInstructions
|
||||
}
|
||||
|
||||
if (catchVariable || finallyBody) {
|
||||
// If function has catch or finally, wrap body in try/catch/finally
|
||||
instructions.push(
|
||||
...this.#compileTryCatchFinally(compileFunctionBody, catchVariable, catchBody, finallyBody, input)
|
||||
)
|
||||
} else {
|
||||
instructions.push(...compileFunctionBody())
|
||||
}
|
||||
|
||||
instructions.push(['RETURN'])
|
||||
|
||||
instructions.push([`${afterLabel}:`])
|
||||
|
|
@ -337,10 +389,48 @@ export class Compiler {
|
|||
}
|
||||
|
||||
case terms.ThenBlock:
|
||||
case terms.SingleLineThenBlock: {
|
||||
const instructions = getAllChildren(node)
|
||||
.map((child) => this.#compileNode(child, input))
|
||||
.flat()
|
||||
case terms.SingleLineThenBlock:
|
||||
case terms.TryBlock: {
|
||||
const children = getAllChildren(node)
|
||||
const instructions: ProgramItem[] = []
|
||||
|
||||
children.forEach((child, index) => {
|
||||
instructions.push(...this.#compileNode(child, input))
|
||||
// keep only the last expression's value
|
||||
if (index < children.length - 1) {
|
||||
instructions.push(['POP'])
|
||||
}
|
||||
})
|
||||
|
||||
return instructions
|
||||
}
|
||||
|
||||
case terms.TryExpr: {
|
||||
const { tryBlock, catchVariable, catchBody, finallyBody } = getTryExprParts(node, input)
|
||||
|
||||
return this.#compileTryCatchFinally(
|
||||
() => this.#compileNode(tryBlock, input),
|
||||
catchVariable,
|
||||
catchBody,
|
||||
finallyBody,
|
||||
input
|
||||
)
|
||||
}
|
||||
|
||||
case terms.Throw: {
|
||||
const children = getAllChildren(node)
|
||||
const [_throwKeyword, expression] = children
|
||||
if (!expression) {
|
||||
throw new CompilerError(
|
||||
`Throw expected expression, got ${children.length} children`,
|
||||
node.from,
|
||||
node.to
|
||||
)
|
||||
}
|
||||
|
||||
const instructions: ProgramItem[] = []
|
||||
instructions.push(...this.#compileNode(expression, input))
|
||||
instructions.push(['THROW'])
|
||||
|
||||
return instructions
|
||||
}
|
||||
|
|
@ -547,4 +637,52 @@ export class Compiler {
|
|||
)
|
||||
}
|
||||
}
|
||||
|
||||
#compileTryCatchFinally(
|
||||
compileTryBody: () => ProgramItem[],
|
||||
catchVariable: string | undefined,
|
||||
catchBody: SyntaxNode | undefined,
|
||||
finallyBody: SyntaxNode | undefined,
|
||||
input: string
|
||||
): ProgramItem[] {
|
||||
const instructions: ProgramItem[] = []
|
||||
this.tryLabelCount++
|
||||
const catchLabel: Label = `.catch_${this.tryLabelCount}`
|
||||
const finallyLabel: Label = finallyBody ? `.finally_${this.tryLabelCount}` : (null as any)
|
||||
const endLabel: Label = `.end_try_${this.tryLabelCount}`
|
||||
|
||||
instructions.push(['PUSH_TRY', catchLabel])
|
||||
instructions.push(...compileTryBody())
|
||||
instructions.push(['POP_TRY'])
|
||||
instructions.push(['JUMP', finallyBody ? finallyLabel : endLabel])
|
||||
|
||||
// catch block
|
||||
instructions.push([`${catchLabel}:`])
|
||||
if (catchBody && catchVariable) {
|
||||
instructions.push(['STORE', catchVariable])
|
||||
const catchInstructions = this.#compileNode(catchBody, input)
|
||||
instructions.push(...catchInstructions)
|
||||
instructions.push(['JUMP', finallyBody ? finallyLabel : endLabel])
|
||||
} else {
|
||||
// no catch block
|
||||
if (finallyBody) {
|
||||
instructions.push(['JUMP', finallyLabel])
|
||||
} else {
|
||||
instructions.push(['THROW'])
|
||||
}
|
||||
}
|
||||
|
||||
// finally block
|
||||
if (finallyBody) {
|
||||
instructions.push([`${finallyLabel}:`])
|
||||
const finallyInstructions = this.#compileNode(finallyBody, input)
|
||||
instructions.push(...finallyInstructions)
|
||||
// finally doesn't return a value
|
||||
instructions.push(['POP'])
|
||||
}
|
||||
|
||||
instructions.push([`${endLabel}:`])
|
||||
|
||||
return instructions
|
||||
}
|
||||
}
|
||||
|
|
|
|||
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' })
|
||||
})
|
||||
})
|
||||
|
|
@ -65,13 +65,34 @@ export const getAssignmentParts = (node: SyntaxNode) => {
|
|||
return { identifier: left, right }
|
||||
}
|
||||
|
||||
export const getCompoundAssignmentParts = (node: SyntaxNode) => {
|
||||
const children = getAllChildren(node)
|
||||
const [left, operator, right] = children
|
||||
|
||||
if (!left || left.type.id !== terms.AssignableIdentifier) {
|
||||
throw new CompilerError(
|
||||
`CompoundAssign left child must be an AssignableIdentifier, got ${left ? left.type.name : 'none'}`,
|
||||
node.from,
|
||||
node.to
|
||||
)
|
||||
} else if (!operator || !right) {
|
||||
throw new CompilerError(
|
||||
`CompoundAssign expected 3 children, got ${children.length}`,
|
||||
node.from,
|
||||
node.to
|
||||
)
|
||||
}
|
||||
|
||||
return { identifier: left, operator, right }
|
||||
}
|
||||
|
||||
export const getFunctionDefParts = (node: SyntaxNode, input: string) => {
|
||||
const children = getAllChildren(node)
|
||||
const [fnKeyword, paramsNode, colon, ...bodyNodes] = children
|
||||
const [fnKeyword, paramsNode, colon, ...rest] = children
|
||||
|
||||
if (!fnKeyword || !paramsNode || !colon || !bodyNodes) {
|
||||
if (!fnKeyword || !paramsNode || !colon || !rest) {
|
||||
throw new CompilerError(
|
||||
`FunctionDef expected 5 children, got ${children.length}`,
|
||||
`FunctionDef expected at least 4 children, got ${children.length}`,
|
||||
node.from,
|
||||
node.to
|
||||
)
|
||||
|
|
@ -88,8 +109,48 @@ export const getFunctionDefParts = (node: SyntaxNode, input: string) => {
|
|||
return input.slice(param.from, param.to)
|
||||
})
|
||||
|
||||
const bodyWithoutEnd = bodyNodes.slice(0, -1)
|
||||
return { paramNames, bodyNodes: bodyWithoutEnd }
|
||||
// Separate body nodes from catch/finally/end
|
||||
const bodyNodes: SyntaxNode[] = []
|
||||
let catchExpr: SyntaxNode | undefined
|
||||
let catchVariable: string | undefined
|
||||
let catchBody: SyntaxNode | undefined
|
||||
let finallyExpr: SyntaxNode | undefined
|
||||
let finallyBody: SyntaxNode | undefined
|
||||
|
||||
for (const child of rest) {
|
||||
if (child.type.id === terms.CatchExpr) {
|
||||
catchExpr = child
|
||||
const catchChildren = getAllChildren(child)
|
||||
const [_catchKeyword, identifierNode, _colon, body] = catchChildren
|
||||
if (!identifierNode || !body) {
|
||||
throw new CompilerError(
|
||||
`CatchExpr expected identifier and body, got ${catchChildren.length} children`,
|
||||
child.from,
|
||||
child.to
|
||||
)
|
||||
}
|
||||
catchVariable = input.slice(identifierNode.from, identifierNode.to)
|
||||
catchBody = body
|
||||
} else if (child.type.id === terms.FinallyExpr) {
|
||||
finallyExpr = child
|
||||
const finallyChildren = getAllChildren(child)
|
||||
const [_finallyKeyword, _colon, body] = finallyChildren
|
||||
if (!body) {
|
||||
throw new CompilerError(
|
||||
`FinallyExpr expected body, got ${finallyChildren.length} children`,
|
||||
child.from,
|
||||
child.to
|
||||
)
|
||||
}
|
||||
finallyBody = body
|
||||
} else if (child.type.name === 'keyword' && input.slice(child.from, child.to) === 'end') {
|
||||
// Skip the end keyword
|
||||
} else {
|
||||
bodyNodes.push(child)
|
||||
}
|
||||
}
|
||||
|
||||
return { paramNames, bodyNodes, catchVariable, catchBody, finallyBody }
|
||||
}
|
||||
|
||||
export const getFunctionCallParts = (node: SyntaxNode, input: string) => {
|
||||
|
|
@ -204,7 +265,12 @@ export const getStringParts = (node: SyntaxNode, input: string) => {
|
|||
}
|
||||
})
|
||||
|
||||
return { parts, hasInterpolation: parts.length > 0 }
|
||||
// hasInterpolation means the string has interpolation ($var) or escape sequences (\n)
|
||||
// A simple string like 'hello' has one StringFragment but no interpolation
|
||||
const hasInterpolation = parts.some(
|
||||
(p) => p.type.id === terms.Interpolation || p.type.id === terms.EscapeSeq
|
||||
)
|
||||
return { parts, hasInterpolation }
|
||||
}
|
||||
|
||||
export const getDotGetParts = (node: SyntaxNode, input: string) => {
|
||||
|
|
@ -239,3 +305,62 @@ export const getDotGetParts = (node: SyntaxNode, input: string) => {
|
|||
|
||||
return { objectName, property }
|
||||
}
|
||||
|
||||
export const getTryExprParts = (node: SyntaxNode, input: string) => {
|
||||
const children = getAllChildren(node)
|
||||
|
||||
// First child is always 'try' keyword, second is colon, third is TryBlock or statement
|
||||
const [tryKeyword, _colon, tryBlock, ...rest] = children
|
||||
|
||||
if (!tryKeyword || !tryBlock) {
|
||||
throw new CompilerError(
|
||||
`TryExpr expected at least 3 children, got ${children.length}`,
|
||||
node.from,
|
||||
node.to
|
||||
)
|
||||
}
|
||||
|
||||
let catchExpr: SyntaxNode | undefined
|
||||
let catchVariable: string | undefined
|
||||
let catchBody: SyntaxNode | undefined
|
||||
let finallyExpr: SyntaxNode | undefined
|
||||
let finallyBody: SyntaxNode | undefined
|
||||
|
||||
rest.forEach((child) => {
|
||||
if (child.type.id === terms.CatchExpr) {
|
||||
catchExpr = child
|
||||
const catchChildren = getAllChildren(child)
|
||||
const [_catchKeyword, identifierNode, _colon, body] = catchChildren
|
||||
if (!identifierNode || !body) {
|
||||
throw new CompilerError(
|
||||
`CatchExpr expected identifier and body, got ${catchChildren.length} children`,
|
||||
child.from,
|
||||
child.to
|
||||
)
|
||||
}
|
||||
catchVariable = input.slice(identifierNode.from, identifierNode.to)
|
||||
catchBody = body
|
||||
} else if (child.type.id === terms.FinallyExpr) {
|
||||
finallyExpr = child
|
||||
const finallyChildren = getAllChildren(child)
|
||||
const [_finallyKeyword, _colon, body] = finallyChildren
|
||||
if (!body) {
|
||||
throw new CompilerError(
|
||||
`FinallyExpr expected body, got ${finallyChildren.length} children`,
|
||||
child.from,
|
||||
child.to
|
||||
)
|
||||
}
|
||||
finallyBody = body
|
||||
}
|
||||
})
|
||||
|
||||
return {
|
||||
tryBlock,
|
||||
catchExpr,
|
||||
catchVariable,
|
||||
catchBody,
|
||||
finallyExpr,
|
||||
finallyBody,
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -10,7 +10,14 @@ const operators: Array<Operator> = [
|
|||
{ str: '!=', tokenName: 'Neq' },
|
||||
{ str: '==', tokenName: 'EqEq' },
|
||||
|
||||
// // Single-char operators
|
||||
// Compound assignment operators (must come before single-char operators)
|
||||
{ str: '+=', tokenName: 'PlusEq' },
|
||||
{ str: '-=', tokenName: 'MinusEq' },
|
||||
{ str: '*=', tokenName: 'StarEq' },
|
||||
{ str: '/=', tokenName: 'SlashEq' },
|
||||
{ str: '%=', tokenName: 'ModuloEq' },
|
||||
|
||||
// Single-char operators
|
||||
{ str: '*', tokenName: 'Star' },
|
||||
{ str: '=', tokenName: 'Eq' },
|
||||
{ str: '/', tokenName: 'Slash' },
|
||||
|
|
|
|||
|
|
@ -6,7 +6,7 @@
|
|||
|
||||
@top Program { item* }
|
||||
|
||||
@external tokens operatorTokenizer from "./operatorTokenizer" { Star, Slash, Plus, Minus, And, Or, Eq, EqEq, Neq, Lt, Lte, Gt, Gte, Modulo }
|
||||
@external tokens operatorTokenizer from "./operatorTokenizer" { Star, Slash, Plus, Minus, And, Or, Eq, EqEq, Neq, Lt, Lte, Gt, Gte, Modulo, PlusEq, MinusEq, StarEq, SlashEq, ModuloEq }
|
||||
|
||||
@tokens {
|
||||
@precedence { Number Regex }
|
||||
|
|
@ -51,8 +51,11 @@ item {
|
|||
consumeToTerminator {
|
||||
PipeExpr |
|
||||
ambiguousFunctionCall |
|
||||
TryExpr |
|
||||
Throw |
|
||||
IfExpr |
|
||||
FunctionDef |
|
||||
CompoundAssign |
|
||||
Assign |
|
||||
BinOp |
|
||||
ConditionalOp |
|
||||
|
|
@ -97,11 +100,11 @@ FunctionDef {
|
|||
}
|
||||
|
||||
singleLineFunctionDef {
|
||||
Do Params colon consumeToTerminator @specialize[@name=keyword]<Identifier, "end">
|
||||
Do Params colon consumeToTerminator CatchExpr? FinallyExpr? @specialize[@name=keyword]<Identifier, "end">
|
||||
}
|
||||
|
||||
multilineFunctionDef {
|
||||
Do Params colon newlineOrSemicolon block @specialize[@name=keyword]<Identifier, "end">
|
||||
Do Params colon newlineOrSemicolon block CatchExpr? FinallyExpr? @specialize[@name=keyword]<Identifier, "end">
|
||||
}
|
||||
|
||||
IfExpr {
|
||||
|
|
@ -132,6 +135,34 @@ SingleLineThenBlock {
|
|||
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 {
|
||||
expression !comparison EqEq expression |
|
||||
expression !comparison Neq expression |
|
||||
|
|
@ -151,6 +182,10 @@ Assign {
|
|||
(AssignableIdentifier | Array) Eq consumeToTerminator
|
||||
}
|
||||
|
||||
CompoundAssign {
|
||||
AssignableIdentifier (PlusEq | MinusEq | StarEq | SlashEq | ModuloEq) consumeToTerminator
|
||||
}
|
||||
|
||||
BinOp {
|
||||
expression !multiplicative Modulo expression |
|
||||
(expression | BinOp) !multiplicative Star (expression | BinOp) |
|
||||
|
|
|
|||
|
|
@ -14,40 +14,51 @@ export const
|
|||
Gt = 12,
|
||||
Gte = 13,
|
||||
Modulo = 14,
|
||||
Identifier = 15,
|
||||
AssignableIdentifier = 16,
|
||||
Word = 17,
|
||||
IdentifierBeforeDot = 18,
|
||||
Do = 19,
|
||||
Program = 20,
|
||||
PipeExpr = 21,
|
||||
FunctionCall = 22,
|
||||
DotGet = 23,
|
||||
Number = 24,
|
||||
ParenExpr = 25,
|
||||
FunctionCallOrIdentifier = 26,
|
||||
BinOp = 27,
|
||||
String = 28,
|
||||
StringFragment = 29,
|
||||
Interpolation = 30,
|
||||
EscapeSeq = 31,
|
||||
Boolean = 32,
|
||||
Regex = 33,
|
||||
Dict = 34,
|
||||
NamedArg = 35,
|
||||
NamedArgPrefix = 36,
|
||||
FunctionDef = 37,
|
||||
Params = 38,
|
||||
colon = 39,
|
||||
keyword = 54,
|
||||
Underscore = 41,
|
||||
Array = 42,
|
||||
Null = 43,
|
||||
ConditionalOp = 44,
|
||||
PositionalArg = 45,
|
||||
IfExpr = 47,
|
||||
SingleLineThenBlock = 49,
|
||||
ThenBlock = 50,
|
||||
ElseIfExpr = 51,
|
||||
ElseExpr = 53,
|
||||
Assign = 55
|
||||
PlusEq = 15,
|
||||
MinusEq = 16,
|
||||
StarEq = 17,
|
||||
SlashEq = 18,
|
||||
ModuloEq = 19,
|
||||
Identifier = 20,
|
||||
AssignableIdentifier = 21,
|
||||
Word = 22,
|
||||
IdentifierBeforeDot = 23,
|
||||
Do = 24,
|
||||
Program = 25,
|
||||
PipeExpr = 26,
|
||||
FunctionCall = 27,
|
||||
DotGet = 28,
|
||||
Number = 29,
|
||||
ParenExpr = 30,
|
||||
FunctionCallOrIdentifier = 31,
|
||||
BinOp = 32,
|
||||
String = 33,
|
||||
StringFragment = 34,
|
||||
Interpolation = 35,
|
||||
EscapeSeq = 36,
|
||||
Boolean = 37,
|
||||
Regex = 38,
|
||||
Dict = 39,
|
||||
NamedArg = 40,
|
||||
NamedArgPrefix = 41,
|
||||
FunctionDef = 42,
|
||||
Params = 43,
|
||||
colon = 44,
|
||||
CatchExpr = 45,
|
||||
keyword = 68,
|
||||
TryBlock = 47,
|
||||
FinallyExpr = 48,
|
||||
Underscore = 51,
|
||||
Array = 52,
|
||||
Null = 53,
|
||||
ConditionalOp = 54,
|
||||
PositionalArg = 55,
|
||||
TryExpr = 57,
|
||||
Throw = 59,
|
||||
IfExpr = 61,
|
||||
SingleLineThenBlock = 63,
|
||||
ThenBlock = 64,
|
||||
ElseIfExpr = 65,
|
||||
ElseExpr = 67,
|
||||
CompoundAssign = 69,
|
||||
Assign = 70
|
||||
|
|
|
|||
|
|
@ -532,6 +532,72 @@ describe('Assign', () => {
|
|||
})
|
||||
})
|
||||
|
||||
describe('CompoundAssign', () => {
|
||||
test('parses += operator', () => {
|
||||
expect('x += 5').toMatchTree(`
|
||||
CompoundAssign
|
||||
AssignableIdentifier x
|
||||
PlusEq +=
|
||||
Number 5`)
|
||||
})
|
||||
|
||||
test('parses -= operator', () => {
|
||||
expect('count -= 1').toMatchTree(`
|
||||
CompoundAssign
|
||||
AssignableIdentifier count
|
||||
MinusEq -=
|
||||
Number 1`)
|
||||
})
|
||||
|
||||
test('parses *= operator', () => {
|
||||
expect('total *= 2').toMatchTree(`
|
||||
CompoundAssign
|
||||
AssignableIdentifier total
|
||||
StarEq *=
|
||||
Number 2`)
|
||||
})
|
||||
|
||||
test('parses /= operator', () => {
|
||||
expect('value /= 10').toMatchTree(`
|
||||
CompoundAssign
|
||||
AssignableIdentifier value
|
||||
SlashEq /=
|
||||
Number 10`)
|
||||
})
|
||||
|
||||
test('parses %= operator', () => {
|
||||
expect('remainder %= 3').toMatchTree(`
|
||||
CompoundAssign
|
||||
AssignableIdentifier remainder
|
||||
ModuloEq %=
|
||||
Number 3`)
|
||||
})
|
||||
|
||||
test('parses compound assignment with expression', () => {
|
||||
expect('x += 1 + 2').toMatchTree(`
|
||||
CompoundAssign
|
||||
AssignableIdentifier x
|
||||
PlusEq +=
|
||||
BinOp
|
||||
Number 1
|
||||
Plus +
|
||||
Number 2`)
|
||||
})
|
||||
|
||||
test('parses compound assignment with function call', () => {
|
||||
expect('total += add 5 3').toMatchTree(`
|
||||
CompoundAssign
|
||||
AssignableIdentifier total
|
||||
PlusEq +=
|
||||
FunctionCall
|
||||
Identifier add
|
||||
PositionalArg
|
||||
Number 5
|
||||
PositionalArg
|
||||
Number 3`)
|
||||
})
|
||||
})
|
||||
|
||||
describe('DotGet whitespace sensitivity', () => {
|
||||
test('no whitespace - DotGet works when identifier in scope', () => {
|
||||
expect('basename = 5; basename.prop').toMatchTree(`
|
||||
|
|
|
|||
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
|
||||
`)
|
||||
})
|
||||
})
|
||||
|
|
@ -184,6 +184,17 @@ const chooseIdentifierToken = (input: InputStream, stack: Stack): number => {
|
|||
}
|
||||
|
||||
const nextCh = getFullCodePoint(input, peekPos)
|
||||
const nextCh2 = getFullCodePoint(input, peekPos + 1)
|
||||
|
||||
// Check for compound assignment operators: +=, -=, *=, /=, %=
|
||||
if ([43/* + */, 45/* - */, 42/* * */, 47/* / */, 37/* % */].includes(nextCh) && nextCh2 === 61/* = */) {
|
||||
// Found compound operator, check if it's followed by whitespace
|
||||
const charAfterOp = getFullCodePoint(input, peekPos + 2)
|
||||
if (isWhiteSpace(charAfterOp) || charAfterOp === -1 /* EOF */) {
|
||||
return AssignableIdentifier
|
||||
}
|
||||
}
|
||||
|
||||
if (nextCh === 61 /* = */) {
|
||||
// Found '=', but check if it's followed by whitespace
|
||||
// If '=' is followed by non-whitespace (like '=cool*'), it won't be tokenized as Eq
|
||||
|
|
|
|||
|
|
@ -39,7 +39,7 @@ export const globals = {
|
|||
switch (value.type) {
|
||||
case 'string': case 'array': return value.value.length
|
||||
case 'dict': return value.value.size
|
||||
default: return 0
|
||||
default: throw new Error(`length: expected string, array, or dict, got ${value.type}`)
|
||||
}
|
||||
},
|
||||
|
||||
|
|
@ -65,7 +65,24 @@ export const globals = {
|
|||
identity: (v: any) => v,
|
||||
|
||||
// collections
|
||||
at: (collection: any, index: number | string) => collection[index],
|
||||
at: (collection: any, index: number | string) => {
|
||||
const value = toValue(collection)
|
||||
if (value.type === 'string' || value.type === 'array') {
|
||||
const idx = typeof index === 'number' ? index : parseInt(index as string)
|
||||
if (idx < 0 || idx >= value.value.length) {
|
||||
throw new Error(`at: index ${idx} out of bounds for ${value.type} of length ${value.value.length}`)
|
||||
}
|
||||
return value.value[idx]
|
||||
} else if (value.type === 'dict') {
|
||||
const key = String(index)
|
||||
if (!value.value.has(key)) {
|
||||
throw new Error(`at: key '${key}' not found in dict`)
|
||||
}
|
||||
return value.value.get(key)
|
||||
} else {
|
||||
throw new Error(`at: expected string, array, or dict, got ${value.type}`)
|
||||
}
|
||||
},
|
||||
range: (start: number, end: number | null) => {
|
||||
if (end === null) {
|
||||
end = start
|
||||
|
|
|
|||
|
|
@ -1,3 +1,5 @@
|
|||
import { type Value, toValue, toNull } from 'reefvm'
|
||||
|
||||
export const list = {
|
||||
slice: (list: any[], start: number, end?: number) => list.slice(start, end),
|
||||
map: async (list: any[], cb: Function) => {
|
||||
|
|
@ -40,9 +42,41 @@ export const list = {
|
|||
return true
|
||||
},
|
||||
|
||||
// mutating
|
||||
push: (list: Value, item: Value) => {
|
||||
if (list.type !== 'array') return toNull()
|
||||
return toValue(list.value.push(item))
|
||||
},
|
||||
pop: (list: Value) => {
|
||||
if (list.type !== 'array') return toNull()
|
||||
return toValue(list.value.pop())
|
||||
},
|
||||
shift: (list: Value) => {
|
||||
if (list.type !== 'array') return toNull()
|
||||
return toValue(list.value.shift())
|
||||
},
|
||||
unshift: (list: Value, item: Value) => {
|
||||
if (list.type !== 'array') return toNull()
|
||||
return toValue(list.value.unshift(item))
|
||||
},
|
||||
splice: (list: Value, start: Value, deleteCount: Value, ...items: Value[]) => {
|
||||
const realList = list.value as any[]
|
||||
const realStart = start.value as number
|
||||
const realDeleteCount = deleteCount.value as number
|
||||
const realItems = items.map(item => item.value)
|
||||
return toValue(realList.splice(realStart, realDeleteCount, ...realItems))
|
||||
},
|
||||
|
||||
// sequence operations
|
||||
reverse: (list: any[]) => list.slice().reverse(),
|
||||
sort: (list: any[], cb?: (a: any, b: any) => number) => list.slice().sort(cb),
|
||||
sort: async (list: any[], cb?: (a: any, b: any) => number) => {
|
||||
const arr = [...list]
|
||||
if (!cb) return arr.sort()
|
||||
for (let i = 0; i < arr.length; i++)
|
||||
for (let j = i + 1; j < arr.length; j++)
|
||||
if ((await cb(arr[i], arr[j])) > 0) [arr[i], arr[j]] = [arr[j], arr[i]]
|
||||
return arr
|
||||
},
|
||||
concat: (...lists: any[][]) => lists.flat(1),
|
||||
flatten: (list: any[], depth: number = 1) => list.flat(depth),
|
||||
unique: (list: any[]) => Array.from(new Set(list)),
|
||||
|
|
@ -52,8 +86,14 @@ export const list = {
|
|||
first: (list: any[]) => list[0] ?? null,
|
||||
last: (list: any[]) => list[list.length - 1] ?? null,
|
||||
rest: (list: any[]) => list.slice(1),
|
||||
take: (list: any[], n: number) => list.slice(0, n),
|
||||
drop: (list: any[], n: number) => list.slice(n),
|
||||
take: (list: any[], n: number) => {
|
||||
if (n < 0) throw new Error(`take: count must be non-negative, got ${n}`)
|
||||
return list.slice(0, n)
|
||||
},
|
||||
drop: (list: any[], n: number) => {
|
||||
if (n < 0) throw new Error(`drop: count must be non-negative, got ${n}`)
|
||||
return list.slice(n)
|
||||
},
|
||||
append: (list: any[], item: any) => [...list, item],
|
||||
prepend: (list: any[], item: any) => [item, ...list],
|
||||
'index-of': (list: any[], item: any) => list.indexOf(item),
|
||||
|
|
@ -87,3 +127,12 @@ export const list = {
|
|||
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),
|
||||
ceil: (n: number) => Math.ceil(n),
|
||||
round: (n: number) => Math.round(n),
|
||||
min: (...nums: number[]) => Math.min(...nums),
|
||||
max: (...nums: number[]) => Math.max(...nums),
|
||||
min: (...nums: number[]) => {
|
||||
if (nums.length === 0) throw new Error('min: expected at least one argument')
|
||||
return Math.min(...nums)
|
||||
},
|
||||
max: (...nums: number[]) => {
|
||||
if (nums.length === 0) throw new Error('max: expected at least one argument')
|
||||
return Math.max(...nums)
|
||||
},
|
||||
pow: (base: number, exp: number) => Math.pow(base, exp),
|
||||
sqrt: (n: number) => Math.sqrt(n),
|
||||
sqrt: (n: number) => {
|
||||
if (n < 0) throw new Error(`sqrt: cannot take square root of negative number ${n}`)
|
||||
return Math.sqrt(n)
|
||||
},
|
||||
random: () => Math.random(),
|
||||
clamp: (n: number, min: number, max: number) => Math.min(Math.max(n, min), max),
|
||||
clamp: (n: number, min: number, max: number) => {
|
||||
if (min > max) throw new Error(`clamp: min (${min}) must be less than or equal to max (${max})`)
|
||||
return Math.min(Math.max(n, min), max)
|
||||
},
|
||||
sign: (n: number) => Math.sign(n),
|
||||
trunc: (n: number) => Math.trunc(n),
|
||||
|
||||
|
|
|
|||
|
|
@ -21,7 +21,11 @@ export const str = {
|
|||
'replace-all': (str: string, search: string, replacement: string) => str.replaceAll(search, replacement),
|
||||
slice: (str: string, start: number, end?: number | null) => str.slice(start, end ?? undefined),
|
||||
substring: (str: string, start: number, end?: number | null) => str.substring(start, end ?? undefined),
|
||||
repeat: (str: string, count: number) => str.repeat(count),
|
||||
repeat: (str: string, count: number) => {
|
||||
if (count < 0) throw new Error(`repeat: count must be non-negative, got ${count}`)
|
||||
if (!Number.isInteger(count)) throw new Error(`repeat: count must be an integer, got ${count}`)
|
||||
return str.repeat(count)
|
||||
},
|
||||
'pad-start': (str: string, length: number, pad: string = ' ') => str.padStart(length, pad),
|
||||
'pad-end': (str: string, length: number, pad: string = ' ') => str.padEnd(length, pad),
|
||||
lines: (str: string) => str.split('\n'),
|
||||
|
|
|
|||
|
|
@ -176,9 +176,12 @@ describe('introspection', () => {
|
|||
await expect(`length 'hello'`).toEvaluateTo(5, globals)
|
||||
await expect(`length [1 2 3]`).toEvaluateTo(3, globals)
|
||||
await expect(`length [a=1 b=2]`).toEvaluateTo(2, globals)
|
||||
await expect(`length 42`).toEvaluateTo(0, globals)
|
||||
await expect(`length true`).toEvaluateTo(0, globals)
|
||||
await expect(`length null`).toEvaluateTo(0, globals)
|
||||
})
|
||||
|
||||
test('length throws on invalid types', async () => {
|
||||
await expect(`try: length 42 catch e: 'error' end`).toEvaluateTo('error', globals)
|
||||
await expect(`try: length true catch e: 'error' end`).toEvaluateTo('error', globals)
|
||||
await expect(`try: length null catch e: 'error' end`).toEvaluateTo('error', globals)
|
||||
})
|
||||
|
||||
test('inspect formats values', async () => {
|
||||
|
|
@ -341,6 +344,84 @@ describe('collections', () => {
|
|||
await expect(`list.index-of [1 2 3] 5`).toEvaluateTo(-1, globals)
|
||||
})
|
||||
|
||||
test('list.push adds to end and mutates array', async () => {
|
||||
await expect(`arr = [1 2]; list.push arr 3; arr`).toEvaluateTo([1, 2, 3], globals)
|
||||
})
|
||||
|
||||
test('list.push returns the size of the array', async () => {
|
||||
await expect(`arr = [1 2]; arr | list.push 3`).toEvaluateTo(3, globals)
|
||||
})
|
||||
|
||||
test('list.pop removes from end and mutates array', async () => {
|
||||
await expect(`arr = [1 2 3]; list.pop arr; arr`).toEvaluateTo([1, 2], globals)
|
||||
})
|
||||
|
||||
test('list.pop returns removed element', async () => {
|
||||
await expect(`list.pop [1 2 3]`).toEvaluateTo(3, globals)
|
||||
})
|
||||
|
||||
test('list.pop returns null for empty array', async () => {
|
||||
await expect(`list.pop []`).toEvaluateTo(null, globals)
|
||||
})
|
||||
|
||||
test('list.shift removes from start and mutates array', async () => {
|
||||
await expect(`arr = [1 2 3]; list.shift arr; arr`).toEvaluateTo([2, 3], globals)
|
||||
})
|
||||
|
||||
test('list.shift returns removed element', async () => {
|
||||
await expect(`list.shift [1 2 3]`).toEvaluateTo(1, globals)
|
||||
})
|
||||
|
||||
test('list.shift returns null for empty array', async () => {
|
||||
await expect(`list.shift []`).toEvaluateTo(null, globals)
|
||||
})
|
||||
|
||||
test('list.unshift adds to start and mutates array', async () => {
|
||||
await expect(`arr = [2 3]; list.unshift arr 1; arr`).toEvaluateTo([1, 2, 3], globals)
|
||||
})
|
||||
|
||||
test('list.unshift returns the length of the array', async () => {
|
||||
await expect(`arr = [2 3]; arr | list.unshift 1`).toEvaluateTo(3, globals)
|
||||
})
|
||||
|
||||
test('list.splice removes elements and mutates array', async () => {
|
||||
await expect(`arr = [1 2 3 4 5]; list.splice arr 1 2; arr`).toEvaluateTo([1, 4, 5], globals)
|
||||
})
|
||||
|
||||
test('list.splice returns removed elements', async () => {
|
||||
await expect(`list.splice [1 2 3 4 5] 1 2`).toEvaluateTo([2, 3], globals)
|
||||
})
|
||||
|
||||
test('list.splice from start', async () => {
|
||||
await expect(`list.splice [1 2 3 4 5] 0 2`).toEvaluateTo([1, 2], globals)
|
||||
})
|
||||
|
||||
test('list.splice to end', async () => {
|
||||
await expect(`arr = [1 2 3 4 5]; list.splice arr 3 2; arr`).toEvaluateTo([1, 2, 3], globals)
|
||||
})
|
||||
|
||||
test('list.sort with no callback sorts ascending', async () => {
|
||||
await expect(`list.sort [3 1 4 1 5] null`).toEvaluateTo([1, 1, 3, 4, 5], globals)
|
||||
})
|
||||
|
||||
test('list.sort with callback sorts using comparator', async () => {
|
||||
await expect(`
|
||||
desc = do a b:
|
||||
b - a
|
||||
end
|
||||
list.sort [3 1 4 1 5] desc
|
||||
`).toEvaluateTo([5, 4, 3, 1, 1], globals)
|
||||
})
|
||||
|
||||
test('list.sort with callback for strings by length', async () => {
|
||||
await expect(`
|
||||
by-length = do a b:
|
||||
(length a) - (length b)
|
||||
end
|
||||
list.sort ['cat' 'a' 'dog' 'elephant'] by-length
|
||||
`).toEvaluateTo(['a', 'cat', 'dog', 'elephant'], globals)
|
||||
})
|
||||
|
||||
test('list.any? checks if any element matches', async () => {
|
||||
await expect(`
|
||||
gt-three = do x: x > 3 end
|
||||
|
|
|
|||
Loading…
Reference in New Issue
Block a user