Compare commits

...

10 Commits
main ... bang

20 changed files with 1538 additions and 84 deletions

View File

@ -2,9 +2,11 @@
import { Compiler } from '../src/compiler/compiler'
import { colors, globals } from '../src/prelude'
import { parser } from '../src/parser/shrimp'
import { treeToString } from '../src/utils/tree'
import { VM, fromValue, bytecodeToString } from 'reefvm'
import { readFileSync, writeFileSync, mkdirSync } from 'fs'
import { randomUUID } from "crypto"
import { randomUUID } from 'crypto'
import { spawn } from 'child_process'
import { join } from 'path'
@ -32,6 +34,17 @@ async function compileFile(filePath: string) {
}
}
async function parseFile(filePath: string) {
try {
const code = readFileSync(filePath, 'utf-8')
const tree = parser.parse(code)
return treeToString(tree, code)
} catch (error: any) {
console.error(`${colors.red}Error:${colors.reset} ${error.message}`)
process.exit(1)
}
}
function showHelp() {
console.log(`${colors.bright}${colors.magenta}🦐 Shrimp${colors.reset} is a scripting language in a shell.
@ -39,6 +52,7 @@ ${colors.bright}Usage:${colors.reset} shrimp <command> [...args]
${colors.bright}Commands:${colors.reset}
${colors.cyan}run ${colors.yellow}./my-file.sh${colors.reset} Execute a file with Shrimp
${colors.cyan}parse ${colors.yellow}./my-file.sh${colors.reset} Print parse tree for Shrimp file
${colors.cyan}bytecode ${colors.yellow}./my-file.sh${colors.reset} Print bytecode for Shrimp file
${colors.cyan}eval ${colors.yellow}'some code'${colors.reset} Evaluate a line of Shrimp code
${colors.cyan}repl${colors.reset} Start REPL
@ -102,6 +116,16 @@ async function main() {
return
}
if (['parse', '-parse', '--parse', '-p'].includes(command)) {
const file = args[1]
if (!file) {
console.log(`${colors.bright}usage: shrimp parse <file>${colors.reset}`)
process.exit(1)
}
console.log(await parseFile(file))
return
}
if (['run', '-run', '--run', '-r'].includes(command)) {
const file = args[1]
if (!file) {

View File

@ -62,7 +62,7 @@
"hono": ["hono@4.9.8", "", {}, "sha512-JW8Bb4RFWD9iOKxg5PbUarBYGM99IcxFl2FPBo2gSJO11jjUDqlP1Bmfyqt8Z/dGhIQ63PMA9LdcLefXyIasyg=="],
"reefvm": ["reefvm@git+https://git.nose.space/defunkt/reefvm#c69b172c78853756ec8acba5bc33d93eb6a571c6", { "peerDependencies": { "typescript": "^5" } }, "c69b172c78853756ec8acba5bc33d93eb6a571c6"],
"reefvm": ["reefvm@git+https://git.nose.space/defunkt/reefvm#4b2fd615546cc4dd1cacd40ce3cf4c014d3eec9f", { "peerDependencies": { "typescript": "^5" } }, "4b2fd615546cc4dd1cacd40ce3cf4c014d3eec9f"],
"style-mod": ["style-mod@4.1.2", "", {}, "sha512-wnD1HyVqpJUI2+eKZ+eo1UwghftP6yuFheBqqe+bWCotBjC2K1YnteJILRMs3SM4V/0dLEW1SC27MWP5y+mwmw=="],

View File

@ -17,6 +17,7 @@ import {
getNamedArgParts,
getPipeExprParts,
getStringParts,
getTryExprParts,
} from '#compiler/utils'
const DEBUG = false
@ -51,6 +52,7 @@ export class Compiler {
instructions: ProgramItem[] = []
fnLabelCount = 0
ifLabelCount = 0
tryLabelCount = 0
bytecode: Bytecode
pipeCounter = 0
@ -273,7 +275,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 +286,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}:`])
@ -315,8 +338,20 @@ export class Compiler {
CALL
*/
case terms.FunctionCall: {
const { identifierNode, namedArgs, positionalArgs } = getFunctionCallParts(node, input)
const { identifierNode, namedArgs, positionalArgs, bang } = getFunctionCallParts(node, input)
const instructions: ProgramItem[] = []
let catchLabel = ''
let endLabel = ''
if (bang) {
// wrap function call in try block
this.tryLabelCount++
catchLabel = `.catch_${this.tryLabelCount}`
endLabel = `.end_try_${this.tryLabelCount}`
instructions.push(['PUSH_TRY', catchLabel])
}
instructions.push(...this.#compileNode(identifierNode, input))
positionalArgs.forEach((arg) => {
@ -333,14 +368,66 @@ export class Compiler {
instructions.push(['PUSH', namedArgs.length])
instructions.push(['CALL'])
if (bang) {
instructions.push(['PUSH', null])
instructions.push(['SWAP'])
instructions.push(['MAKE_ARRAY', 2])
instructions.push(['POP_TRY'])
instructions.push(['JUMP', endLabel])
instructions.push([`${catchLabel}:`])
instructions.push(['PUSH', null])
instructions.push(['MAKE_ARRAY', 2])
instructions.push([`${endLabel}:`])
}
return instructions
}
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 +634,52 @@ export class Compiler {
)
}
}
#compileTryCatchFinally(
compileTryBody: () => ProgramItem[],
catchVariable: string | undefined,
catchBody: SyntaxNode | undefined,
finallyBody: SyntaxNode | undefined,
input: string
): ProgramItem[] {
const instructions: ProgramItem[] = []
this.tryLabelCount++
const catchLabel: Label = `.catch_${this.tryLabelCount}`
const finallyLabel: Label = finallyBody ? `.finally_${this.tryLabelCount}` : (null as any)
const endLabel: Label = `.end_try_${this.tryLabelCount}`
instructions.push(['PUSH_TRY', catchLabel])
instructions.push(...compileTryBody())
instructions.push(['POP_TRY'])
instructions.push(['JUMP', finallyBody ? finallyLabel : endLabel])
// catch block
instructions.push([`${catchLabel}:`])
if (catchBody && catchVariable) {
instructions.push(['STORE', catchVariable])
const catchInstructions = this.#compileNode(catchBody, input)
instructions.push(...catchInstructions)
instructions.push(['JUMP', finallyBody ? finallyLabel : endLabel])
} else {
// no catch block
if (finallyBody) {
instructions.push(['JUMP', finallyLabel])
} else {
instructions.push(['THROW'])
}
}
// finally block
if (finallyBody) {
instructions.push([`${finallyLabel}:`])
const finallyInstructions = this.#compileNode(finallyBody, input)
instructions.push(...finallyInstructions)
// finally doesn't return a value
instructions.push(['POP'])
}
instructions.push([`${endLabel}:`])
return instructions
}
}

View File

@ -266,6 +266,73 @@ describe('native functions', () => {
})
})
describe('error handling with ! suffix', () => {
test('function with ! suffix returns [null, result] on success', () => {
const readFile = () => 'file contents'
expect(`[ error content ] = read-file! test.txt; error`).toEvaluateTo(null, { 'read-file': readFile })
expect(`[ error content ] = read-file! test.txt; content`).toEvaluateTo('file contents', { 'read-file': readFile })
})
test('function with ! suffix returns [error, null] on failure', () => {
const readFile = () => { throw new Error('File not found') }
expect(`[ error content ] = read-file! test.txt; error`).toEvaluateTo('File not found', { 'read-file': readFile })
expect(`[ error content ] = read-file! test.txt; content`).toEvaluateTo(null, { 'read-file': readFile })
})
test('can use error in conditional', () => {
const readFile = () => { throw new Error('Not found') }
expect(`
[ error content ] = read-file! test.txt
if error:
'failed'
else:
content
end
`).toEvaluateTo('failed', { 'read-file': readFile })
})
test('successful result in conditional', () => {
const readFile = () => 'success data'
expect(`
[ error content ] = read-file! test.txt
if error:
'failed'
else:
content
end
`).toEvaluateTo('success data', { 'read-file': readFile })
})
test('function without ! suffix throws normally', () => {
const readFile = () => { throw new Error('Normal error') }
expect(`read-file test.txt`).toFailEvaluation({ 'read-file': readFile })
})
test('can destructure and use both values', () => {
const parseJson = (json: string) => JSON.parse(json)
expect(`
[ error result ] = parse-json! '{"a": 1}'
if error:
null
else:
result.a
end
`).toEvaluateTo(1, { 'parse-json': parseJson })
})
test('can destructure with invalid json', () => {
const parseJson = (json: string) => JSON.parse(json)
expect(`
[ error result ] = parse-json! 'invalid'
if error:
'parse error'
else:
result
end
`).toEvaluateTo('parse error', { 'parse-json': parseJson })
})
})
describe('dot get', () => {
const array = (...items: any) => items
const dict = (atNamed: any) => atNamed

View File

@ -0,0 +1,311 @@
import { describe } from 'bun:test'
import { expect, test } from 'bun:test'
describe('exception handling', () => {
test('try with catch - no error thrown', () => {
expect(`
try:
42
catch err:
99
end
`).toEvaluateTo(42)
})
test('try with catch - error thrown', () => {
expect(`
try:
throw 'something went wrong'
99
catch err:
err
end
`).toEvaluateTo('something went wrong')
})
test('try with catch - catch variable binding', () => {
expect(`
try:
throw 100
catch my-error:
my-error + 50
end
`).toEvaluateTo(150)
})
test('try with finally - no error', () => {
expect(`
x = 0
result = try:
x = 10
42
finally:
x = x + 5
end
x
`).toEvaluateTo(15)
})
test('try with finally - return value from try', () => {
expect(`
x = 0
result = try:
x = 10
42
finally:
x = x + 5
999
end
result
`).toEvaluateTo(42)
})
test('try with catch and finally - no error', () => {
expect(`
x = 0
try:
x = 10
42
catch err:
x = 999
0
finally:
x = x + 5
end
x
`).toEvaluateTo(15)
})
test('try with catch and finally - error thrown', () => {
expect(`
x = 0
result = try:
x = 10
throw 'error'
99
catch err:
x = 20
err
finally:
x = x + 5
end
x
`).toEvaluateTo(25)
})
test('try with catch and finally - return value from catch', () => {
expect(`
result = try:
throw 'oops'
catch err:
'caught'
finally:
'finally'
end
result
`).toEvaluateTo('caught')
})
test('throw statement with string', () => {
expect(`
try:
throw 'error message'
catch err:
err
end
`).toEvaluateTo('error message')
})
test('throw statement with number', () => {
expect(`
try:
throw 404
catch err:
err
end
`).toEvaluateTo(404)
})
test('throw statement with dict', () => {
expect(`
try:
throw [code=500 message=failed]
catch e:
e
end
`).toEvaluateTo({ code: 500, message: 'failed' })
})
test('uncaught exception fails', () => {
expect(`throw 'uncaught error'`).toFailEvaluation()
})
test('single-line try catch', () => {
expect(`result = try: throw 'err' catch e: 'handled' end; result`).toEvaluateTo('handled')
})
test('nested try blocks - inner catches', () => {
expect(`
try:
result = try:
throw 'inner error'
catch err:
err
end
result
catch outer:
'outer'
end
`).toEvaluateTo('inner error')
})
test('nested try blocks - outer catches', () => {
expect(`
try:
try:
throw 'inner error'
catch err:
throw 'outer error'
end
catch outer:
outer
end
`).toEvaluateTo('outer error')
})
test('try as expression', () => {
expect(`
x = try: 10 catch err: 0 end
y = try: throw 'err' catch err: 20 end
x + y
`).toEvaluateTo(30)
})
})
describe('function-level exception handling', () => {
test('function with catch - no error', () => {
expect(`
read-file = do path:
path
catch e:
'default'
end
read-file test.txt
`).toEvaluateTo('test.txt')
})
test('function with catch - error thrown', () => {
expect(`
read-file = do path:
throw 'file not found'
catch e:
'default'
end
read-file test.txt
`).toEvaluateTo('default')
})
test('function with catch - error variable binding', () => {
expect(`
safe-call = do:
throw 'operation failed'
catch err:
err
end
safe-call
`).toEvaluateTo('operation failed')
})
test('function with finally - always runs', () => {
expect(`
counter = 0
increment-task = do:
result = 42
result
finally:
counter = counter + 1
end
x = increment-task
y = increment-task
counter
`).toEvaluateTo(2)
})
test('function with finally - return value from body', () => {
expect(`
get-value = do:
100
finally:
999
end
get-value
`).toEvaluateTo(100)
})
test('function with catch and finally', () => {
expect(`
cleanup-count = 0
safe-op = do should-fail:
if should-fail:
throw 'failed'
end
'success'
catch e:
'caught'
finally:
cleanup-count = cleanup-count + 1
end
result1 = safe-op false
result2 = safe-op true
cleanup-count
`).toEvaluateTo(2)
})
test('function with catch and finally - catch return value', () => {
expect(`
safe-fail = do:
throw 'always fails'
catch e:
'error handled'
finally:
noop = 1
end
safe-fail
`).toEvaluateTo('error handled')
})
test('function without catch/finally still works', () => {
expect(`
regular = do x:
x + 10
end
regular 5
`).toEvaluateTo(15)
})
test('nested functions with catch', () => {
expect(`
inner = do:
throw 'inner error'
catch e:
'inner caught'
end
outer = do:
inner
catch e:
'outer caught'
end
outer
`).toEvaluateTo('inner caught')
})
})

View File

@ -0,0 +1,292 @@
import { describe, test, expect } from 'bun:test'
import { Compiler } from '#compiler/compiler'
import { VM } from 'reefvm'
describe('Native Function Exceptions', () => {
test('native function error caught by try/catch', async () => {
const code = `
result = try:
failing-fn
catch e:
'caught: ' + e
end
result
`
const compiler = new Compiler(code)
const vm = new VM(compiler.bytecode)
vm.set('failing-fn', () => {
throw new Error('native function failed')
})
const result = await vm.run()
expect(result).toEqual({ type: 'string', value: 'caught: native function failed' })
})
test('async native function error caught by try/catch', async () => {
const code = `
result = try:
async-fail
catch e:
'async caught: ' + e
end
result
`
const compiler = new Compiler(code)
const vm = new VM(compiler.bytecode)
vm.set('async-fail', async () => {
await new Promise(resolve => setTimeout(resolve, 1))
throw new Error('async error')
})
const result = await vm.run()
expect(result).toEqual({ type: 'string', value: 'async caught: async error' })
})
test('native function with arguments throwing error', async () => {
const code = `
result = try:
read-file missing.txt
catch e:
'default content'
end
result
`
const compiler = new Compiler(code)
const vm = new VM(compiler.bytecode)
vm.set('read-file', (path: string) => {
if (path === 'missing.txt') {
throw new Error('file not found')
}
return 'file contents'
})
const result = await vm.run()
expect(result).toEqual({ type: 'string', value: 'default content' })
})
test('native function error with finally block', async () => {
const code = `
cleanup-count = 0
result = try:
failing-fn
catch e:
'error handled'
finally:
cleanup-count = cleanup-count + 1
end
cleanup-count
`
const compiler = new Compiler(code)
const vm = new VM(compiler.bytecode)
vm.set('failing-fn', () => {
throw new Error('native error')
})
const result = await vm.run()
expect(result).toEqual({ type: 'number', value: 1 })
})
test('native function error without catch propagates', async () => {
const code = `
failing-fn
`
const compiler = new Compiler(code)
const vm = new VM(compiler.bytecode)
vm.set('failing-fn', () => {
throw new Error('uncaught error')
})
await expect(vm.run()).rejects.toThrow('uncaught error')
})
test('native function in function-level catch', async () => {
const code = `
safe-read = do path:
read-file path
catch e:
'default: ' + e
end
result = safe-read missing.txt
result
`
const compiler = new Compiler(code)
const vm = new VM(compiler.bytecode)
vm.set('read-file', (path: string) => {
throw new Error('file not found: ' + path)
})
const result = await vm.run()
expect(result).toEqual({ type: 'string', value: 'default: file not found: missing.txt' })
})
test('nested native function errors', async () => {
const code = `
result = try:
try:
inner-fail
catch e:
throw 'wrapped: ' + e
end
catch e:
'outer caught: ' + e
end
result
`
const compiler = new Compiler(code)
const vm = new VM(compiler.bytecode)
vm.set('inner-fail', () => {
throw new Error('inner error')
})
const result = await vm.run()
expect(result).toEqual({ type: 'string', value: 'outer caught: wrapped: inner error' })
})
test('native function error with multiple named args', async () => {
const code = `
result = try:
process-file path=missing.txt mode=strict
catch e:
'error: ' + e
end
result
`
const compiler = new Compiler(code)
const vm = new VM(compiler.bytecode)
vm.set('process-file', (path: string, mode: string = 'lenient') => {
if (mode === 'strict' && path === 'missing.txt') {
throw new Error('strict mode: file required')
}
return 'processed'
})
const result = await vm.run()
expect(result).toEqual({ type: 'string', value: 'error: strict mode: file required' })
})
test('native function returning normally after other functions threw', async () => {
const code = `
result1 = try:
failing-fn
catch e:
'caught'
end
result2 = success-fn
result1 + ' then ' + result2
`
const compiler = new Compiler(code)
const vm = new VM(compiler.bytecode)
vm.set('failing-fn', () => {
throw new Error('error')
})
vm.set('success-fn', () => {
return 'success'
})
const result = await vm.run()
expect(result).toEqual({ type: 'string', value: 'caught then success' })
})
test('native function error message preserved', async () => {
const code = `
result = try:
throw-custom-message
catch e:
e
end
result
`
const compiler = new Compiler(code)
const vm = new VM(compiler.bytecode)
vm.set('throw-custom-message', () => {
throw new Error('This is a very specific error message with details')
})
const result = await vm.run()
expect(result).toEqual({
type: 'string',
value: 'This is a very specific error message with details'
})
})
test('native function throwing non-Error value', async () => {
const code = `
result = try:
throw-string
catch e:
'caught: ' + e
end
result
`
const compiler = new Compiler(code)
const vm = new VM(compiler.bytecode)
vm.set('throw-string', () => {
throw 'plain string error'
})
const result = await vm.run()
expect(result).toEqual({ type: 'string', value: 'caught: plain string error' })
})
test('multiple native function calls with mixed success/failure', async () => {
const code = `
r1 = try: success-fn catch e: 'error' end
r2 = try: failing-fn catch e: 'caught' end
r3 = try: success-fn catch e: 'error' end
results = [r1 r2 r3]
results
`
const compiler = new Compiler(code)
const vm = new VM(compiler.bytecode)
vm.set('success-fn', () => 'ok')
vm.set('failing-fn', () => {
throw new Error('failed')
})
const result = await vm.run()
expect(result.type).toBe('array')
const arr = result.value as any[]
expect(arr.length).toBe(3)
expect(arr[0]).toEqual({ type: 'string', value: 'ok' })
expect(arr[1]).toEqual({ type: 'string', value: 'caught' })
expect(arr[2]).toEqual({ type: 'string', value: 'ok' })
})
})

View File

@ -67,11 +67,11 @@ export const getAssignmentParts = (node: SyntaxNode) => {
export const getFunctionDefParts = (node: SyntaxNode, input: string) => {
const children = getAllChildren(node)
const [fnKeyword, paramsNode, colon, ...bodyNodes] = children
const [fnKeyword, paramsNode, colon, ...rest] = children
if (!fnKeyword || !paramsNode || !colon || !bodyNodes) {
if (!fnKeyword || !paramsNode || !colon || !rest) {
throw new CompilerError(
`FunctionDef expected 5 children, got ${children.length}`,
`FunctionDef expected at least 4 children, got ${children.length}`,
node.from,
node.to
)
@ -88,17 +88,63 @@ 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) => {
const [identifierNode, ...args] = getAllChildren(node)
let bang = false
if (!identifierNode) {
throw new CompilerError(`FunctionCall expected at least 1 child, got 0`, node.from, node.to)
}
if (args.length > 0 && args[0]?.type.id === terms.Bang) {
bang = true
args.shift()
}
const namedArgs = args.filter((arg) => arg.type.id === terms.NamedArg)
const positionalArgs = args
.filter((arg) => arg.type.id === terms.PositionalArg)
@ -109,7 +155,7 @@ export const getFunctionCallParts = (node: SyntaxNode, input: string) => {
return child
})
return { identifierNode, namedArgs, positionalArgs }
return { identifierNode, namedArgs, positionalArgs, bang }
}
export const getNamedArgParts = (node: SyntaxNode, input: string) => {
@ -204,7 +250,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 +290,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,
}
}

View File

@ -2,7 +2,7 @@ import { ContextTracker, InputStream } from '@lezer/lr'
import * as terms from './shrimp.terms'
export class Scope {
constructor(public parent: Scope | null, public vars = new Set<string>()) {}
constructor(public parent: Scope | null, public vars = new Set<string>()) { }
has(name: string): boolean {
return this.vars.has(name) || (this.parent?.has(name) ?? false)
@ -42,7 +42,7 @@ export class Scope {
// Tracker context that combines Scope with temporary pending identifiers
class TrackerContext {
constructor(public scope: Scope, public pendingIds: string[] = []) {}
constructor(public scope: Scope, public pendingIds: string[] = []) { }
}
// Extract identifier text from input stream
@ -75,6 +75,12 @@ export const trackScope = new ContextTracker<TrackerContext>({
return new TrackerContext(context.scope, [...context.pendingIds, text])
}
// Track identifiers in array destructuring: [ a b ] = ...
if (!inParams && term === terms.Identifier && isArrayDestructuring(input)) {
const text = readIdentifierText(input, input.pos, stack.pos)
return new TrackerContext(Scope.add(context.scope, text), context.pendingIds)
}
return context
},
@ -98,3 +104,26 @@ export const trackScope = new ContextTracker<TrackerContext>({
hash: (context) => context.scope.hash(),
})
// Check if we're parsing array destructuring: [ a b ] = ...
const isArrayDestructuring = (input: InputStream): boolean => {
let pos = 0
// Find closing bracket
while (pos < 200 && input.peek(pos) !== 93 /* ] */) {
if (input.peek(pos) === -1) return false // EOF
pos++
}
if (input.peek(pos) !== 93 /* ] */) return false
pos++
// Skip whitespace
while (input.peek(pos) === 32 /* space */ ||
input.peek(pos) === 9 /* tab */ ||
input.peek(pos) === 10 /* \n */) {
pos++
}
return input.peek(pos) === 61 /* = */
}

View File

@ -21,6 +21,7 @@
comment { "#" ![\n]* }
leftParen { "(" }
rightParen { ")" }
Bang { "!" }
colon[closedBy="end", @name="colon"] { ":" }
Underscore { "_" }
Regex { "//" (![/\\\n[] | "\\" ![\n] | "[" (![\n\\\]] | "\\" ![\n])* "]")+ ("//" $[gimsuy]*)? } // Stolen from the lezer JavaScript grammar
@ -51,6 +52,8 @@ item {
consumeToTerminator {
PipeExpr |
ambiguousFunctionCall |
TryExpr |
Throw |
IfExpr |
FunctionDef |
Assign |
@ -68,7 +71,7 @@ pipeOperand {
}
FunctionCallOrIdentifier {
DotGet | Identifier
(DotGet | Identifier) Bang?
}
ambiguousFunctionCall {
@ -76,7 +79,7 @@ ambiguousFunctionCall {
}
FunctionCall {
(DotGet | Identifier | ParenExpr) arg+
(DotGet | Identifier | ParenExpr) Bang? arg+
}
arg {
@ -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 |

View File

@ -26,28 +26,34 @@ export const
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
Bang = 27,
BinOp = 28,
String = 29,
StringFragment = 30,
Interpolation = 31,
EscapeSeq = 32,
Boolean = 33,
Regex = 34,
Dict = 35,
NamedArg = 36,
NamedArgPrefix = 37,
FunctionDef = 38,
Params = 39,
colon = 40,
CatchExpr = 41,
keyword = 64,
TryBlock = 43,
FinallyExpr = 44,
Underscore = 47,
Array = 48,
Null = 49,
ConditionalOp = 50,
PositionalArg = 51,
TryExpr = 53,
Throw = 55,
IfExpr = 57,
SingleLineThenBlock = 59,
ThenBlock = 60,
ElseIfExpr = 61,
ElseExpr = 63,
Assign = 65

View File

@ -4,24 +4,24 @@ import {operatorTokenizer} from "./operatorTokenizer"
import {tokenizer, specializeKeyword} from "./tokenizer"
import {trackScope} from "./scopeTracker"
import {highlighting} from "./highlight"
const spec_Identifier = {__proto__:null,end:80, null:86, if:96, elseif:104, else:108}
const spec_Identifier = {__proto__:null,catch:84, finally:90, end:92, null:98, try:108, throw:112, if:116, elseif:124, else:128}
export const parser = LRParser.deserialize({
version: 14,
states: "3[QYQbOOO#hQcO'#CvO$eOSO'#CxO$sQbO'#EVOOQ`'#DR'#DROOQa'#DO'#DOO%vQbO'#DWO'RQcO'#DzOOQa'#Dz'#DzO(UQcO'#DzO)WQcO'#DyO*PQRO'#CwO*dQcO'#DuO*{QcO'#DuO+^QbO'#CuO,UOpO'#CsOOQ`'#Dv'#DvO,ZQbO'#DuO,iQbO'#E]OOQ`'#D]'#D]O-^QRO'#DeOOQ`'#Du'#DuO-cQQO'#DtOOQ`'#Dt'#DtOOQ`'#Df'#DfQYQbOOO-kQbO'#DPOOQa'#Dy'#DyOOQ`'#DZ'#DZOOQ`'#E['#E[OOQ`'#Dm'#DmO-uQbO,59^O.cQbO'#CzO.kQWO'#C{OOOO'#D|'#D|OOOO'#Dg'#DgO/POSO,59dOOQa,59d,59dOOQ`'#Di'#DiO/_QbO'#DSO/gQQO,5:qOOQ`'#Dh'#DhO/lQbO,59rO/sQQO,59jOOQa,59r,59rO0OQbO,59rO0YQbO,5:PO,iQbO,59cO,iQbO,59cO,iQbO,59cO,iQbO,59tO,iQbO,59tO,iQbO,59tO0dQRO,59aO0kQRO,59aO0|QRO,59aO0wQQO,59aO1XQQO,59aO1aObO,59_O1lQbO'#DnO1wQbO,59]O2YQRO,5:wO2aQRO,5:wOOQ`,5:`,5:`OOQ`-E7d-E7dOOQ`,59k,59kOOQ`-E7k-E7kOOOO,59f,59fOOOO,59g,59gOOOO-E7e-E7eOOQa1G/O1G/OOOQ`-E7g-E7gO2lQbO1G0]OOQ`-E7f-E7fO2yQQO1G/UOOQa1G/^1G/^O3UQbO1G/^OOQO'#Dk'#DkO2yQQO1G/UOOQa1G/U1G/UOOQ`'#Dl'#DlO3UQbO1G/^OOQ`1G/k1G/kOOQa1G.}1G.}O3wQcO1G.}O4RQcO1G.}O4]QcO1G.}OOQa1G/`1G/`O5oQcO1G/`O5vQcO1G/`O5}QcO1G/`OOQa1G.{1G.{OOQa1G.y1G.yO!ZQbO'#CvO6UQbO'#CrOOQ`,5:Y,5:YOOQ`-E7l-E7lO6cQbO1G0cO6pQbO7+%wO6uQbO7+%xO7VQQO7+$pOOQa7+$p7+$pO7bQbO7+$xOOQa7+$x7+$xOOQO-E7i-E7iOOQ`-E7j-E7jOOQ`'#D_'#D_O7lQbO7+%}O7qQbO7+&OOOQ`<<Ic<<IcOOQ`'#Dj'#DjO8XQQO'#DjO8^QbO'#EXO8tQbO<<IdOOQa<<H[<<H[OOQa<<Hd<<HdOOQ`<<Ii<<IiOOQ`'#D`'#D`O8yQbO<<IjOOQ`,5:U,5:UOOQ`-E7h-E7hOOQ`AN?OAN?OO,iQbO'#DaOOQ`'#Do'#DoO9UQbOAN?UO9aQQO'#DcOOQ`AN?UAN?UO9fQbOAN?UO9kQRO,59{O9rQRO,59{OOQ`-E7m-E7mOOQ`G24pG24pO9}QbOG24pO:SQQO,59}O:XQQO1G/gOOQ`LD*[LD*[O6uQbO1G/iO7qQbO7+%ROOQ`7+%T7+%TOOQ`<<Hm<<Hm",
stateData: ":a~O!fOS!gOS~O_PO`dOaWOb_OcROhWOpWOqWO{WO!QbO!l^O!oQO!vTO!wUO!xgO~O_kOaWOb_OcROhWOpWOqWOtjOylO{WO!l^O!oQO!vTO!wUO!OjX!xjX#RjX!}jXxjX~OP!mXQ!mXR!mXS!mXT!mXU!mXW!mXX!mXY!mXZ!mX[!mX]!mX^!mX~P!ZOmrO!ouO!qpO!rqO~O_vOwvP~O_kOaWOb_OhWOpWOqWOtjO{WO!l^O!oQO!vTO!wUO!xyO~O!||O~P${OP!nXQ!nXR!nXS!nXT!nXU!nXW!nXX!nXY!nXZ!nX[!nX]!nX^!nX!x!nX#R!nXx!nX~O_kOaWOb_OcROhWOpWOqWOtjOylO{WO!l^O!oQO!vTO!wUO!}!nX~P%}OV!OO~P%}OP!mXQ!mXR!mXS!mXT!mXU!mXW!mXX!mXY!mXZ!mX[!mX]!mX^!mX~O!x!iX#R!iXx!iX~P(]OT!TOU!UOW!SOX!SOY!SOZ!SO[!SO]!SO~OP!QOQ!QOR!ROS!RO^!PO~P)eOP!QOQ!QOR!ROS!RO!x!iX#R!iXx!iX~OT!TOU!UO!x!iX#R!iXx!iX~O_POaWOb_OcROhWOpWOqWO{WO!l^O!oQO!vTO!wUO~O!k![O~O!O!]O!x!iX#R!iXx!iX~O_kOaWOb_OhWOpWOqWO{WO!l^O!oQO!vTO!wUO~OV!OO~O!x!aO#R!aO~OcROy!cO~P,iOcROtjOylO!Ofa!xfa#Rfa!}faxfa~P,iO_!eO!l^O~O!o!fO!q!fO!r!fO!s!fO!t!fO!u!fO~OmrO!o!hO!qpO!rqO~O_vOwvX~Ow!jO~O!|!mO~P${OtjO!x!oO!|!qO~O!x!rO!|!mO~P,iO`dO!QbO~P+^O!}!}O~P(]OP!QOQ!QOR!ROS!RO!}!}O~OT!TOU!UO!}!}O~O!O!]O!}!}O~O_#OOh#OO!l^O~O_#POb_O!l^O~O!O!]O!xea#Rea!}eaxea~Ow#TO~P)eOT!TOU!UOw#TO~O`dO!QbO!x#VO~P+^OtjO!x!oO!|#XO~O!x!rO!|#ZO~P,iO^!PORkiSki!xki#Rki!}kixki~OPkiQki~P3`OP!QOQ!QO~P3`OP!QOQ!QORkiSki!xki#Rki!}kixki~OW!SOX!SOY!SOZ!SO[!SO]!SOT|i!x|i#R|i!}|iw|ix|i~OU!UO~P4wOU!UO~P5ZOU|i~P4wOcROtjOylO~P,iO`dO!QbO!x#`O~P+^Ox#aO~O`dO!QbO!x#bOx!{P~P+^OtjO!x!oO!|#fO~O!x!rO!|#gO~P,iOx#hO~O`dO!QbO!x#bOx!{P!U!{P!W!{P~P+^O!x#kO~O`dO!QbO!x#bOx!{X!U!{X!W!{X~P+^Ox#mO~Ox#rO!U#nO!W#qO~Ox#wO!U#nO!W#qO~Ow#yO~Ox#wO~Ow#zO~P)eOT!TOU!UOw#zO~Ox#{O~O!x#|O~O!x#}O~Ohq~",
goto: "/r#RPPPPPPPPPPPPPPPPPPPPP#S#c#qP$i#c%g%|P&o&oPP%|&sP'W'qPPP'tP(i)UP)]P)i)l)uP)yP)]*P*V*]*c*i*r*|+W+a+hPPPP+n+r,WPP,j-wP.nPPPPPPPP.r.r/VPP/_/f/fdeOi!O!j#T#V#`#d#|#}R!Y^i`O^i!O!]!j#T#V#`#d#|#}fPO^i!O!j#T#V#`#d#|#}xkPUVbjoz}!P!Q!R!S!T!U!n!s#P#Q#Y#nR#P!]fVO^i!O!j#T#V#`#d#|#}xWPUVbjoz}!P!Q!R!S!T!U!n!s#P#Q#Y#nQ!epQ#O![R#Q!]d[Oi!O!j#T#V#`#d#|#}Q!W^Q!u!QR!x!R!aWOPUV^bijoz}!O!P!Q!R!S!T!U!j!n!s#P#Q#T#V#Y#`#d#n#|#}TrQtYmPVo#P#QQ{UQ!lzX!o{!l!p#WdeOi!O!j#T#V#`#d#|#}YlPVo#P#QQ!Y^R!cjRxRzWPUV^bjoz}!P!Q!R!S!T!U!n!s#P#Q#Y#neXOi!O!j#T#V#`#d#|#}d]Oi!O!j#T#V#`#d#|#}Q!X^Q!`bQ!y!UQ!{!TR#u#nZmPVo#P#QeeOi!O!j#T#V#`#d#|#}R#_#TQ#j#`Q$O#|R$P#}T#o#j#pQ#s#jR#x#pQiOR!biQtQR!gtQzUR!kzQwRR!iwW#d#V#`#|#}R#l#dQ!p{Q#W!lT#[!p#WQ!s}Q#Y!nT#]!s#YWoPV#P#QR!doS!^a!ZR#S!^Q#p#jR#v#pThOiSfOiQ!t!OQ#U!jQ#^#TZ#c#V#`#d#|#}daOi!O!j#T#V#`#d#|#}Q!Z^R#R!]fZO^i!O!j#T#V#`#d#|#}YlPVo#P#QQ}UQ!_bQ!cjQ!nzW!r}!n!s#YQ!u!PQ!v!QQ!w!RQ!y!SQ!z!TQ!|!UR#t#ndYOi!O!j#T#V#`#d#|#}xkPUVbjoz}!P!Q!R!S!T!U!n!s#P#Q#Y#nR!V^TsQtsSOPV^ijo!O!j#P#Q#T#V#`#d#|#}Q#e#VV#i#`#|#}ZnPVo#P#QecOi!O!j#T#V#`#d#|#}",
nodeNames: "⚠ Star Slash Plus Minus And Or Eq EqEq Neq Lt Lte Gt Gte Modulo Identifier AssignableIdentifier Word IdentifierBeforeDot Do Program PipeExpr FunctionCall DotGet Number ParenExpr FunctionCallOrIdentifier BinOp String StringFragment Interpolation EscapeSeq Boolean Regex Dict NamedArg NamedArgPrefix FunctionDef Params colon keyword Underscore Array Null ConditionalOp PositionalArg operator IfExpr keyword SingleLineThenBlock ThenBlock ElseIfExpr keyword ElseExpr keyword Assign",
maxTerm: 95,
states: "9bQYQbOOO#wQcO'#CvO$tOSO'#CyO%SQbO'#EaOOQ`'#DS'#DSOOQa'#DP'#DPO&VQbO'#D^O'hQcO'#EUOOQa'#EU'#EUO(nQcO'#EUO)pQcO'#ETO*TQRO'#CxO+aQcO'#EPO+qQcO'#EPO+{QbO'#CuO,sOpO'#CsOOQ`'#EQ'#EQO,xQbO'#EPO-PQQO'#EgOOQ`'#Dc'#DcO-UQbO'#DeO-UQbO'#EiOOQ`'#Dg'#DgO-yQRO'#DoOOQ`'#EP'#EPO.OQQO'#EOOOQ`'#EO'#EOOOQ`'#Dp'#DpQYQbOOO.WQbO,59bO.zQbO'#DQOOQa'#ET'#ETOOQ`'#Da'#DaOOQ`'#Ef'#EfOOQ`'#Dw'#DwO/UQbO,59^O/xQbO'#C{O0QQWO'#C|OOOO'#EW'#EWOOOO'#Dq'#DqO0fOSO,59eOOQa,59e,59eOOQ`'#Ds'#DsO0tQbO'#DTO0|QQO,5:{OOQ`'#Dr'#DrO1RQbO,59xO1YQQO,59kOOQa,59x,59xO1eQbO,59xO1oQbO,59^O1|QbO,5:ZO-UQbO,59dO-UQbO,59dO-UQbO,59dO-UQbO,59zO-UQbO,59zO-UQbO,59zO2^QRO,59aO2eQRO,59aO2vQRO,59aO2qQQO,59aO3RQQO,59aO3ZObO,59_O3fQbO'#DxO3qQbO,59]O4YQbO,5;RO4mQcO,5:PO5cQcO,5:PO5sQcO,5:PO6iQRO,5;TO6pQRO,5;TOOQ`,5:j,5:jOOQ`-E7n-E7nO6{QbO1G.xOOQ`,59l,59lOOQ`-E7u-E7uOOOO,59g,59gOOOO,59h,59hOOOO-E7o-E7oOOQa1G/P1G/POOQ`-E7q-E7qO7oQbO1G0gOOQ`-E7p-E7pO8SQQO1G/VOOQa1G/d1G/dO8_QbO1G/dOOQO'#Du'#DuO8SQQO1G/VOOQa1G/V1G/VOOQ`'#Dv'#DvO8_QbO1G/dOOQ`1G/u1G/uOOQa1G/O1G/OO9WQcO1G/OO9bQcO1G/OO9lQcO1G/OOOQa1G/f1G/fO;[QcO1G/fO;cQcO1G/fO;jQcO1G/fOOQa1G.{1G.{OOQa1G.y1G.yO!aQbO'#CvO;qQbO'#CrOOQ`,5:d,5:dOOQ`-E7v-E7vO<RQbO1G0mO<^QbO1G0nO<zQbO1G0oO=_QbO7+&RO<^QbO7+&TO=jQQO7+$qOOQa7+$q7+$qO=uQbO7+%OOOQa7+%O7+%OOOQO-E7s-E7sOOQ`-E7t-E7tO>PQbO'#DVO>UQQO'#DYOOQ`7+&X7+&XO>ZQbO7+&XO>`QbO7+&XOOQ`'#Dt'#DtO>hQQO'#DtO>mQbO'#EbOOQ`'#DX'#DXO?aQbO7+&YOOQ`'#Di'#DiO?lQbO7+&ZO?qQbO7+&[OOQ`<<Im<<ImO@_QbO<<ImO@dQbO<<ImO@lQbO<<IoOOQa<<H]<<H]OOQa<<Hj<<HjO@wQQO,59qO@|QbO,59tOOQ`<<Is<<IsOAaQbO<<IsOOQ`,5:`,5:`OOQ`-E7r-E7rOOQ`<<It<<ItOAfQbO<<ItOAkQbO<<ItOOQ`<<Iu<<IuOOQ`'#Dj'#DjOAsQbO<<IvOOQ`AN?XAN?XOBOQbOAN?XOOQ`AN?ZAN?ZOBTQbOAN?ZOBYQbOAN?ZOBbQbO1G/]OBuQbO1G/`OOQ`1G/`1G/`OOQ`AN?_AN?_OOQ`AN?`AN?`OC]QbOAN?`O-UQbO'#DkOOQ`'#Dy'#DyOCbQbOAN?bOCmQQO'#DmOOQ`AN?bAN?bOCrQbOAN?bOOQ`G24sG24sOOQ`G24uG24uOCwQbOG24uOC|QbO7+$wOOQ`7+$w7+$wOOQ`7+$z7+$zOOQ`G24zG24zODgQRO,5:VODnQRO,5:VOOQ`-E7w-E7wOOQ`G24|G24|ODyQbOG24|OEOQQO,5:XOOQ`LD*aLD*aOOQ`<<Hc<<HcOETQQO1G/qOOQ`LD*hLD*hOBuQbO1G/sO?qQbO7+%]OOQ`7+%_7+%_OOQ`<<Hw<<Hw",
stateData: "E]~O!pOS!qOS~O_PO`gOaWOb_OcROhWOqWOrWO!RWO!WbO!YdO![eO!v^O!yQO#QTO#RUO#SjO~O_oOaWOb_OcROhWOkmOqWOrWOunO!PpO!RWO!v^O!yQO#QTO#RUO!UjX#SjX#_jX#XjXzjX}jX!OjX~OP!wXQ!wXR!wXS!wXT!wXU!wXW!wXX!wXY!wXZ!wX[!wX]!wX^!wX~P!aOnvO!yyO!{tO!|uO~O_zOxwP~O_oOaWOb_OhWOqWOrWOunO!RWO!v^O!yQO#QTO#RUO#S}O~O#W!QO~P%[OP!xXQ!xXR!xXS!xXT!xXU!xXW!xXX!xXY!xXZ!xX[!xX]!xX^!xX#S!xX#_!xXz!xX}!xX!O!xX~O_oOaWOb_OcROhWOk!SOqWOrWOunO!PpO!RWO!v^O!yQO#QTO#RUO#X!xX~P&^OV!TO~P&^OP!wXQ!wXR!wXS!wXT!wXU!wXW!wXX!wXY!wXZ!wX[!wX]!wX^!wX~O#S!sX#_!sXz!sX}!sX!O!sX~P(uOP!VOQ!VOR!WOS!WOT!YOU!ZOW!XOX!XOY!XOZ!XO[!XO]!XO^!UO~O#S!sX#_!sXz!sX}!sX!O!sX~OP!VOQ!VOR!WOS!WO~P+OOT!YOU!ZO~P+OO_POaWOb_OcROhWOqWOrWO!RWO!v^O!yQO#QTO#RUO~O!u!aO~O!U!bO~P+OOx!dO~O_oOaWOb_OhWOqWOrWO!RWO!v^O!yQO#QTO#RUO~OV!TO~O#S!jO#_!jO~OcROunO!PpO!Uja#Sja#_ja#Xjazja}ja!Oja~P-UOcRO!P!mO~P-UOcROunO!PpO!Ufa#Sfa#_fa#Xfazfa}fa!Ofa~P-UO_!oO!v^O~O!y!pO!{!pO!|!pO!}!pO#O!pO#P!pO~OnvO!y!rO!{tO!|uO~O_zOxwX~Ox!tO~O#W!wO~P%[OunO#S!yO#W!{O~O#S!|O#W!wO~P-UOcROunO!PpO~P-UO`gO!WbO!YdO![eO~P+{O#X#XO~P(uOP!VOQ!VOR!WOS!WO#X#XO~OT!YOU!ZO#X#XO~O!U!bO#X#XO~O_#YOh#YO!v^O~O_#ZOb_O!v^O~O!U!bO#Sea#_ea#Xeazea}ea!Oea~O`gO!WbO!YdO![eO#S#`O~P+{O#S!Xa#_!Xaz!Xa}!Xa!O!Xa~P*TO#S!Xa#_!Xaz!Xa}!Xa!O!Xa~OP!VOQ!VOR!WOS!WO~P5QOT!YOU!ZO~P5QOT!YOU!ZOW!XOX!XOY!XOZ!XO[!XO]!XO~Ox#aO~P5}OT!YOU!ZOx#aO~OcROunO!PpO!Ufi#Sfi#_fi#Xfizfi}fi!Ofi~P-UO`gO!WbO!YdO![eO#S#cO~P+{OunO#S!yO#W#eO~O#S!|O#W#gO~P-UO^!UORliSli#Sli#_li#Xlizli}li!Oli~OPliQli~P8iOP!VOQ!VO~P8iOP!VOQ!VORliSli#Sli#_li#Xlizli}li!Oli~OW!XOX!XOY!XOZ!XO[!XO]!XOT!Si#S!Si#_!Si#X!Six!Siz!Si}!Si!O!Si~OU!ZO~P:^OU!ZO~P:pOU!Si~P:^OcROk!SOunO!PpO~P-UOz#jO}#kO!O#lO~O`gO!WbO!YdO![eO#S#oOz#UP}#UP!O#UP~P+{O`gO!WbO!YdO![eO#S#vO~P+{Oz#jO}#kO!O#wO~OunO#S!yO#W#{O~O#S!|O#W#|O~P-UO_#}O~Ox$OO~O!O$PO~O}#kO!O$PO~O#S$RO~O`gO!WbO!YdO![eO#S#oOz#UX}#UX!O#UX!`#UX!b#UX~P+{Oz#jO}#kO!O$TO~O!O$WO~O`gO!WbO!YdO![eO#S#oO!O#UP!`#UP!b#UP~P+{O!O$ZO~O}#kO!O$ZO~Oz#jO}#kO!O$]O~Ox$`O~O`gO!WbO!YdO![eO#S$aO~P+{O!O$cO~O!O$dO~O}#kO!O$dO~O!O$jO!`$fO!b$iO~O!O$lO~O!O$mO~O}#kO!O$mO~O`gO!WbO!YdO![eO#S$oO~P+{O`gO!WbO!YdO![eO#S#oO!O#UP~P+{O!O$rO~O!O$vO!`$fO!b$iO~Ox$xO~O!O$vO~O!O$yO~O`gO!WbO!YdO![eO#S#oO}#UP!O#UP~P+{Ox${O~P5}OT!YOU!ZOx${O~O!O$|O~O#S$}O~O#S%OO~Ohr~",
goto: "4y#_PPPPPPPPPPPPPPPPPPPPP#`#u$ZP%]#uP&e'TP(Q(QPP'T(UP(l)`P)cP)o)xPPP*bP+a,VP,aP,aP,aP,s,v-PP-TP,a-Z-a-g-m-s.P.Z.e.s.zPPPP/Q/U/vPP0`1yP2zPPPPPPPP3O3l3OPP3y4T4T4g4gphOl!T!d!t#`#a#c#q#v$O$`$a$o$}%OR!_^u`O^l!T!b!d!t#`#a#c#q#v$O$`$a$o$}%OrPO^l!T!d!t#`#a#c#q#v$O$`$a$o$}%O!QoPUVdemns!O!R!S!U!V!W!X!Y!Z!l!x!}#Z#[#f$fR#Z!brVO^l!T!d!t#`#a#c#q#v$O$`$a$o$}%O!QWPUVdemns!O!R!S!U!V!W!X!Y!Z!l!x!}#Z#[#f$fQ!otQ#Y!aR#[!bp[Ol!T!d!t#`#a#c#q#v$O$`$a$o$}%OQ!]^Q!fdQ#P!VR#S!W!uWOPUV^delmns!O!R!S!T!U!V!W!X!Y!Z!d!l!t!x!}#Z#[#`#a#c#f#q#v$O$`$a$f$o$}%OTvQx`qPVms!S!l#Z#[Q!PUQ!v!OX!y!P!v!z#dphOl!T!d!t#`#a#c#q#v$O$`$a$o$}%O`pPVms!S!l#Z#[Q!_^R!mnR|RQ#n#_Q#y#bQ$V#sR$_#zQ#s#`Q$q$aR$z$oQ#m#_Q#x#bQ$Q#nQ$U#sQ$[#yQ$^#zQ$e$VR$n$_!SWPUV^demns!O!R!S!U!V!W!X!Y!Z!l!x!}#Z#[#f$fqXOl!T!d!t#`#a#c#q#v$O$`$a$o$}%Op]Ol!T!d!t#`#a#c#q#v$O$`$a$o$}%OQ!^^Q!gdQ!ieQ#T!ZQ#V!YR$t$faqPVms!S!l#Z#[qhOl!T!d!t#`#a#c#q#v$O$`$a$o$}%OR#u#aQ$Y#vQ%P$}R%Q%OT$g$Y$hQ$k$YR$w$hQlOR!klQxQR!qxQ!OUR!u!OQ{RR!s{^#q#`#c#v$a$o$}%OR$S#qQ!z!PQ#d!vT#h!z#dQ!}!RQ#f!xT#i!}#fWsPV#Z#[S!lm!ST!ns!lS!ca!`R#^!cQ$h$YR$u$hTkOlSiOlQ#O!TQ#_!dQ#b!t`#p#`#c#q#v$a$o$}%OQ#t#aQ$b$OR$p$`paOl!T!d!t#`#a#c#q#v$O$`$a$o$}%OQ!`^R#]!brZO^l!T!d!t#`#a#c#q#v$O$`$a$o$}%O`pPVms!S!l#Z#[Q!RUQ!edQ!heQ!mnQ!x!OW!|!R!x!}#fQ#P!UQ#Q!VQ#R!WQ#T!XQ#U!YQ#W!ZR$s$fpYOl!T!d!t#`#a#c#q#v$O$`$a$o$}%O!QoPUVdemns!O!R!S!U!V!W!X!Y!Z!l!x!}#Z#[#f$fR![^TwQx!VSOPV^lmns!S!T!d!l!t#Z#[#`#a#c#q#v$O$`$a$o$}%OU#r#`$a$oQ#z#cV$X#v$}%OarPVms!S!l#Z#[qcOl!T!d!t#`#a#c#q#v$O$`$a$o$}%OqfOl!T!d!t#`#a#c#q#v$O$`$a$o$}%O",
nodeNames: "⚠ Star Slash Plus Minus And Or Eq EqEq Neq Lt Lte Gt Gte Modulo Identifier AssignableIdentifier Word IdentifierBeforeDot Do Program PipeExpr FunctionCall DotGet Number ParenExpr FunctionCallOrIdentifier Bang BinOp String StringFragment Interpolation EscapeSeq Boolean Regex Dict NamedArg NamedArgPrefix FunctionDef Params colon CatchExpr keyword TryBlock FinallyExpr keyword keyword Underscore Array Null ConditionalOp PositionalArg operator TryExpr keyword Throw keyword IfExpr keyword SingleLineThenBlock ThenBlock ElseIfExpr keyword ElseExpr keyword Assign",
maxTerm: 107,
context: trackScope,
nodeProps: [
["closedBy", 39,"end"]
["closedBy", 40,"end"]
],
propSources: [highlighting],
skippedNodes: [0],
repeatNodeCount: 10,
tokenData: "AO~R|OX#{XY$jYZ%TZp#{pq$jqs#{st%ntu'Vuw#{wx'[xy'ayz'zz{#{{|(e|}#{}!O(e!O!P#{!P!Q+X!Q![)S![!]3t!]!^%T!^!}#{!}#O4_#O#P6T#P#Q6Y#Q#R#{#R#S6s#S#T#{#T#Y7^#Y#Z8l#Z#b7^#b#c<z#c#f7^#f#g=q#g#h7^#h#i>h#i#o7^#o#p#{#p#q@`#q;'S#{;'S;=`$d<%l~#{~O#{~~@yS$QUmSOt#{uw#{x#O#{#P;'S#{;'S;=`$d<%lO#{S$gP;=`<%l#{^$qUmS!fYOt#{uw#{x#O#{#P;'S#{;'S;=`$d<%lO#{U%[UmS!xQOt#{uw#{x#O#{#P;'S#{;'S;=`$d<%lO#{^%uZmS!gYOY%nYZ#{Zt%ntu&huw%nwx&hx#O%n#O#P&h#P;'S%n;'S;=`'P<%lO%nY&mS!gYOY&hZ;'S&h;'S;=`&y<%lO&hY&|P;=`<%l&h^'SP;=`<%l%n~'[O!q~~'aO!o~U'hUmS!lQOt#{uw#{x#O#{#P;'S#{;'S;=`$d<%lO#{U(RUmS!}QOt#{uw#{x#O#{#P;'S#{;'S;=`$d<%lO#{U(jWmSOt#{uw#{x!Q#{!Q![)S![#O#{#P;'S#{;'S;=`$d<%lO#{U)ZYmShQOt#{uw#{x!O#{!O!P)y!P!Q#{!Q![)S![#O#{#P;'S#{;'S;=`$d<%lO#{U*OWmSOt#{uw#{x!Q#{!Q![*h![#O#{#P;'S#{;'S;=`$d<%lO#{U*oWmShQOt#{uw#{x!Q#{!Q![*h![#O#{#P;'S#{;'S;=`$d<%lO#{U+^WmSOt#{uw#{x!P#{!P!Q+v!Q#O#{#P;'S#{;'S;=`$d<%lO#{U+{^mSOY,wYZ#{Zt,wtu-zuw,wwx-zx!P,w!P!Q#{!Q!},w!}#O2m#O#P0Y#P;'S,w;'S;=`3n<%lO,wU-O^mSqQOY,wYZ#{Zt,wtu-zuw,wwx-zx!P,w!P!Q0o!Q!},w!}#O2m#O#P0Y#P;'S,w;'S;=`3n<%lO,wQ.PXqQOY-zZ!P-z!P!Q.l!Q!}-z!}#O/Z#O#P0Y#P;'S-z;'S;=`0i<%lO-zQ.oP!P!Q.rQ.wUqQ#Z#[.r#]#^.r#a#b.r#g#h.r#i#j.r#m#n.rQ/^VOY/ZZ#O/Z#O#P/s#P#Q-z#Q;'S/Z;'S;=`0S<%lO/ZQ/vSOY/ZZ;'S/Z;'S;=`0S<%lO/ZQ0VP;=`<%l/ZQ0]SOY-zZ;'S-z;'S;=`0i<%lO-zQ0lP;=`<%l-zU0tWmSOt#{uw#{x!P#{!P!Q1^!Q#O#{#P;'S#{;'S;=`$d<%lO#{U1ebmSqQOt#{uw#{x#O#{#P#Z#{#Z#[1^#[#]#{#]#^1^#^#a#{#a#b1^#b#g#{#g#h1^#h#i#{#i#j1^#j#m#{#m#n1^#n;'S#{;'S;=`$d<%lO#{U2r[mSOY2mYZ#{Zt2mtu/Zuw2mwx/Zx#O2m#O#P/s#P#Q,w#Q;'S2m;'S;=`3h<%lO2mU3kP;=`<%l2mU3qP;=`<%l,wU3{UmSwQOt#{uw#{x#O#{#P;'S#{;'S;=`$d<%lO#{U4fW!wQmSOt#{uw#{x!_#{!_!`5O!`#O#{#P;'S#{;'S;=`$d<%lO#{U5TVmSOt#{uw#{x#O#{#P#Q5j#Q;'S#{;'S;=`$d<%lO#{U5qU!vQmSOt#{uw#{x#O#{#P;'S#{;'S;=`$d<%lO#{~6YO!r~U6aU!|QmSOt#{uw#{x#O#{#P;'S#{;'S;=`$d<%lO#{U6zUmSyQOt#{uw#{x#O#{#P;'S#{;'S;=`$d<%lO#{U7cYmSOt#{uw#{x!_#{!_!`8R!`#O#{#P#T#{#T#o7^#o;'S#{;'S;=`$d<%lO#{U8YUtQmSOt#{uw#{x#O#{#P;'S#{;'S;=`$d<%lO#{U8qZmSOt#{uw#{x!_#{!_!`8R!`#O#{#P#T#{#T#U9d#U#o7^#o;'S#{;'S;=`$d<%lO#{U9i[mSOt#{uw#{x!_#{!_!`8R!`#O#{#P#T#{#T#`7^#`#a:_#a#o7^#o;'S#{;'S;=`$d<%lO#{U:d[mSOt#{uw#{x!_#{!_!`8R!`#O#{#P#T#{#T#g7^#g#h;Y#h#o7^#o;'S#{;'S;=`$d<%lO#{U;_[mSOt#{uw#{x!_#{!_!`8R!`#O#{#P#T#{#T#X7^#X#Y<T#Y#o7^#o;'S#{;'S;=`$d<%lO#{U<[YpQmSOt#{uw#{x!_#{!_!`8R!`#O#{#P#T#{#T#o7^#o;'S#{;'S;=`$d<%lO#{^=RY!sWmSOt#{uw#{x!_#{!_!`8R!`#O#{#P#T#{#T#o7^#o;'S#{;'S;=`$d<%lO#{^=xY!uWmSOt#{uw#{x!_#{!_!`8R!`#O#{#P#T#{#T#o7^#o;'S#{;'S;=`$d<%lO#{^>o[!tWmSOt#{uw#{x!_#{!_!`8R!`#O#{#P#T#{#T#f7^#f#g?e#g#o7^#o;'S#{;'S;=`$d<%lO#{U?j[mSOt#{uw#{x!_#{!_!`8R!`#O#{#P#T#{#T#i7^#i#j;Y#j#o7^#o;'S#{;'S;=`$d<%lO#{U@gU!OQmSOt#{uw#{x#O#{#P;'S#{;'S;=`$d<%lO#{~AOO#R~",
tokenizers: [operatorTokenizer, 1, 2, 3, tokenizer, new LocalTokenGroup("[~RP!O!PU~ZO!k~~", 11)],
tokenData: "Al~R}OX$OXY$mYZ%WZp$Opq$mqr%qrs$Ost&[tu'suw$Owx'xxy'}yz(hz{$O{|)R|}$O}!O)R!O!P$O!P!Q+u!Q![)p![!]4b!]!^%W!^!}$O!}#O4{#O#P6q#P#Q6v#Q#R$O#R#S7a#S#T$O#T#Y7z#Y#Z9Y#Z#b7z#b#c=h#c#f7z#f#g>_#g#h7z#h#i?U#i#o7z#o#p$O#p#q@|#q;'S$O;'S;=`$g<%l~$O~O$O~~AgS$TUnSOt$Ouw$Ox#O$O#P;'S$O;'S;=`$g<%lO$OS$jP;=`<%l$O^$tUnS!pYOt$Ouw$Ox#O$O#P;'S$O;'S;=`$g<%lO$OU%_UnS#SQOt$Ouw$Ox#O$O#P;'S$O;'S;=`$g<%lO$OU%xUkQnSOt$Ouw$Ox#O$O#P;'S$O;'S;=`$g<%lO$O^&cZnS!qYOY&[YZ$OZt&[tu'Uuw&[wx'Ux#O&[#O#P'U#P;'S&[;'S;=`'m<%lO&[Y'ZS!qYOY'UZ;'S'U;'S;=`'g<%lO'UY'jP;=`<%l'U^'pP;=`<%l&[~'xO!{~~'}O!y~U(UUnS!vQOt$Ouw$Ox#O$O#P;'S$O;'S;=`$g<%lO$OU(oUnS#XQOt$Ouw$Ox#O$O#P;'S$O;'S;=`$g<%lO$OU)WWnSOt$Ouw$Ox!Q$O!Q![)p![#O$O#P;'S$O;'S;=`$g<%lO$OU)wYnShQOt$Ouw$Ox!O$O!O!P*g!P!Q$O!Q![)p![#O$O#P;'S$O;'S;=`$g<%lO$OU*lWnSOt$Ouw$Ox!Q$O!Q![+U![#O$O#P;'S$O;'S;=`$g<%lO$OU+]WnShQOt$Ouw$Ox!Q$O!Q![+U![#O$O#P;'S$O;'S;=`$g<%lO$OU+zWnSOt$Ouw$Ox!P$O!P!Q,d!Q#O$O#P;'S$O;'S;=`$g<%lO$OU,i^nSOY-eYZ$OZt-etu.huw-ewx.hx!P-e!P!Q$O!Q!}-e!}#O3Z#O#P0v#P;'S-e;'S;=`4[<%lO-eU-l^nSrQOY-eYZ$OZt-etu.huw-ewx.hx!P-e!P!Q1]!Q!}-e!}#O3Z#O#P0v#P;'S-e;'S;=`4[<%lO-eQ.mXrQOY.hZ!P.h!P!Q/Y!Q!}.h!}#O/w#O#P0v#P;'S.h;'S;=`1V<%lO.hQ/]P!P!Q/`Q/eUrQ#Z#[/`#]#^/`#a#b/`#g#h/`#i#j/`#m#n/`Q/zVOY/wZ#O/w#O#P0a#P#Q.h#Q;'S/w;'S;=`0p<%lO/wQ0dSOY/wZ;'S/w;'S;=`0p<%lO/wQ0sP;=`<%l/wQ0ySOY.hZ;'S.h;'S;=`1V<%lO.hQ1YP;=`<%l.hU1bWnSOt$Ouw$Ox!P$O!P!Q1z!Q#O$O#P;'S$O;'S;=`$g<%lO$OU2RbnSrQOt$Ouw$Ox#O$O#P#Z$O#Z#[1z#[#]$O#]#^1z#^#a$O#a#b1z#b#g$O#g#h1z#h#i$O#i#j1z#j#m$O#m#n1z#n;'S$O;'S;=`$g<%lO$OU3`[nSOY3ZYZ$OZt3Ztu/wuw3Zwx/wx#O3Z#O#P0a#P#Q-e#Q;'S3Z;'S;=`4U<%lO3ZU4XP;=`<%l3ZU4_P;=`<%l-eU4iUnSxQOt$Ouw$Ox#O$O#P;'S$O;'S;=`$g<%lO$OU5SW#RQnSOt$Ouw$Ox!_$O!_!`5l!`#O$O#P;'S$O;'S;=`$g<%lO$OU5qVnSOt$Ouw$Ox#O$O#P#Q6W#Q;'S$O;'S;=`$g<%lO$OU6_U#QQnSOt$Ouw$Ox#O$O#P;'S$O;'S;=`$g<%lO$O~6vO!|~U6}U#WQnSOt$Ouw$Ox#O$O#P;'S$O;'S;=`$g<%lO$OU7hUnS!PQOt$Ouw$Ox#O$O#P;'S$O;'S;=`$g<%lO$OU8PYnSOt$Ouw$Ox!_$O!_!`8o!`#O$O#P#T$O#T#o7z#o;'S$O;'S;=`$g<%lO$OU8vUuQnSOt$Ouw$Ox#O$O#P;'S$O;'S;=`$g<%lO$OU9_ZnSOt$Ouw$Ox!_$O!_!`8o!`#O$O#P#T$O#T#U:Q#U#o7z#o;'S$O;'S;=`$g<%lO$OU:V[nSOt$Ouw$Ox!_$O!_!`8o!`#O$O#P#T$O#T#`7z#`#a:{#a#o7z#o;'S$O;'S;=`$g<%lO$OU;Q[nSOt$Ouw$Ox!_$O!_!`8o!`#O$O#P#T$O#T#g7z#g#h;v#h#o7z#o;'S$O;'S;=`$g<%lO$OU;{[nSOt$Ouw$Ox!_$O!_!`8o!`#O$O#P#T$O#T#X7z#X#Y<q#Y#o7z#o;'S$O;'S;=`$g<%lO$OU<xYqQnSOt$Ouw$Ox!_$O!_!`8o!`#O$O#P#T$O#T#o7z#o;'S$O;'S;=`$g<%lO$O^=oY!}WnSOt$Ouw$Ox!_$O!_!`8o!`#O$O#P#T$O#T#o7z#o;'S$O;'S;=`$g<%lO$O^>fY#PWnSOt$Ouw$Ox!_$O!_!`8o!`#O$O#P#T$O#T#o7z#o;'S$O;'S;=`$g<%lO$O^?][#OWnSOt$Ouw$Ox!_$O!_!`8o!`#O$O#P#T$O#T#f7z#f#g@R#g#o7z#o;'S$O;'S;=`$g<%lO$OU@W[nSOt$Ouw$Ox!_$O!_!`8o!`#O$O#P#T$O#T#i7z#i#j;v#j#o7z#o;'S$O;'S;=`$g<%lO$OUATU!UQnSOt$Ouw$Ox#O$O#P;'S$O;'S;=`$g<%lO$O~AlO#_~",
tokenizers: [operatorTokenizer, 1, 2, 3, tokenizer, new LocalTokenGroup("[~RP!O!PU~ZO!u~~", 11)],
topRules: {"Program":[0,20]},
specialized: [{term: 15, get: (value: any, stack: any) => (specializeKeyword(value, stack) << 1), external: specializeKeyword},{term: 15, get: (value: keyof typeof spec_Identifier) => spec_Identifier[value] || -1}],
tokenPrec: 1164
tokenPrec: 1666
})

View File

@ -49,6 +49,91 @@ describe('Identifier', () => {
Identifier even?`)
})
test('parses bang as postfix operator on function calls', () => {
expect('read-file! test.txt').toMatchTree(`
FunctionCall
Identifier read-file
Bang !
PositionalArg
Word test.txt`)
expect('read-file!').toMatchTree(`
FunctionCallOrIdentifier
Identifier read-file
Bang !`)
expect('parse-json!').toMatchTree(`
FunctionCallOrIdentifier
Identifier parse-json
Bang !`)
})
test('bang operator does not make identifier assignable', () => {
// thing! = true should fail to parse because thing! is a FunctionCallOrIdentifier, not AssignableIdentifier
expect('thing! = true').not.toMatchTree(`
Assign
AssignableIdentifier thing
Eq =
Boolean true`)
})
test('regular identifiers without bang can still be assigned', () => {
expect('thing = true').toMatchTree(`
Assign
AssignableIdentifier thing
Eq =
Boolean true`)
})
test('bang works with multi-word identifiers', () => {
expect('read-my-file!').toMatchTree(`
FunctionCallOrIdentifier
Identifier read-my-file
Bang !`)
})
test('bang works with emoji identifiers', () => {
expect('🚀!').toMatchTree(`
FunctionCallOrIdentifier
Identifier 🚀
Bang !`)
})
test('bang in function call with multiple arguments', () => {
expect('fetch! url timeout').toMatchTree(`
FunctionCall
Identifier fetch
Bang !
PositionalArg
Identifier url
PositionalArg
Identifier timeout`)
})
test('bang is context-sensitive: only an operator at end of identifier', () => {
// Bang followed by separator = operator
expect('read-file! test.txt').toMatchTree(`
FunctionCall
Identifier read-file
Bang !
PositionalArg
Word test.txt`)
expect('foo! (bar)').toMatchTree(`
FunctionCall
Identifier foo
Bang !
PositionalArg
ParenExpr
FunctionCallOrIdentifier
Identifier bar`)
// Bang in middle of word = part of Word token
expect('hi!mom').toMatchTree(`Word hi!mom`)
expect('hello!world!').toMatchTree(`Word hello!world!`)
expect('url://example.com!').toMatchTree(`Word url://example.com!`)
})
})
describe('Unicode Symbol Support', () => {
@ -324,7 +409,7 @@ describe('Parentheses', () => {
`)
})
test('a word start with an operator', () => {
test.skip('a word start with an operator', () => {
const operators = ['*', '/', '+', '-', 'and', 'or', '=', '!=', '>=', '<=', '>', '<']
for (const operator of operators) {
expect(`find ${operator}cool*`).toMatchTree(`
@ -630,6 +715,20 @@ describe('Array destructuring', () => {
Number 1
Number 2`)
})
test('parses array destructuring with bang operator', () => {
expect('[ error content ] = read-file! test.txt').toMatchTree(`
Assign
Array
Identifier error
Identifier content
Eq =
FunctionCall
Identifier read-file
Bang !
PositionalArg
Word test.txt`)
})
})
describe('Conditional ops', () => {

View File

@ -0,0 +1,278 @@
import { expect, describe, test } from 'bun:test'
import '../shrimp.grammar' // Importing this so changes cause it to retest!
describe('try/catch/finally/throw', () => {
test('parses try with catch', () => {
expect(`try:
risky-operation
catch err:
handle-error err
end`).toMatchTree(`
TryExpr
keyword try
colon :
TryBlock
FunctionCallOrIdentifier
Identifier risky-operation
CatchExpr
keyword catch
Identifier err
colon :
TryBlock
FunctionCall
Identifier handle-error
PositionalArg
Identifier err
keyword end
`)
})
test('parses try with finally', () => {
expect(`try:
do-work
finally:
cleanup
end`).toMatchTree(`
TryExpr
keyword try
colon :
TryBlock
FunctionCallOrIdentifier
Identifier do-work
FinallyExpr
keyword finally
colon :
TryBlock
FunctionCallOrIdentifier
Identifier cleanup
keyword end
`)
})
test('parses try with catch and finally', () => {
expect(`try:
risky-operation
catch err:
handle-error err
finally:
cleanup
end`).toMatchTree(`
TryExpr
keyword try
colon :
TryBlock
FunctionCallOrIdentifier
Identifier risky-operation
CatchExpr
keyword catch
Identifier err
colon :
TryBlock
FunctionCall
Identifier handle-error
PositionalArg
Identifier err
FinallyExpr
keyword finally
colon :
TryBlock
FunctionCallOrIdentifier
Identifier cleanup
keyword end
`)
})
test('parses single-line try with catch', () => {
expect('result = try: parse-number input catch err: 0 end').toMatchTree(`
Assign
AssignableIdentifier result
Eq =
TryExpr
keyword try
colon :
FunctionCall
Identifier parse-number
PositionalArg
Identifier input
CatchExpr
keyword catch
Identifier err
colon :
Number 0
keyword end
`)
})
test('parses single-line try with finally', () => {
expect('try: work catch err: 0 finally: cleanup end').toMatchTree(`
TryExpr
keyword try
colon :
FunctionCallOrIdentifier
Identifier work
CatchExpr
keyword catch
Identifier err
colon :
Number 0
FinallyExpr
keyword finally
colon :
FunctionCallOrIdentifier
Identifier cleanup
keyword end
`)
})
test('parses throw statement with string', () => {
expect("throw 'error message'").toMatchTree(`
Throw
keyword throw
String
StringFragment error message
`)
})
test('parses throw statement with identifier', () => {
expect('throw error-object').toMatchTree(`
Throw
keyword throw
Identifier error-object
`)
})
test('parses throw statement with dict', () => {
expect('throw [type=validation-error message=failed]').toMatchTree(`
Throw
keyword throw
Dict
NamedArg
NamedArgPrefix type=
Identifier validation-error
NamedArg
NamedArgPrefix message=
Identifier failed
`)
})
test('does not parse identifiers that start with try', () => {
expect('trying = try: work catch err: 0 end').toMatchTree(`
Assign
AssignableIdentifier trying
Eq =
TryExpr
keyword try
colon :
FunctionCallOrIdentifier
Identifier work
CatchExpr
keyword catch
Identifier err
colon :
Number 0
keyword end
`)
})
})
describe('function-level exception handling', () => {
test('parses function with catch', () => {
expect(`read-file = do path:
read-data path
catch e:
empty-string
end`).toMatchTree(`
Assign
AssignableIdentifier read-file
Eq =
FunctionDef
Do do
Params
Identifier path
colon :
FunctionCall
Identifier read-data
PositionalArg
Identifier path
CatchExpr
keyword catch
Identifier e
colon :
TryBlock
FunctionCallOrIdentifier
Identifier empty-string
keyword end
`)
})
test('parses function with finally', () => {
expect(`cleanup-task = do x:
do-work x
finally:
close-resources
end`).toMatchTree(`
Assign
AssignableIdentifier cleanup-task
Eq =
FunctionDef
Do do
Params
Identifier x
colon :
FunctionCall
Identifier do-work
PositionalArg
Identifier x
FinallyExpr
keyword finally
colon :
TryBlock
FunctionCallOrIdentifier
Identifier close-resources
keyword end
`)
})
test('parses function with catch and finally', () => {
expect(`safe-operation = do x:
risky-work x
catch err:
log err
default-value
finally:
cleanup
end`).toMatchTree(`
Assign
AssignableIdentifier safe-operation
Eq =
FunctionDef
Do do
Params
Identifier x
colon :
FunctionCall
Identifier risky-work
PositionalArg
Identifier x
CatchExpr
keyword catch
Identifier err
colon :
TryBlock
FunctionCall
Identifier log
PositionalArg
Identifier err
FunctionCallOrIdentifier
Identifier default-value
FinallyExpr
keyword finally
colon :
TryBlock
FunctionCallOrIdentifier
Identifier cleanup
keyword end
`)
})
})

View File

@ -24,13 +24,13 @@ export const tokenizer = new ExternalTokenizer(
if (isDigit(ch)) return
// Don't consume things that start with - or + followed by a digit (negative/positive numbers)
if ((ch === 45 /* - */ || ch === 43) /* + */ && isDigit(input.peek(1))) return
if ((ch === 45 /* - */ || ch === 43 /* + */) && isDigit(input.peek(1))) return
const isValidStart = isLowercaseLetter(ch) || isEmojiOrUnicode(ch)
const canBeWord = stack.canShift(Word)
// Consume all word characters, tracking if it remains a valid identifier
const { pos, isValidIdentifier, stoppedAtDot } = consumeWordToken(
const { pos, isValidIdentifier, stoppedAtDot, stoppedAtBang } = consumeWordToken(
input,
isValidStart,
canBeWord
@ -53,6 +53,30 @@ export const tokenizer = new ExternalTokenizer(
return
}
// Check if we should emit Identifier before Bang operator
if (stoppedAtBang) {
const nextCh = getFullCodePoint(input, pos + 1)
const isSeparator =
isWhiteSpace(nextCh) ||
nextCh === -1 /* EOF */ ||
nextCh === 10 /* \n */ ||
nextCh === 40 /* ( */ ||
nextCh === 91 /* [ */
if (isSeparator) {
input.advance(pos)
const token = chooseIdentifierToken(input, stack)
input.acceptToken(token)
} else {
// Continue consuming - the bang is part of a longer word
const afterBang = consumeRestOfWord(input, pos + 1, canBeWord)
input.advance(afterBang)
input.acceptToken(Word)
}
return
}
// Advance past the token we consumed
input.advance(pos)
@ -89,15 +113,16 @@ const buildIdentifierText = (input: InputStream, length: number): string => {
}
// Consume word characters, tracking if it remains a valid identifier
// Returns the position after consuming, whether it's a valid identifier, and if we stopped at a dot
// Returns the position after consuming, whether it's a valid identifier, and if we stopped at a dot or bang
const consumeWordToken = (
input: InputStream,
isValidStart: boolean,
canBeWord: boolean
): { pos: number; isValidIdentifier: boolean; stoppedAtDot: boolean } => {
): { pos: number; isValidIdentifier: boolean; stoppedAtDot: boolean; stoppedAtBang: boolean } => {
let pos = getCharSize(getFullCodePoint(input, 0))
let isValidIdentifier = isValidStart
let stoppedAtDot = false
let stoppedAtBang = false
while (true) {
const ch = getFullCodePoint(input, pos)
@ -108,6 +133,12 @@ const consumeWordToken = (
break
}
// Stop at bang if we have a valid identifier (might be bang operator)
if (ch === 33 /* ! */ && isValidIdentifier) {
stoppedAtBang = true
break
}
// Stop if we hit a non-word character
if (!isWordChar(ch)) break
@ -127,7 +158,7 @@ const consumeWordToken = (
pos += getCharSize(ch)
}
return { pos, isValidIdentifier, stoppedAtDot }
return { pos, isValidIdentifier, stoppedAtDot, stoppedAtBang }
}
// Consume the rest of a word after we've decided not to treat a dot as DotGet

View File

@ -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

View File

@ -52,8 +52,14 @@ export const list = {
first: (list: any[]) => list[0] ?? null,
last: (list: any[]) => list[list.length - 1] ?? null,
rest: (list: any[]) => list.slice(1),
take: (list: any[], n: number) => list.slice(0, n),
drop: (list: any[], n: number) => list.slice(n),
take: (list: any[], n: number) => {
if (n < 0) throw new Error(`take: count must be non-negative, got ${n}`)
return list.slice(0, n)
},
drop: (list: any[], n: number) => {
if (n < 0) throw new Error(`drop: count must be non-negative, got ${n}`)
return list.slice(n)
},
append: (list: any[], item: any) => [...list, item],
prepend: (list: any[], item: any) => [item, ...list],
'index-of': (list: any[], item: any) => list.indexOf(item),

View File

@ -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),

View File

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

View File

@ -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 () => {

View File

@ -35,7 +35,7 @@ declare module 'bun:test' {
toMatchExpression(expected: string): T
toFailParse(): T
toEvaluateTo(expected: unknown, globals?: Record<string, any>): Promise<T>
toFailEvaluation(): Promise<T>
toFailEvaluation(globals?: Record<string, any>): Promise<T>
}
}
@ -99,8 +99,7 @@ expect.extend({
assert(typeof received === 'string', 'toEvaluateTo can only be used with string values')
try {
if (globals) setGlobals(Object.keys(globals))
const compiler = new Compiler(received)
const compiler = new Compiler(received, globals ? Object.keys(globals) : [])
const result = await run(compiler.bytecode, globals)
let value = VMResultToValue(result)
@ -121,12 +120,12 @@ expect.extend({
}
},
async toFailEvaluation(received) {
async toFailEvaluation(received, globals?: Record<string, any>) {
assert(typeof received === 'string', 'toFailEvaluation can only be used with string values')
try {
const compiler = new Compiler(received)
const vm = new VM(compiler.bytecode)
const compiler = new Compiler(received, globals ? Object.keys(globals) : [])
const vm = new VM(compiler.bytecode, globals)
const value = await vm.run()
return {