Compare commits

..

20 Commits

Author SHA1 Message Date
Chris Wanstrath
b0ad0a0768 you too 2025-11-25 16:57:43 -08:00
Chris Wanstrath
ccf8f41544 match lezer API 2025-11-25 16:57:18 -08:00
Chris Wanstrath
6da00dd3c8 pipes end expressions 2025-11-25 16:53:17 -08:00
Chris Wanstrath
d4cb86b50b switch bin/shrimp to new parser 2025-11-25 16:50:32 -08:00
Chris Wanstrath
c7d4db8528 we get globals for free now 2025-11-25 16:43:07 -08:00
Chris Wanstrath
de0d43a1d6 use new parser in curlys 2025-11-25 16:41:42 -08:00
Chris Wanstrath
4d718ef12b keywords are magical 2025-11-25 16:36:38 -08:00
Chris Wanstrath
4b57728072 fix | 2025-11-25 16:35:26 -08:00
Chris Wanstrath
2c0723b8a3 fix dot.get + thing 2025-11-25 16:27:18 -08:00
Chris Wanstrath
c6e5c44755 throw takes an expression 2025-11-25 16:08:14 -08:00
Chris Wanstrath
1d2c85b19c fix curly strings 2025-11-25 16:04:58 -08:00
Chris Wanstrath
52e100cab3 regex flags, bad regexs become Words 2025-11-25 16:00:06 -08:00
Chris Wanstrath
5010a9584c fix bitwise precedence 2025-11-25 15:51:45 -08:00
Chris Wanstrath
4b6f6a127f disable errors... for now! 2025-11-25 13:27:56 -08:00
Chris Wanstrath
38eaed490c make more compiler tests pass 2025-11-25 13:27:53 -08:00
Chris Wanstrath
16cb47ddcc do allowed in arg/dict values 2025-11-25 13:16:41 -08:00
Chris Wanstrath
a5b2802c0c try to match lezer API more closely 2025-11-25 13:08:28 -08:00
Chris Wanstrath
1b80a159c5 hwhitespace 2025-11-25 11:30:27 -08:00
Chris Wanstrath
109c7ff9f6 minor 2025-11-24 16:15:37 -08:00
49919e9f85 new parser(-ish) 2025-11-13 22:34:04 -08:00
20 changed files with 682 additions and 360 deletions

View File

@ -1,7 +1,7 @@
#!/usr/bin/env bun #!/usr/bin/env bun
import { colors, globals as prelude } from '../src/prelude' import { colors, globals as prelude } from '../src/prelude'
import { treeToString } from '../src/utils/tree' import { treeToString2 } from '../src/utils/tree'
import { runCode, runFile, compileFile, parseCode } from '../src' import { runCode, runFile, compileFile, parseCode } from '../src'
import { resolve } from 'path' import { resolve } from 'path'
import { bytecodeToString } from 'reefvm' import { bytecodeToString } from 'reefvm'
@ -143,7 +143,7 @@ async function main() {
process.exit(1) process.exit(1)
} }
const input = readFileSync(file, 'utf-8') const input = readFileSync(file, 'utf-8')
console.log(treeToString(parseCode(input), input)) console.log(treeToString2(parseCode(input), input))
return return
} }

View File

@ -1,8 +1,10 @@
import { CompilerError } from '#compiler/compilerError.ts' import { CompilerError } from '#compiler/compilerError.ts'
import { parseToTree as parse } from '#parser/parser2' import { parse } from '#parser/parser2'
import { Tree, SyntaxNode } from '#parser/node' import { SyntaxNode, Tree } from '#parser/node'
import { parser } from '#parser/shrimp.ts'
import * as terms from '#parser/shrimp.terms' import * as terms from '#parser/shrimp.terms'
import { setGlobals } from '#parser/tokenizer' import { setGlobals } from '#parser/tokenizer'
import { tokenizeCurlyString } from '#parser/curlyTokenizer'
import { assert, errorMessage } from '#utils/utils' import { assert, errorMessage } from '#utils/utils'
import { toBytecode, type Bytecode, type ProgramItem, bytecodeToString } from 'reefvm' import { toBytecode, type Bytecode, type ProgramItem, bytecodeToString } from 'reefvm'
import { import {
@ -62,7 +64,8 @@ export class Compiler {
constructor(public input: string, globals?: string[] | Record<string, any>) { constructor(public input: string, globals?: string[] | Record<string, any>) {
try { try {
if (globals) setGlobals(Array.isArray(globals) ? globals : Object.keys(globals)) if (globals) setGlobals(Array.isArray(globals) ? globals : Object.keys(globals))
const cst = parse(input) const ast = parse(input)
const cst = new Tree(ast)
// const errors = checkTreeForErrors(cst) // const errors = checkTreeForErrors(cst)
// const firstError = errors[0] // const firstError = errors[0]
@ -88,8 +91,8 @@ export class Compiler {
} }
#compileCst(cst: Tree, input: string) { #compileCst(cst: Tree, input: string) {
const isProgram = cst.topNode.typeId === terms.Program const isProgram = cst.topNode.type.id === terms.Program
assert(isProgram, `Expected Program node, got ${cst.topNode.type}`) assert(isProgram, `Expected Program node, got ${cst.topNode.type.name}`)
let child = cst.topNode.firstChild let child = cst.topNode.firstChild
while (child) { while (child) {
@ -104,7 +107,7 @@ export class Compiler {
const value = input.slice(node.from, node.to) const value = input.slice(node.from, node.to)
if (DEBUG) console.log(`🫦 ${node.name}: ${value}`) if (DEBUG) console.log(`🫦 ${node.name}: ${value}`)
switch (node.typeId) { switch (node.type.id) {
case terms.Number: case terms.Number:
// Handle sign prefix for hex, binary, and octal literals // Handle sign prefix for hex, binary, and octal literals
// Number() doesn't parse '-0xFF', '+0xFF', '-0o77', etc. correctly // Number() doesn't parse '-0xFF', '+0xFF', '-0o77', etc. correctly
@ -123,6 +126,9 @@ export class Compiler {
return [[`PUSH`, numberValue]] return [[`PUSH`, numberValue]]
case terms.String: { case terms.String: {
if (node.firstChild?.type.id === terms.CurlyString)
return this.#compileCurlyString(value, input)
const { parts, hasInterpolation } = getStringParts(node, input) const { parts, hasInterpolation } = getStringParts(node, input)
// Simple string without interpolation or escapes - extract text directly // Simple string without interpolation or escapes - extract text directly
@ -137,7 +143,7 @@ export class Compiler {
parts.forEach((part) => { parts.forEach((part) => {
const partValue = input.slice(part.from, part.to) const partValue = input.slice(part.from, part.to)
switch (part.typeId) { switch (part.type.id) {
case terms.StringFragment: case terms.StringFragment:
// Plain text fragment - just push as-is // Plain text fragment - just push as-is
instructions.push(['PUSH', partValue]) instructions.push(['PUSH', partValue])
@ -161,7 +167,7 @@ export class Compiler {
default: default:
throw new CompilerError( throw new CompilerError(
`Unexpected string part: ${part.type}`, `Unexpected string part: ${part.type.name}`,
part.from, part.from,
part.to part.to
) )
@ -218,7 +224,7 @@ export class Compiler {
instructions.push(['TRY_LOAD', objectName]) instructions.push(['TRY_LOAD', objectName])
const flattenProperty = (prop: SyntaxNode): void => { const flattenProperty = (prop: SyntaxNode): void => {
if (prop.typeId === terms.DotGet) { if (prop.type.id === terms.DotGet) {
const nestedParts = getDotGetParts(prop, input) const nestedParts = getDotGetParts(prop, input)
const nestedObjectValue = input.slice(nestedParts.object.from, nestedParts.object.to) const nestedObjectValue = input.slice(nestedParts.object.from, nestedParts.object.to)
@ -227,7 +233,7 @@ export class Compiler {
flattenProperty(nestedParts.property) flattenProperty(nestedParts.property)
} else { } else {
if (prop.typeId === terms.ParenExpr) { if (prop.type.id === terms.ParenExpr) {
instructions.push(...this.#compileNode(prop, input)) instructions.push(...this.#compileNode(prop, input))
} else { } else {
const propertyValue = input.slice(prop.from, prop.to) const propertyValue = input.slice(prop.from, prop.to)
@ -436,7 +442,7 @@ export class Compiler {
} }
case terms.FunctionCallOrIdentifier: { case terms.FunctionCallOrIdentifier: {
if (node.firstChild?.typeId === terms.DotGet) { if (node.firstChild?.type.id === terms.DotGet) {
const instructions: ProgramItem[] = [] const instructions: ProgramItem[] = []
const callLabel: Label = `.call_dotget_${++this.labelCount}` const callLabel: Label = `.call_dotget_${++this.labelCount}`
const afterLabel: Label = `.after_dotget_${++this.labelCount}` const afterLabel: Label = `.after_dotget_${++this.labelCount}`
@ -527,20 +533,20 @@ export class Compiler {
instructions.push([`${fnLabel}:`]) instructions.push([`${fnLabel}:`])
instructions.push( instructions.push(
...block ...block
.filter((x) => x.type !== 'keyword') .filter((x) => x.type.name !== 'keyword')
.map((x) => this.#compileNode(x!, input)) .map((x) => this.#compileNode(x!, input))
.flat() .flat()
) )
instructions.push(['RETURN']) instructions.push(['RETURN'])
instructions.push([`${afterLabel}:`]) instructions.push([`${afterLabel}:`])
if (fn?.typeId === terms.FunctionCallOrIdentifier) { if (fn?.type.id === terms.FunctionCallOrIdentifier) {
instructions.push(['LOAD', input.slice(fn!.from, fn!.to)]) instructions.push(['LOAD', input.slice(fn!.from, fn!.to)])
instructions.push(['MAKE_FUNCTION', [], fnLabel]) instructions.push(['MAKE_FUNCTION', [], fnLabel])
instructions.push(['PUSH', 1]) instructions.push(['PUSH', 1])
instructions.push(['PUSH', 0]) instructions.push(['PUSH', 0])
instructions.push(['CALL']) instructions.push(['CALL'])
} else if (fn?.typeId === terms.FunctionCall) { } else if (fn?.type.id === terms.FunctionCall) {
let body = this.#compileNode(fn!, input) let body = this.#compileNode(fn!, input)
const namedArgCount = (body[body.length - 2]![1] as number) * 2 const namedArgCount = (body[body.length - 2]![1] as number) * 2
const startSlice = body.length - namedArgCount - 3 const startSlice = body.length - namedArgCount - 3
@ -733,11 +739,11 @@ export class Compiler {
instructions.push(...this.#compileNode(identifierNode, input)) instructions.push(...this.#compileNode(identifierNode, input))
const isUnderscoreInPositionalArgs = positionalArgs.some( const isUnderscoreInPositionalArgs = positionalArgs.some(
(arg) => arg.typeId === terms.Underscore (arg) => arg.type.id === terms.Underscore
) )
const isUnderscoreInNamedArgs = namedArgs.some((arg) => { const isUnderscoreInNamedArgs = namedArgs.some((arg) => {
const { valueNode } = getNamedArgParts(arg, input) const { valueNode } = getNamedArgParts(arg, input)
return valueNode.typeId === terms.Underscore return valueNode.type.id === terms.Underscore
}) })
const shouldPushPositionalArg = !isUnderscoreInPositionalArgs && !isUnderscoreInNamedArgs const shouldPushPositionalArg = !isUnderscoreInPositionalArgs && !isUnderscoreInNamedArgs
@ -748,7 +754,7 @@ export class Compiler {
} }
positionalArgs.forEach((arg) => { positionalArgs.forEach((arg) => {
if (arg.typeId === terms.Underscore) { if (arg.type.id === terms.Underscore) {
instructions.push(['LOAD', pipeValName]) instructions.push(['LOAD', pipeValName])
} else { } else {
instructions.push(...this.#compileNode(arg, input)) instructions.push(...this.#compileNode(arg, input))
@ -758,7 +764,7 @@ export class Compiler {
namedArgs.forEach((arg) => { namedArgs.forEach((arg) => {
const { name, valueNode } = getNamedArgParts(arg, input) const { name, valueNode } = getNamedArgParts(arg, input)
instructions.push(['PUSH', name]) instructions.push(['PUSH', name])
if (valueNode.typeId === terms.Underscore) { if (valueNode.type.id === terms.Underscore) {
instructions.push(['LOAD', pipeValName]) instructions.push(['LOAD', pipeValName])
} else { } else {
instructions.push(...this.#compileNode(valueNode, input)) instructions.push(...this.#compileNode(valueNode, input))
@ -780,7 +786,7 @@ export class Compiler {
// = can be a valid word, and is also valid inside words, so for now we cheat // = can be a valid word, and is also valid inside words, so for now we cheat
// and check for arrays that look like `[ = ]` to interpret them as // and check for arrays that look like `[ = ]` to interpret them as
// empty dicts // empty dicts
if (children.length === 1 && children[0]!.typeId === terms.Word) { if (children.length === 1 && children[0]!.type.id === terms.Word) {
const child = children[0]! const child = children[0]!
if (input.slice(child.from, child.to) === '=') { if (input.slice(child.from, child.to) === '=') {
return [['MAKE_DICT', 0]] return [['MAKE_DICT', 0]]
@ -832,8 +838,8 @@ export class Compiler {
case terms.Import: { case terms.Import: {
const instructions: ProgramItem[] = [] const instructions: ProgramItem[] = []
const [_import, ...nodes] = getAllChildren(node) const [_import, ...nodes] = getAllChildren(node)
const args = nodes.filter(node => node.typeId === terms.Identifier) const args = nodes.filter(node => node.type.id === terms.Identifier)
const namedArgs = nodes.filter(node => node.typeId === terms.NamedArg) const namedArgs = nodes.filter(node => node.type.id === terms.NamedArg)
instructions.push(['LOAD', 'import']) instructions.push(['LOAD', 'import'])
@ -860,7 +866,7 @@ export class Compiler {
default: default:
throw new CompilerError( throw new CompilerError(
`Compiler doesn't know how to handle a "${node.type}" (${node.typeId}) node.`, `Compiler doesn't know how to handle a "${node.type.name}" (${node.type.id}) node.`,
node.from, node.from,
node.to node.to
) )
@ -914,4 +920,26 @@ export class Compiler {
return instructions return instructions
} }
#compileCurlyString(value: string, input: string): ProgramItem[] {
const instructions: ProgramItem[] = []
const nodes = tokenizeCurlyString(value)
nodes.forEach((node) => {
if (typeof node === 'string') {
instructions.push(['PUSH', node])
} else {
const [input, topNode] = node
let child = topNode.firstChild
while (child) {
instructions.push(...this.#compileNode(child, input))
child = child.nextSibling
}
}
})
instructions.push(['STR_CONCAT', nodes.length])
return instructions
}
} }

View File

@ -1,6 +1,6 @@
import { CompilerError } from '#compiler/compilerError.ts' import { CompilerError } from '#compiler/compilerError.ts'
import type { SyntaxNode, Tree } from '#parser/node'
import * as terms from '#parser/shrimp.terms' import * as terms from '#parser/shrimp.terms'
import type { SyntaxNode, Tree } from '#parser/node'
export const checkTreeForErrors = (tree: Tree): CompilerError[] => { export const checkTreeForErrors = (tree: Tree): CompilerError[] => {
const errors: CompilerError[] = [] const errors: CompilerError[] = []
@ -24,7 +24,7 @@ export const getAllChildren = (node: SyntaxNode): SyntaxNode[] => {
child = child.nextSibling child = child.nextSibling
} }
return children.filter((n) => n.typeId !== terms.Comment) return children.filter((n) => n.type.id !== terms.Comment)
} }
export const getBinaryParts = (node: SyntaxNode) => { export const getBinaryParts = (node: SyntaxNode) => {
@ -51,14 +51,15 @@ export const getAssignmentParts = (node: SyntaxNode) => {
} }
// array destructuring // array destructuring
if (left && left.typeId === terms.Array) { if (left && left.type.id === terms.Array) {
const identifiers = getAllChildren(left).filter((child) => child.typeId === terms.Identifier) const identifiers = getAllChildren(left).filter((child) => child.type.id === terms.Identifier)
return { arrayPattern: identifiers, right } return { arrayPattern: identifiers, right }
} }
if (!left || left.typeId !== terms.AssignableIdentifier) { if (!left || left.type.id !== terms.AssignableIdentifier) {
throw new CompilerError( throw new CompilerError(
`Assign left child must be an AssignableIdentifier or Array, got ${left ? left.type : 'none' `Assign left child must be an AssignableIdentifier or Array, got ${
left ? left.type.name : 'none'
}`, }`,
node.from, node.from,
node.to node.to
@ -72,9 +73,10 @@ export const getCompoundAssignmentParts = (node: SyntaxNode) => {
const children = getAllChildren(node) const children = getAllChildren(node)
const [left, operator, right] = children const [left, operator, right] = children
if (!left || left.typeId !== terms.AssignableIdentifier) { if (!left || left.type.id !== terms.AssignableIdentifier) {
throw new CompilerError( throw new CompilerError(
`CompoundAssign left child must be an AssignableIdentifier, got ${left ? left.type : 'none' `CompoundAssign left child must be an AssignableIdentifier, got ${
left ? left.type.name : 'none'
}`, }`,
node.from, node.from,
node.to node.to
@ -103,9 +105,9 @@ export const getFunctionDefParts = (node: SyntaxNode, input: string) => {
} }
const paramNames = getAllChildren(paramsNode).map((param) => { const paramNames = getAllChildren(paramsNode).map((param) => {
if (param.typeId !== terms.Identifier && param.typeId !== terms.NamedParam) { if (param.type.id !== terms.Identifier && param.type.id !== terms.NamedParam) {
throw new CompilerError( throw new CompilerError(
`FunctionDef params must be Identifier or NamedParam, got ${param.type}`, `FunctionDef params must be Identifier or NamedParam, got ${param.type.name}`,
param.from, param.from,
param.to param.to
) )
@ -122,7 +124,7 @@ export const getFunctionDefParts = (node: SyntaxNode, input: string) => {
let finallyBody: SyntaxNode | undefined let finallyBody: SyntaxNode | undefined
for (const child of rest) { for (const child of rest) {
if (child.typeId === terms.CatchExpr) { if (child.type.id === terms.CatchExpr) {
catchExpr = child catchExpr = child
const catchChildren = getAllChildren(child) const catchChildren = getAllChildren(child)
const [_catchKeyword, identifierNode, _colon, body] = catchChildren const [_catchKeyword, identifierNode, _colon, body] = catchChildren
@ -135,7 +137,7 @@ export const getFunctionDefParts = (node: SyntaxNode, input: string) => {
} }
catchVariable = input.slice(identifierNode.from, identifierNode.to) catchVariable = input.slice(identifierNode.from, identifierNode.to)
catchBody = body catchBody = body
} else if (child.typeId === terms.FinallyExpr) { } else if (child.type.id === terms.FinallyExpr) {
finallyExpr = child finallyExpr = child
const finallyChildren = getAllChildren(child) const finallyChildren = getAllChildren(child)
const [_finallyKeyword, _colon, body] = finallyChildren const [_finallyKeyword, _colon, body] = finallyChildren
@ -147,7 +149,7 @@ export const getFunctionDefParts = (node: SyntaxNode, input: string) => {
) )
} }
finallyBody = body finallyBody = body
} else if (child.type === 'keyword' && input.slice(child.from, child.to) === 'end') { } else if (child.type.name === 'keyword' && input.slice(child.from, child.to) === 'end') {
// Skip the end keyword // Skip the end keyword
} else { } else {
bodyNodes.push(child) bodyNodes.push(child)
@ -164,9 +166,9 @@ export const getFunctionCallParts = (node: SyntaxNode, input: string) => {
throw new CompilerError(`FunctionCall expected at least 1 child, got 0`, node.from, node.to) throw new CompilerError(`FunctionCall expected at least 1 child, got 0`, node.from, node.to)
} }
const namedArgs = args.filter((arg) => arg.typeId === terms.NamedArg) const namedArgs = args.filter((arg) => arg.type.id === terms.NamedArg)
const positionalArgs = args const positionalArgs = args
.filter((arg) => arg.typeId === terms.PositionalArg) .filter((arg) => arg.type.id === terms.PositionalArg)
.map((arg) => { .map((arg) => {
const child = arg.firstChild const child = arg.firstChild
if (!child) throw new CompilerError(`PositionalArg has no child`, arg.from, arg.to) if (!child) throw new CompilerError(`PositionalArg has no child`, arg.from, arg.to)
@ -207,16 +209,16 @@ export const getIfExprParts = (node: SyntaxNode, input: string) => {
rest.forEach((child) => { rest.forEach((child) => {
const parts = getAllChildren(child) const parts = getAllChildren(child)
if (child.typeId === terms.ElseExpr) { if (child.type.id === terms.ElseExpr) {
if (parts.length !== 3) { if (parts.length !== 3) {
const message = `ElseExpr expected 1 child, got ${parts.length}` const message = `ElseExpr expected 1 child, got ${parts.length}`
throw new CompilerError(message, child.from, child.to) throw new CompilerError(message, child.from, child.to)
} }
elseThenBlock = parts.at(-1) elseThenBlock = parts.at(-1)
} else if (child.typeId === terms.ElseIfExpr) { } else if (child.type.id === terms.ElseIfExpr) {
const [_else, _if, conditional, _colon, thenBlock] = parts const [_else, _if, conditional, _colon, thenBlock] = parts
if (!conditional || !thenBlock) { if (!conditional || !thenBlock) {
const names = parts.map((p) => p.type).join(', ') const names = parts.map((p) => p.type.name).join(', ')
const message = `ElseIfExpr expected conditional and thenBlock, got ${names}` const message = `ElseIfExpr expected conditional and thenBlock, got ${names}`
throw new CompilerError(message, child.from, child.to) throw new CompilerError(message, child.from, child.to)
} }
@ -248,10 +250,10 @@ export const getStringParts = (node: SyntaxNode, input: string) => {
// The text is just between the quotes // The text is just between the quotes
const parts = children.filter((child) => { const parts = children.filter((child) => {
return ( return (
child.typeId === terms.StringFragment || child.type.id === terms.StringFragment ||
child.typeId === terms.Interpolation || child.type.id === terms.Interpolation ||
child.typeId === terms.EscapeSeq || child.type.id === terms.EscapeSeq ||
child.typeId === terms.CurlyString child.type.id === terms.CurlyString
) )
}) })
@ -259,13 +261,13 @@ export const getStringParts = (node: SyntaxNode, input: string) => {
// Validate each part is the expected type // Validate each part is the expected type
parts.forEach((part) => { parts.forEach((part) => {
if ( if (
part.typeId !== terms.StringFragment && part.type.id !== terms.StringFragment &&
part.typeId !== terms.Interpolation && part.type.id !== terms.Interpolation &&
part.typeId !== terms.EscapeSeq && part.type.id !== terms.EscapeSeq &&
part.typeId !== terms.CurlyString part.type.id !== terms.CurlyString
) { ) {
throw new CompilerError( throw new CompilerError(
`String child must be StringFragment, Interpolation, or EscapeSeq, got ${part.type}`, `String child must be StringFragment, Interpolation, or EscapeSeq, got ${part.type.name}`,
part.from, part.from,
part.to part.to
) )
@ -275,7 +277,7 @@ export const getStringParts = (node: SyntaxNode, input: string) => {
// hasInterpolation means the string has interpolation ($var) or escape sequences (\n) // hasInterpolation means the string has interpolation ($var) or escape sequences (\n)
// A simple string like 'hello' has one StringFragment but no interpolation // A simple string like 'hello' has one StringFragment but no interpolation
const hasInterpolation = parts.some( const hasInterpolation = parts.some(
(p) => p.typeId === terms.Interpolation || p.typeId === terms.EscapeSeq (p) => p.type.id === terms.Interpolation || p.type.id === terms.EscapeSeq
) )
return { parts, hasInterpolation } return { parts, hasInterpolation }
} }
@ -292,17 +294,17 @@ export const getDotGetParts = (node: SyntaxNode, input: string) => {
) )
} }
if (object.typeId !== terms.IdentifierBeforeDot && object.typeId !== terms.Dollar) { if (object.type.id !== terms.IdentifierBeforeDot && object.type.id !== terms.Dollar) {
throw new CompilerError( throw new CompilerError(
`DotGet object must be an IdentifierBeforeDot, got ${object.type}`, `DotGet object must be an IdentifierBeforeDot, got ${object.type.name}`,
object.from, object.from,
object.to object.to
) )
} }
if (![terms.Identifier, terms.Number, terms.ParenExpr, terms.DotGet].includes(property.typeId)) { if (![terms.Identifier, terms.Number, terms.ParenExpr, terms.DotGet].includes(property.type.id)) {
throw new CompilerError( throw new CompilerError(
`DotGet property must be an Identifier, Number, ParenExpr, or DotGet, got ${property.type}`, `DotGet property must be an Identifier, Number, ParenExpr, or DotGet, got ${property.type.name}`,
property.from, property.from,
property.to property.to
) )
@ -334,7 +336,7 @@ export const getTryExprParts = (node: SyntaxNode, input: string) => {
let finallyBody: SyntaxNode | undefined let finallyBody: SyntaxNode | undefined
rest.forEach((child) => { rest.forEach((child) => {
if (child.typeId === terms.CatchExpr) { if (child.type.id === terms.CatchExpr) {
catchExpr = child catchExpr = child
const catchChildren = getAllChildren(child) const catchChildren = getAllChildren(child)
const [_catchKeyword, identifierNode, _colon, body] = catchChildren const [_catchKeyword, identifierNode, _colon, body] = catchChildren
@ -347,7 +349,7 @@ export const getTryExprParts = (node: SyntaxNode, input: string) => {
} }
catchVariable = input.slice(identifierNode.from, identifierNode.to) catchVariable = input.slice(identifierNode.from, identifierNode.to)
catchBody = body catchBody = body
} else if (child.typeId === terms.FinallyExpr) { } else if (child.type.id === terms.FinallyExpr) {
finallyExpr = child finallyExpr = child
const finallyChildren = getAllChildren(child) const finallyChildren = getAllChildren(child)
const [_finallyKeyword, _colon, body] = finallyChildren const [_finallyKeyword, _colon, body] = finallyChildren

View File

@ -1,15 +1,15 @@
import { readFileSync } from 'fs' import { readFileSync } from 'fs'
import { VM, fromValue, toValue, isValue, type Bytecode } from 'reefvm' import { VM, fromValue, toValue, isValue, type Bytecode } from 'reefvm'
import { type Tree } from '@lezer/common'
import { Compiler } from '#compiler/compiler' import { Compiler } from '#compiler/compiler'
import { parser } from '#parser/shrimp' import { parse } from '#parser/parser2'
import { type SyntaxNode, Tree } from '#parser/node'
import { globals as parserGlobals, setGlobals as setParserGlobals } from '#parser/tokenizer' import { globals as parserGlobals, setGlobals as setParserGlobals } from '#parser/tokenizer'
import { globals as prelude } from '#prelude' import { globals as prelude } from '#prelude'
export { Compiler } from '#compiler/compiler' export { Compiler } from '#compiler/compiler'
export { parser } from '#parser/shrimp' export { parse } from '#parser/parser2'
export { type SyntaxNode, Tree } from '#parser/node'
export { globals as prelude } from '#prelude' export { globals as prelude } from '#prelude'
export type { Tree } from '@lezer/common'
export { type Value, type Bytecode } from 'reefvm' export { type Value, type Bytecode } from 'reefvm'
export { toValue, fromValue, isValue, Scope, VM, bytecodeToString } from 'reefvm' export { toValue, fromValue, isValue, Scope, VM, bytecodeToString } from 'reefvm'
@ -41,7 +41,7 @@ export class Shrimp {
return isValue(result) ? fromValue(result, this.vm) : result return isValue(result) ? fromValue(result, this.vm) : result
} }
parse(code: string): Tree { parse(code: string): SyntaxNode {
return parseCode(code, this.globals) return parseCode(code, this.globals)
} }
@ -105,8 +105,8 @@ export function parseCode(code: string, globals?: Record<string, any>): Tree {
const globalNames = [...Object.keys(prelude), ...(globals ? Object.keys(globals) : [])] const globalNames = [...Object.keys(prelude), ...(globals ? Object.keys(globals) : [])]
setParserGlobals(globalNames) setParserGlobals(globalNames)
const result = parser.parse(code) const result = parse(code)
setParserGlobals(oldGlobals) setParserGlobals(oldGlobals)
return result return new Tree(result)
} }

View File

@ -1,5 +1,6 @@
import { parser } from '#parser/shrimp.ts' import { parser } from '#parser/shrimp.ts'
import type { SyntaxNode } from '@lezer/common' import { parse } from '#parser/parser2'
import type { SyntaxNode } from '#parser/node'
import { isIdentStart, isIdentChar } from './tokenizer' import { isIdentStart, isIdentChar } from './tokenizer'
// Turns a { curly string } into strings and nodes for interpolation // Turns a { curly string } into strings and nodes for interpolation
@ -37,7 +38,7 @@ export const tokenizeCurlyString = (value: string): (string | [string, SyntaxNod
} }
const input = value.slice(start + 2, pos) // skip '$(' const input = value.slice(start + 2, pos) // skip '$('
tokens.push([input, parser.parse(input).topNode]) tokens.push([input, parse(input)])
start = ++pos // skip ')' start = ++pos // skip ')'
} else { } else {
char = value[++pos] char = value[++pos]
@ -48,7 +49,7 @@ export const tokenizeCurlyString = (value: string): (string | [string, SyntaxNod
char = value[++pos] char = value[++pos]
const input = value.slice(start + 1, pos) // skip '$' const input = value.slice(start + 1, pos) // skip '$'
tokens.push([input, parser.parse(input).topNode]) tokens.push([input, parse(input)])
start = pos-- // backtrack and start over start = pos-- // backtrack and start over
} }
} }

View File

@ -1,5 +1,5 @@
import { type Token, TokenType } from './tokenizer2' import { type Token, TokenType } from './tokenizer2'
import { nameToId } from './terms' import * as term from './shrimp.terms'
export type NodeType = export type NodeType =
| 'Program' | 'Program'
@ -58,6 +58,7 @@ export type NodeType =
| 'Import' | 'Import'
| 'Do' | 'Do'
| 'Underscore'
| 'colon' | 'colon'
| 'keyword' | 'keyword'
| 'operator' | 'operator'
@ -113,32 +114,224 @@ export const operators: Record<string, any> = {
export class Tree { export class Tree {
constructor(public topNode: SyntaxNode) { } constructor(public topNode: SyntaxNode) { }
get length(): number {
return this.topNode.to
}
cursor() {
return {
type: this.topNode.type,
from: this.topNode.from,
to: this.topNode.to,
node: this.topNode,
}
}
}
// TODO: TEMPORARY SHIM
class SyntaxNodeType {
constructor(public nodeType: NodeType) { }
is(other: string) {
return this.nodeType === other
}
get id(): number {
switch (this.nodeType) {
case 'Program':
return term.Program
case 'Block':
return term.Block
case 'FunctionCall':
return term.FunctionCall
case 'FunctionCallOrIdentifier':
return term.FunctionCallOrIdentifier
case 'FunctionCallWithBlock':
return term.FunctionCallWithBlock
case 'PositionalArg':
return term.PositionalArg
case 'NamedArg':
return term.NamedArg
case 'FunctionDef':
return term.FunctionDef
case 'Params':
return term.Params
case 'NamedParam':
return term.NamedParam
case 'Null':
return term.Null
case 'Boolean':
return term.Boolean
case 'Number':
return term.Number
case 'String':
return term.String
case 'StringFragment':
return term.StringFragment
case 'CurlyString':
return term.CurlyString
case 'DoubleQuote':
return term.DoubleQuote
case 'EscapeSeq':
return term.EscapeSeq
case 'Interpolation':
return term.Interpolation
case 'Regex':
return term.Regex
case 'Identifier':
return term.Identifier
case 'AssignableIdentifier':
return term.AssignableIdentifier
case 'IdentifierBeforeDot':
return term.IdentifierBeforeDot
case 'Word':
return term.Word
case 'Array':
return term.Array
case 'Dict':
return term.Dict
case 'Comment':
return term.Comment
case 'BinOp':
return term.BinOp
case 'ConditionalOp':
return term.ConditionalOp
case 'ParenExpr':
return term.ParenExpr
case 'Assign':
return term.Assign
case 'CompoundAssign':
return term.CompoundAssign
case 'DotGet':
return term.DotGet
case 'PipeExpr':
return term.PipeExpr
case 'IfExpr':
return term.IfExpr
case 'ElseIfExpr':
return term.ElseIfExpr
case 'ElseExpr':
return term.ElseExpr
case 'WhileExpr':
return term.WhileExpr
case 'TryExpr':
return term.TryExpr
case 'CatchExpr':
return term.CatchExpr
case 'FinallyExpr':
return term.FinallyExpr
case 'Throw':
return term.Throw
case 'Eq':
return term.Eq
case 'Modulo':
return term.Modulo
case 'Plus':
return term.Plus
case 'Star':
return term.Star
case 'Slash':
return term.Slash
case 'Import':
return term.Import
case 'Do':
return term.Do
case 'Underscore':
return term.Underscore
case 'colon':
return term.colon
case 'keyword':
return term.keyword
}
return 0
}
get name(): string {
return this.nodeType
}
} }
export class SyntaxNode { export class SyntaxNode {
type: NodeType #type: NodeType
from: number from: number
to: number to: number
parent: SyntaxNode | null parent: SyntaxNode | null
children: SyntaxNode[] = [] children: SyntaxNode[] = []
constructor(type: NodeType, from: number, to: number, parent: SyntaxNode | null = null) { constructor(type: NodeType, from: number, to: number, parent: SyntaxNode | null = null) {
this.type = type this.#type = type
this.from = from this.from = from
this.to = to this.to = to
this.parent = parent this.parent = parent
} }
get typeId(): number {
return nameToId(this.type)
}
static from(token: Token, parent?: SyntaxNode): SyntaxNode { static from(token: Token, parent?: SyntaxNode): SyntaxNode {
return new SyntaxNode(TokenType[token.type] as NodeType, token.from, token.to, parent ?? null) return new SyntaxNode(TokenType[token.type] as NodeType, token.from, token.to, parent ?? null)
} }
get type(): SyntaxNodeType {
return new SyntaxNodeType(this.#type)
}
set type(name: NodeType) {
this.#type = name
}
get name(): string { get name(): string {
return this.type return this.type.name
} }
get isError(): boolean { get isError(): boolean {
@ -179,7 +372,7 @@ export class SyntaxNode {
} }
toString(): string { toString(): string {
return this.type return this.type.name
} }
} }
@ -200,7 +393,7 @@ export const precedence: Record<string, number> = {
// Nullish coalescing // Nullish coalescing
'??': 35, '??': 35,
// Bitwise shift (lower precedence than addition) // Bitwise shifts (lower precedence than addition)
'<<': 37, '<<': 37,
'>>': 37, '>>': 37,
'>>>': 37, '>>>': 37,
@ -209,7 +402,7 @@ export const precedence: Record<string, number> = {
'+': 40, '+': 40,
'-': 40, '-': 40,
// Bitwise AND/OR/XOR (between addition and multiplication) // Bitwise AND/OR/XOR (higher precedence than addition)
'band': 45, 'band': 45,
'bor': 45, 'bor': 45,
'bxor': 45, 'bxor': 45,

View File

@ -1,5 +1,5 @@
import { Scanner, type Token, TokenType } from './tokenizer2' import { Scanner, type Token, TokenType } from './tokenizer2'
import { Tree, SyntaxNode, operators, precedence, conditionals, compounds } from './node' import { SyntaxNode, operators, precedence, conditionals, compounds } from './node'
import { globals } from './tokenizer' import { globals } from './tokenizer'
import { parseString } from './stringParser' import { parseString } from './stringParser'
@ -10,10 +10,6 @@ export const parse = (input: string): SyntaxNode => {
return parser.parse(input) return parser.parse(input)
} }
export const parseToTree = (input: string): Tree => {
return new Tree(parse(input))
}
class Scope { class Scope {
parent?: Scope parent?: Scope
set = new Set<string>() set = new Set<string>()
@ -122,19 +118,17 @@ export class Parser {
expr = this.exprWithPrecedence() expr = this.exprWithPrecedence()
// check for destructuring // check for destructuring
if (expr.type === 'Array' && this.is($T.Operator, '=')) if (expr.type.is('Array') && this.is($T.Operator, '='))
return this.destructure(expr) return this.destructure(expr)
// check for parens function call // check for parens function call
// ex: (ref my-func) my-arg // ex: (ref my-func) my-arg
// but not if followed by operator: (x) + 1 if (expr.type.is('ParenExpr') && !this.isExprEnd())
if (expr.type === 'ParenExpr' && !this.isExprEnd() && !this.is($T.Operator))
expr = this.functionCall(expr) expr = this.functionCall(expr)
// if there's an operator (not pipe), continue with precedence parsing // if dotget is followed by binary operator, continue parsing as binary expression
if (this.is($T.Operator) && !this.isPipe()) { if (expr.type.is('DotGet') && this.is($T.Operator) && !this.is($T.Operator, '|'))
expr = this.continueWithPrecedence(expr) expr = this.dotGetBinOp(expr)
}
// one | echo // one | echo
if (allowPipe && this.isPipe()) if (allowPipe && this.isPipe())
@ -145,33 +139,6 @@ export class Parser {
return expr return expr
} }
// Continue parsing with precedence after we already have a left side
continueWithPrecedence(left: SyntaxNode, minBp = 0): SyntaxNode {
while (this.is($T.Operator)) {
const op = this.current().value!
const bp = precedence[op]
// operator has lower precedence than required, stop
if (bp === undefined || bp < minBp) break
const opNode = this.op()
// right-associative operators (like **) use same bp, others use bp + 1
const nextMinBp = op === '**' ? bp : bp + 1
// parse right-hand side with higher precedence
const right = this.exprWithPrecedence(nextMinBp)
const nodeType = conditionals.has(op) ? 'ConditionalOp' : 'BinOp'
const node = new SyntaxNode(nodeType, left.from, right.to)
node.push(left, opNode, right)
left = node
}
return left
}
// piping | stuff | is | cool // piping | stuff | is | cool
pipe(left: SyntaxNode): SyntaxNode { pipe(left: SyntaxNode): SyntaxNode {
const canLookPastNewlines = this.inParens === 0 const canLookPastNewlines = this.inParens === 0
@ -278,6 +245,22 @@ export class Parser {
// parse specific nodes // parse specific nodes
// //
// raw determines whether we just want the SyntaxNodes or we want to
// wrap them in a PositionalArg
arg(raw = false): SyntaxNode {
// 'do' is a special function arg - it doesn't need to be wrapped
// in parens. otherwise, args are regular value()s
const val = this.is($T.Keyword, 'do') ? this.do() : this.value()
if (raw) {
return val
} else {
const arg = new SyntaxNode('PositionalArg', val.from, val.to)
arg.add(val)
return arg
}
}
// [ 1 2 3 ] // [ 1 2 3 ]
array(): SyntaxNode { array(): SyntaxNode {
const open = this.expect($T.OpenBracket) const open = this.expect($T.OpenBracket)
@ -358,7 +341,7 @@ export class Parser {
} }
// atoms are the basic building blocks: literals, identifiers, words // atoms are the basic building blocks: literals, identifiers, words
atom() { atom(): SyntaxNode {
if (this.is($T.String)) if (this.is($T.String))
return this.string() return this.string()
@ -462,15 +445,7 @@ export class Parser {
continue continue
} }
if (this.is($T.NamedArgPrefix)) { values.push(this.is($T.NamedArgPrefix) ? this.namedArg() : this.arg())
const prefix = SyntaxNode.from(this.next())
const val = this.is($T.Keyword, 'do') ? this.do() : this.value()
const arg = new SyntaxNode('NamedArg', prefix.from, val.to)
arg.push(prefix, val)
values.push(arg)
} else {
values.push(this.value())
}
} }
const close = this.expect($T.CloseBracket) const close = this.expect($T.CloseBracket)
@ -549,7 +524,7 @@ export class Parser {
if (!this.scope.has(ident)) if (!this.scope.has(ident))
return this.word(left) return this.word(left)
if (left.type === 'Identifier') left.type = 'IdentifierBeforeDot' if (left.type.is('Identifier')) left.type = 'IdentifierBeforeDot'
let parts = [] let parts = []
while (this.is($T.Operator, '.')) { while (this.is($T.Operator, '.')) {
@ -564,19 +539,37 @@ export class Parser {
return nodes ? node.push(left, nodes!) : node.push(left, ...parts) return nodes ? node.push(left, nodes!) : node.push(left, ...parts)
} }
// continue parsing dotget/word binary operation
dotGetBinOp(left: SyntaxNode): SyntaxNode {
while (this.is($T.Operator) && !this.is($T.Operator, '|')) {
const op = this.current().value!
const bp = precedence[op]
if (bp === undefined) break
const opNode = this.op()
const right = this.exprWithPrecedence(bp + 1)
const nodeType = conditionals.has(op) ? 'ConditionalOp' : 'BinOp'
const node = new SyntaxNode(nodeType, left.from, right.to)
node.push(left, opNode, right)
left = node
}
return left
}
// dotget in a statement/expression (something.blah) or (something.blah arg1) // dotget in a statement/expression (something.blah) or (something.blah arg1)
dotGetFunctionCall(): SyntaxNode { dotGetFunctionCall(): SyntaxNode {
const dotGet = this.dotGet() const dotGet = this.dotGet()
// dotget not in scope, regular Word // if followed by a binary operator (not pipe), return dotGet/Word as-is for expression parser
if (dotGet.type === 'Word') return dotGet if (this.is($T.Operator) && !this.is($T.Operator, '|'))
if (this.is($T.Operator) && !this.isPipe())
return dotGet return dotGet
else if (this.isPipe() || this.isExprEnd()) // dotget not in scope, regular Word
return this.functionCallOrIdentifier(dotGet) if (dotGet.type.is('Word')) return dotGet
if (this.isExprEnd())
return this.functionCallOrIdentifier(dotGet)
else else
return this.functionCall(dotGet) return this.functionCall(dotGet)
} }
@ -595,17 +588,8 @@ export class Parser {
const ident = fn ?? this.identifier() const ident = fn ?? this.identifier()
const args: SyntaxNode[] = [] const args: SyntaxNode[] = []
while (!this.isExprEnd() && !this.is($T.Operator, '|')) { while (!this.isExprEnd())
if (this.is($T.NamedArgPrefix)) { args.push(this.is($T.NamedArgPrefix) ? this.namedArg() : this.arg())
args.push(this.namedArg())
} else {
// 'do' is the only keyword allowed as a function argument
const val = this.is($T.Keyword, 'do') ? this.do() : this.value()
const arg = new SyntaxNode('PositionalArg', val.from, val.to)
arg.add(val)
args.push(arg)
}
}
const node = new SyntaxNode('FunctionCall', ident.from, (args.at(-1) || ident).to) const node = new SyntaxNode('FunctionCall', ident.from, (args.at(-1) || ident).to)
node.push(ident, ...args) node.push(ident, ...args)
@ -626,7 +610,7 @@ export class Parser {
inner = this.dotGet() inner = this.dotGet()
// if the dotGet was just a Word, bail // if the dotGet was just a Word, bail
if (inner.type === 'Word') return inner if (inner.type.is('Word')) return inner
} }
inner ??= this.identifier() inner ??= this.identifier()
@ -715,7 +699,7 @@ export class Parser {
// abc= true // abc= true
namedArg(): SyntaxNode { namedArg(): SyntaxNode {
const prefix = SyntaxNode.from(this.expect($T.NamedArgPrefix)) const prefix = SyntaxNode.from(this.expect($T.NamedArgPrefix))
const val = this.value() const val = this.arg(true)
const node = new SyntaxNode('NamedArg', prefix.from, val.to) const node = new SyntaxNode('NamedArg', prefix.from, val.to)
return node.push(prefix, val) return node.push(prefix, val)
} }
@ -725,7 +709,7 @@ export class Parser {
const prefix = SyntaxNode.from(this.expect($T.NamedArgPrefix)) const prefix = SyntaxNode.from(this.expect($T.NamedArgPrefix))
const val = this.value() const val = this.value()
if (!['Null', 'Boolean', 'Number', 'String'].includes(val.type)) if (!['Null', 'Boolean', 'Number', 'String'].includes(val.type.name))
throw `[namedParam] default value must be Null|Bool|Num|Str, got ${val.type}\n\n ${this.input}\n` throw `[namedParam] default value must be Null|Bool|Num|Str, got ${val.type}\n\n ${this.input}\n`
const node = new SyntaxNode('NamedParam', prefix.from, val.to) const node = new SyntaxNode('NamedParam', prefix.from, val.to)
@ -771,7 +755,7 @@ export class Parser {
// throw blah // throw blah
throw(): SyntaxNode { throw(): SyntaxNode {
const keyword = this.keyword('throw') const keyword = this.keyword('throw')
const val = this.value() const val = this.expression()
const node = new SyntaxNode('Throw', keyword.from, val.to) const node = new SyntaxNode('Throw', keyword.from, val.to)
return node.push(keyword, val) return node.push(keyword, val)
} }
@ -885,6 +869,7 @@ export class Parser {
isExprEnd(): boolean { isExprEnd(): boolean {
return this.isAny($T.Colon, $T.Semicolon, $T.Newline, $T.CloseParen, $T.CloseBracket) || return this.isAny($T.Colon, $T.Semicolon, $T.Newline, $T.CloseParen, $T.CloseBracket) ||
this.is($T.Operator, '|') ||
this.isExprEndKeyword() || !this.current() this.isExprEndKeyword() || !this.current()
} }
@ -933,9 +918,9 @@ function collapseDotGets(origNodes: SyntaxNode[]): SyntaxNode {
while (nodes.length > 0) { while (nodes.length > 0) {
const left = nodes.pop()! const left = nodes.pop()!
if (left.type === 'Identifier') left.type = 'IdentifierBeforeDot' if (left.type.is('Identifier')) left.type = 'IdentifierBeforeDot'
const dot = new SyntaxNode("DotGet", left.from, right.to); const dot = new SyntaxNode("DotGet", left.from, right.to)
dot.push(left, right) dot.push(left, right)
right = dot right = dot

View File

@ -1,27 +1,32 @@
import { SyntaxNode } from './node' import { SyntaxNode } from './node'
/**
// Parse string contents into fragments, interpolations, and escape sequences. * Parse string contents into fragments, interpolations, and escape sequences.
*
* Input: full string including quotes, e.g. "'hello $name'"
* Output: SyntaxNode tree with StringFragment, Interpolation, EscapeSeq children
*/
export const parseString = (input: string, from: number, to: number, parser: any): SyntaxNode => { export const parseString = (input: string, from: number, to: number, parser: any): SyntaxNode => {
const stringNode = new SyntaxNode('String', from, to) const stringNode = new SyntaxNode('String', from, to)
const content = input.slice(from, to) const content = input.slice(from, to)
// Determine string type
const firstChar = content[0] const firstChar = content[0]
// double quotes: no interpolation or escapes // Double-quoted strings: no interpolation or escapes
if (firstChar === '"') { if (firstChar === '"') {
const fragment = new SyntaxNode('DoubleQuote', from, to) const fragment = new SyntaxNode('DoubleQuote', from, to)
stringNode.add(fragment) stringNode.add(fragment)
return stringNode return stringNode
} }
// curlies: interpolation but no escapes // Curly strings: interpolation but no escapes
if (firstChar === '{') { if (firstChar === '{') {
parseCurlyString(stringNode, input, from, to, parser) parseCurlyString(stringNode, input, from, to, parser)
return stringNode return stringNode
} }
// single-quotes: interpolation and escapes // Single-quoted strings: interpolation and escapes
if (firstChar === "'") { if (firstChar === "'") {
parseSingleQuoteString(stringNode, input, from, to, parser) parseSingleQuoteString(stringNode, input, from, to, parser)
return stringNode return stringNode
@ -30,19 +35,26 @@ export const parseString = (input: string, from: number, to: number, parser: any
throw `Unknown string type starting with: ${firstChar}` throw `Unknown string type starting with: ${firstChar}`
} }
/**
* Parse single-quoted string: 'hello $name\n'
* Supports: interpolation ($var, $(expr)), escape sequences (\n, \$, etc)
*/
const parseSingleQuoteString = (stringNode: SyntaxNode, input: string, from: number, to: number, parser: any) => { const parseSingleQuoteString = (stringNode: SyntaxNode, input: string, from: number, to: number, parser: any) => {
let pos = from + 1 // skip opening ' let pos = from + 1 // Skip opening '
let fragmentStart = pos let fragmentStart = pos
while (pos < to - 1) { // -1 to skip closing ' while (pos < to - 1) { // -1 to skip closing '
const char = input[pos] const char = input[pos]
// Escape sequence
if (char === '\\' && pos + 1 < to - 1) { if (char === '\\' && pos + 1 < to - 1) {
// Push accumulated fragment
if (pos > fragmentStart) { if (pos > fragmentStart) {
const frag = new SyntaxNode('StringFragment', fragmentStart, pos) const frag = new SyntaxNode('StringFragment', fragmentStart, pos)
stringNode.add(frag) stringNode.add(frag)
} }
// Add escape sequence node
const escNode = new SyntaxNode('EscapeSeq', pos, pos + 2) const escNode = new SyntaxNode('EscapeSeq', pos, pos + 2)
stringNode.add(escNode) stringNode.add(escNode)
@ -51,15 +63,19 @@ const parseSingleQuoteString = (stringNode: SyntaxNode, input: string, from: num
continue continue
} }
// Interpolation
if (char === '$') { if (char === '$') {
// Push accumulated fragment
if (pos > fragmentStart) { if (pos > fragmentStart) {
const frag = new SyntaxNode('StringFragment', fragmentStart, pos) const frag = new SyntaxNode('StringFragment', fragmentStart, pos)
stringNode.add(frag) stringNode.add(frag)
} }
pos++ // skip $ pos++ // Skip $
// Parse interpolation content
if (input[pos] === '(') { if (input[pos] === '(') {
// Expression interpolation: $(expr)
const interpStart = pos - 1 // Include the $ const interpStart = pos - 1 // Include the $
const exprResult = parseInterpolationExpr(input, pos, parser) const exprResult = parseInterpolationExpr(input, pos, parser)
const interpNode = new SyntaxNode('Interpolation', interpStart, exprResult.endPos) const interpNode = new SyntaxNode('Interpolation', interpStart, exprResult.endPos)
@ -67,6 +83,7 @@ const parseSingleQuoteString = (stringNode: SyntaxNode, input: string, from: num
stringNode.add(interpNode) stringNode.add(interpNode)
pos = exprResult.endPos pos = exprResult.endPos
} else { } else {
// Variable interpolation: $name
const interpStart = pos - 1 const interpStart = pos - 1
const identEnd = findIdentifierEnd(input, pos, to - 1) const identEnd = findIdentifierEnd(input, pos, to - 1)
const identNode = new SyntaxNode('FunctionCallOrIdentifier', pos, identEnd) const identNode = new SyntaxNode('FunctionCallOrIdentifier', pos, identEnd)
@ -86,21 +103,27 @@ const parseSingleQuoteString = (stringNode: SyntaxNode, input: string, from: num
pos++ pos++
} }
// Push final fragment
if (pos > fragmentStart && fragmentStart < to - 1) { if (pos > fragmentStart && fragmentStart < to - 1) {
const frag = new SyntaxNode('StringFragment', fragmentStart, pos) const frag = new SyntaxNode('StringFragment', fragmentStart, pos)
stringNode.add(frag) stringNode.add(frag)
} }
} }
/**
* Parse curly string: { hello $name }
* Supports: interpolation ($var, $(expr)), nested braces
* Does NOT support: escape sequences (raw content)
*/
const parseCurlyString = (stringNode: SyntaxNode, input: string, from: number, to: number, parser: any) => { const parseCurlyString = (stringNode: SyntaxNode, input: string, from: number, to: number, parser: any) => {
let pos = from + 1 // skip opening { let pos = from + 1 // Skip opening {
let fragmentStart = from // include the opening { in the fragment let fragmentStart = from // Include the opening { in the fragment
let depth = 1 let depth = 1
while (pos < to && depth > 0) { while (pos < to && depth > 0) {
const char = input[pos] const char = input[pos]
// track nesting // Track brace nesting
if (char === '{') { if (char === '{') {
depth++ depth++
pos++ pos++
@ -110,6 +133,7 @@ const parseCurlyString = (stringNode: SyntaxNode, input: string, from: number, t
if (char === '}') { if (char === '}') {
depth-- depth--
if (depth === 0) { if (depth === 0) {
// Push final fragment including closing }
const frag = new SyntaxNode('CurlyString', fragmentStart, pos + 1) const frag = new SyntaxNode('CurlyString', fragmentStart, pos + 1)
stringNode.add(frag) stringNode.add(frag)
break break
@ -118,29 +142,19 @@ const parseCurlyString = (stringNode: SyntaxNode, input: string, from: number, t
continue continue
} }
if (char === '\\' && pos + 1 < to && input[pos + 1] === '$') { // Interpolation
if (pos > fragmentStart) {
const frag = new SyntaxNode('CurlyString', fragmentStart, pos)
stringNode.add(frag)
}
const escapedFrag = new SyntaxNode('CurlyString', pos + 1, pos + 2)
stringNode.add(escapedFrag)
pos += 2 // skip \ and $
fragmentStart = pos
continue
}
if (char === '$') { if (char === '$') {
// Push accumulated fragment
if (pos > fragmentStart) { if (pos > fragmentStart) {
const frag = new SyntaxNode('CurlyString', fragmentStart, pos) const frag = new SyntaxNode('CurlyString', fragmentStart, pos)
stringNode.add(frag) stringNode.add(frag)
} }
pos++ // skip $ pos++ // Skip $
// Parse interpolation content
if (input[pos] === '(') { if (input[pos] === '(') {
// Expression interpolation: $(expr)
const interpStart = pos - 1 const interpStart = pos - 1
const exprResult = parseInterpolationExpr(input, pos, parser) const exprResult = parseInterpolationExpr(input, pos, parser)
const interpNode = new SyntaxNode('Interpolation', interpStart, exprResult.endPos) const interpNode = new SyntaxNode('Interpolation', interpStart, exprResult.endPos)
@ -148,6 +162,7 @@ const parseCurlyString = (stringNode: SyntaxNode, input: string, from: number, t
stringNode.add(interpNode) stringNode.add(interpNode)
pos = exprResult.endPos pos = exprResult.endPos
} else { } else {
// Variable interpolation: $name
const interpStart = pos - 1 const interpStart = pos - 1
const identEnd = findIdentifierEnd(input, pos, to) const identEnd = findIdentifierEnd(input, pos, to)
const identNode = new SyntaxNode('FunctionCallOrIdentifier', pos, identEnd) const identNode = new SyntaxNode('FunctionCallOrIdentifier', pos, identEnd)
@ -168,10 +183,16 @@ const parseCurlyString = (stringNode: SyntaxNode, input: string, from: number, t
} }
} }
/**
* Parse a parenthesized expression interpolation: $(a + b)
* Returns the parsed expression node and the position after the closing )
* pos is position of the opening ( in the full input string
*/
const parseInterpolationExpr = (input: string, pos: number, parser: any): { node: SyntaxNode, endPos: number } => { const parseInterpolationExpr = (input: string, pos: number, parser: any): { node: SyntaxNode, endPos: number } => {
// Find matching closing paren
let depth = 1 let depth = 1
let start = pos let start = pos
let end = pos + 1 // start after opening ( let end = pos + 1 // Start after opening (
while (end < input.length && depth > 0) { while (end < input.length && depth > 0) {
if (input[end] === '(') depth++ if (input[end] === '(') depth++
@ -184,21 +205,28 @@ const parseInterpolationExpr = (input: string, pos: number, parser: any): { node
const exprContent = input.slice(start + 1, end) // Content between ( and ) const exprContent = input.slice(start + 1, end) // Content between ( and )
const closeParen = end const closeParen = end
end++ // move past closing ) end++ // Move past closing )
// Use the main parser to parse the expression
const exprNode = parser.parse(exprContent) const exprNode = parser.parse(exprContent)
// Get the first real node (skip Program wrapper)
const innerNode = exprNode.firstChild || exprNode const innerNode = exprNode.firstChild || exprNode
const offset = start + 1 // position where exprContent starts in input // Adjust node positions: they're relative to exprContent, need to offset to full input
const offset = start + 1 // Position where exprContent starts in full input
adjustNodePositions(innerNode, offset) adjustNodePositions(innerNode, offset)
// Wrap in ParenExpr - use positions in the full string
const parenNode = new SyntaxNode('ParenExpr', start, closeParen + 1) const parenNode = new SyntaxNode('ParenExpr', start, closeParen + 1)
parenNode.add(innerNode) parenNode.add(innerNode)
return { node: parenNode, endPos: end } return { node: parenNode, endPos: end }
} }
/**
* Recursively adjust all node positions by adding an offset
*/
const adjustNodePositions = (node: SyntaxNode, offset: number) => { const adjustNodePositions = (node: SyntaxNode, offset: number) => {
node.from += offset node.from += offset
node.to += offset node.to += offset
@ -208,11 +236,15 @@ const adjustNodePositions = (node: SyntaxNode, offset: number) => {
} }
} }
/**
* Find the end position of an identifier starting at pos
* Identifiers: lowercase letter or emoji, followed by letters/digits/dashes/emoji
*/
const findIdentifierEnd = (input: string, pos: number, maxPos: number): number => { const findIdentifierEnd = (input: string, pos: number, maxPos: number): number => {
let end = pos let end = pos
while (end < maxPos) { while (end < maxPos) {
const char = input[end]! const char = input[end]
// Stop at non-identifier characters // Stop at non-identifier characters
if (!/[a-z0-9\-?]/.test(char)) { if (!/[a-z0-9\-?]/.test(char)) {

View File

@ -1,86 +0,0 @@
import * as terms from '#parser/shrimp.terms'
export function nameToId(name: string): number {
switch (name) {
case 'Star': return terms.Star
case 'Slash': return terms.Slash
case 'Plus': return terms.Plus
case 'Minus': return terms.Minus
case 'And': return terms.And
case 'Or': return terms.Or
case 'Eq': return terms.Eq
case 'EqEq': return terms.EqEq
case 'Neq': return terms.Neq
case 'Lt': return terms.Lt
case 'Lte': return terms.Lte
case 'Gt': return terms.Gt
case 'Gte': return terms.Gte
case 'Modulo': return terms.Modulo
case 'PlusEq': return terms.PlusEq
case 'MinusEq': return terms.MinusEq
case 'StarEq': return terms.StarEq
case 'SlashEq': return terms.SlashEq
case 'ModuloEq': return terms.ModuloEq
case 'Band': return terms.Band
case 'Bor': return terms.Bor
case 'Bxor': return terms.Bxor
case 'Shl': return terms.Shl
case 'Shr': return terms.Shr
case 'Ushr': return terms.Ushr
case 'NullishCoalesce': return terms.NullishCoalesce
case 'NullishEq': return terms.NullishEq
case 'Identifier': return terms.Identifier
case 'AssignableIdentifier': return terms.AssignableIdentifier
case 'Word': return terms.Word
case 'IdentifierBeforeDot': return terms.IdentifierBeforeDot
case 'CurlyString': return terms.CurlyString
case 'newline': return terms.newline
case 'pipeStartsLine': return terms.pipeStartsLine
case 'Do': return terms.Do
case 'Comment': return terms.Comment
case 'Program': return terms.Program
case 'PipeExpr': return terms.PipeExpr
case 'WhileExpr': return terms.WhileExpr
case 'keyword': return terms.keyword
case 'ConditionalOp': return terms.ConditionalOp
case 'ParenExpr': return terms.ParenExpr
case 'FunctionCallWithNewlines': return terms.FunctionCallWithNewlines
case 'DotGet': return terms.DotGet
case 'Number': return terms.Number
case 'Dollar': return terms.Dollar
case 'PositionalArg': return terms.PositionalArg
case 'FunctionDef': return terms.FunctionDef
case 'Params': return terms.Params
case 'NamedParam': return terms.NamedParam
case 'NamedArgPrefix': return terms.NamedArgPrefix
case 'String': return terms.String
case 'StringFragment': return terms.StringFragment
case 'Interpolation': return terms.Interpolation
case 'FunctionCallOrIdentifier': return terms.FunctionCallOrIdentifier
case 'EscapeSeq': return terms.EscapeSeq
case 'DoubleQuote': return terms.DoubleQuote
case 'Boolean': return terms.Boolean
case 'Null': return terms.Null
case 'colon': return terms.colon
case 'CatchExpr': return terms.CatchExpr
case 'Block': return terms.Block
case 'FinallyExpr': return terms.FinallyExpr
case 'Underscore': return terms.Underscore
case 'NamedArg': return terms.NamedArg
case 'IfExpr': return terms.IfExpr
case 'FunctionCall': return terms.FunctionCall
case 'ElseIfExpr': return terms.ElseIfExpr
case 'ElseExpr': return terms.ElseExpr
case 'BinOp': return terms.BinOp
case 'Regex': return terms.Regex
case 'Dict': return terms.Dict
case 'Array': return terms.Array
case 'FunctionCallWithBlock': return terms.FunctionCallWithBlock
case 'TryExpr': return terms.TryExpr
case 'Throw': return terms.Throw
case 'Import': return terms.Import
case 'CompoundAssign': return terms.CompoundAssign
case 'Assign': return terms.Assign
default: throw `unknown term: ${name}`
}
}

View File

@ -139,11 +139,24 @@ describe('try/catch/finally/throw', () => {
`) `)
}) })
test('parses throw statement with BinOp', () => {
expect("throw 'error message:' + msg").toMatchTree(`
Throw
keyword throw
BinOp
String
StringFragment error message:
Plus +
Identifier msg
`)
})
test('parses throw statement with identifier', () => { test('parses throw statement with identifier', () => {
expect('throw error-object').toMatchTree(` expect('throw error-object').toMatchTree(`
Throw Throw
keyword throw keyword throw
Identifier error-object FunctionCallOrIdentifier
Identifier error-object
`) `)
}) })

View File

@ -43,6 +43,58 @@ describe('calling functions', () => {
`) `)
}) })
test('call with function', () => {
expect(`tail do x: x end`).toMatchTree(`
FunctionCall
Identifier tail
PositionalArg
FunctionDef
Do do
Params
Identifier x
colon :
FunctionCallOrIdentifier
Identifier x
keyword end
`)
})
test('call with arg and function', () => {
expect(`tail true do x: x end`).toMatchTree(`
FunctionCall
Identifier tail
PositionalArg
Boolean true
PositionalArg
FunctionDef
Do do
Params
Identifier x
colon :
FunctionCallOrIdentifier
Identifier x
keyword end
`)
})
test('call with function in named arg', () => {
expect(`tail callback=do x: x end`).toMatchTree(`
FunctionCall
Identifier tail
NamedArg
NamedArgPrefix callback=
FunctionDef
Do do
Params
Identifier x
colon :
FunctionCallOrIdentifier
Identifier x
keyword end
`)
})
test('command with arg that is also a command', () => { test('command with arg that is also a command', () => {
expect('tail tail').toMatchTree(` expect('tail tail').toMatchTree(`
FunctionCall FunctionCall

View File

@ -336,6 +336,22 @@ describe('dict literals', () => {
`) `)
}) })
test('work with functions', () => {
expect(`[trap=do x: x end]`).toMatchTree(`
Dict
NamedArg
NamedArgPrefix trap=
FunctionDef
Do do
Params
Identifier x
colon :
FunctionCallOrIdentifier
Identifier x
keyword end
`)
})
test('can be nested', () => { test('can be nested', () => {
expect('[a=one b=[two [c=three]]]').toMatchTree(` expect('[a=one b=[two [c=three]]]').toMatchTree(`
Dict Dict

View File

@ -176,6 +176,43 @@ describe('pipe expressions', () => {
Identifier echo Identifier echo
`) `)
}) })
test('parenthesized expressions can be piped', () => {
expect(`(1 + 2) | echo`).toMatchTree(`
PipeExpr
ParenExpr
BinOp
Number 1
Plus +
Number 2
operator |
FunctionCallOrIdentifier
Identifier echo
`)
})
test('complex parenthesized expressions with pipes', () => {
expect(`((math.random) * 10 + 1) | math.floor`).toMatchTree(`
PipeExpr
ParenExpr
BinOp
BinOp
ParenExpr
FunctionCallOrIdentifier
DotGet
IdentifierBeforeDot math
Identifier random
Star *
Number 10
Plus +
Number 1
operator |
FunctionCallOrIdentifier
DotGet
IdentifierBeforeDot math
Identifier floor
`)
})
}) })
describe('pipe continuation', () => { describe('pipe continuation', () => {
@ -309,7 +346,7 @@ grep h`).toMatchTree(`
Identifier split Identifier split
PositionalArg PositionalArg
String String
StringFragment StringFragment (space)
operator | operator |
FunctionCall FunctionCall
Identifier map Identifier map
@ -333,3 +370,41 @@ grep h`).toMatchTree(`
`) `)
}) })
}) })
describe('Underscore', () => {
test('works in pipes', () => {
expect(`sub 3 1 | div (sub 110 9 | sub 1) _ | div 5`).toMatchTree(`
PipeExpr
FunctionCall
Identifier sub
PositionalArg
Number 3
PositionalArg
Number 1
operator |
FunctionCall
Identifier div
PositionalArg
ParenExpr
PipeExpr
FunctionCall
Identifier sub
PositionalArg
Number 110
PositionalArg
Number 9
operator |
FunctionCall
Identifier sub
PositionalArg
Number 1
PositionalArg
Underscore _
operator |
FunctionCall
Identifier div
PositionalArg
Number 5
`)
})
})

View File

@ -38,7 +38,8 @@ const valueTokens = new Set([
TokenType.Comment, TokenType.Comment,
TokenType.Keyword, TokenType.Operator, TokenType.Keyword, TokenType.Operator,
TokenType.Identifier, TokenType.Word, TokenType.NamedArgPrefix, TokenType.Identifier, TokenType.Word, TokenType.NamedArgPrefix,
TokenType.Boolean, TokenType.Number, TokenType.String, TokenType.Regex TokenType.Boolean, TokenType.Number, TokenType.String, TokenType.Regex,
TokenType.Underscore
]) ])
const operators = new Set([ const operators = new Set([
@ -106,8 +107,8 @@ const keywords = new Set([
]) ])
// helper // helper
function c(strings: TemplateStringsArray) { function c(strings: TemplateStringsArray, ...values: any[]) {
return strings[0]!.charCodeAt(0) return strings.reduce((result, str, i) => result + str + (values[i] ?? ""), "").charCodeAt(0)
} }
function s(c: number): string { function s(c: number): string {
@ -289,7 +290,7 @@ export class Scanner {
} }
readCurlyString() { readCurlyString() {
this.start = this.pos - 1 // include opening { this.start = this.pos - 1
let depth = 1 let depth = 1
this.next() this.next()
@ -338,7 +339,7 @@ export class Scanner {
// classify the token based on what we read // classify the token based on what we read
if (word === '_') if (word === '_')
this.pushChar(TokenType.Underscore) this.push(TokenType.Underscore)
else if (word === 'null') else if (word === 'null')
this.push(TokenType.Null) this.push(TokenType.Null)
@ -386,25 +387,37 @@ export class Scanner {
this.start = this.pos - 1 this.start = this.pos - 1
this.next() // skip 2nd / this.next() // skip 2nd /
let foundClosing = false
while (this.char > 0) { while (this.char > 0) {
if (this.char === c`/` && this.peek() === c`/`) { if (this.char === c`/` && this.peek() === c`/`) {
this.next() // skip / this.next() // skip /
this.next() // skip / this.next() // skip /
foundClosing = true
// read regex flags
while (this.char > 0 && isIdentStart(this.char))
this.next()
// validate regex
const to = this.pos - getCharSize(this.char)
const regexText = this.input.slice(this.start, to)
const [_, pattern, flags] = regexText.match(/^\/\/(.*)\/\/([gimsuy]*)$/) || []
if (pattern) {
try {
new RegExp(pattern, flags)
this.push(TokenType.Regex)
break
} catch (e) {
// invalid regex - fall through to Word
}
}
// invalid regex is treated as Word
this.push(TokenType.Word)
break break
} }
this.next() this.next()
} }
const closing = new Set([c`g`, c`i`, c`m`, c`s`, c`u`, c`y`])
// read flags (e.g., 'gi', 'gim', etc.)
if (foundClosing)
while (closing.has(this.char)) this.next()
this.push(TokenType.Regex)
} }
canBeDotGet(lastToken?: Token): boolean { canBeDotGet(lastToken?: Token): boolean {

View File

@ -1,90 +1,89 @@
import { expect, describe, test } from 'bun:test' import { expect, describe, test } from 'bun:test'
import { globals } from '#prelude'
describe('var and var?', () => { describe('var and var?', () => {
test('var? checks if a variable exists', async () => { test('var? checks if a variable exists', async () => {
await expect(`var? 'nada'`).toEvaluateTo(false, globals) await expect(`var? 'nada'`).toEvaluateTo(false)
await expect(`var? 'info'`).toEvaluateTo(false, globals) await expect(`var? 'info'`).toEvaluateTo(false)
await expect(`abc = abc; var? 'abc'`).toEvaluateTo(true, globals) await expect(`abc = abc; var? 'abc'`).toEvaluateTo(true)
await expect(`var? 'var?'`).toEvaluateTo(true, globals) await expect(`var? 'var?'`).toEvaluateTo(true)
await expect(`var? 'dict'`).toEvaluateTo(true, globals) await expect(`var? 'dict'`).toEvaluateTo(true)
await expect(`var? dict`).toEvaluateTo(true, globals) await expect(`var? dict`).toEvaluateTo(true)
}) })
test('var returns a value or null', async () => { test('var returns a value or null', async () => {
await expect(`var 'nada'`).toEvaluateTo(null, globals) await expect(`var 'nada'`).toEvaluateTo(null)
await expect(`var nada`).toEvaluateTo(null, globals) await expect(`var nada`).toEvaluateTo(null)
await expect(`var 'info'`).toEvaluateTo(null, globals) await expect(`var 'info'`).toEvaluateTo(null)
await expect(`abc = my-string; var 'abc'`).toEvaluateTo('my-string', globals) await expect(`abc = my-string; var 'abc'`).toEvaluateTo('my-string')
await expect(`abc = my-string; var abc`).toEvaluateTo(null, globals) await expect(`abc = my-string; var abc`).toEvaluateTo(null)
}) })
}) })
describe('type predicates', () => { describe('type predicates', () => {
test('string? checks for string type', async () => { test('string? checks for string type', async () => {
await expect(`string? 'hello'`).toEvaluateTo(true, globals) await expect(`string? 'hello'`).toEvaluateTo(true)
await expect(`string? 42`).toEvaluateTo(false, globals) await expect(`string? 42`).toEvaluateTo(false)
}) })
test('number? checks for number type', async () => { test('number? checks for number type', async () => {
await expect(`number? 42`).toEvaluateTo(true, globals) await expect(`number? 42`).toEvaluateTo(true)
await expect(`number? 'hello'`).toEvaluateTo(false, globals) await expect(`number? 'hello'`).toEvaluateTo(false)
}) })
test('boolean? checks for boolean type', async () => { test('boolean? checks for boolean type', async () => {
await expect(`boolean? true`).toEvaluateTo(true, globals) await expect(`boolean? true`).toEvaluateTo(true)
await expect(`boolean? 42`).toEvaluateTo(false, globals) await expect(`boolean? 42`).toEvaluateTo(false)
}) })
test('array? checks for array type', async () => { test('array? checks for array type', async () => {
await expect(`array? [1 2 3]`).toEvaluateTo(true, globals) await expect(`array? [1 2 3]`).toEvaluateTo(true)
await expect(`array? 42`).toEvaluateTo(false, globals) await expect(`array? 42`).toEvaluateTo(false)
}) })
test('dict? checks for dict type', async () => { test('dict? checks for dict type', async () => {
await expect(`dict? [a=1]`).toEvaluateTo(true, globals) await expect(`dict? [a=1]`).toEvaluateTo(true)
await expect(`dict? []`).toEvaluateTo(false, globals) await expect(`dict? []`).toEvaluateTo(false)
}) })
test('null? checks for null type', async () => { test('null? checks for null type', async () => {
await expect(`null? null`).toEvaluateTo(true, globals) await expect(`null? null`).toEvaluateTo(true)
await expect(`null? 42`).toEvaluateTo(false, globals) await expect(`null? 42`).toEvaluateTo(false)
}) })
test('some? checks for non-null', async () => { test('some? checks for non-null', async () => {
await expect(`some? 42`).toEvaluateTo(true, globals) await expect(`some? 42`).toEvaluateTo(true)
await expect(`some? null`).toEvaluateTo(false, globals) await expect(`some? null`).toEvaluateTo(false)
}) })
}) })
describe('introspection', () => { describe('introspection', () => {
test('type returns proper types', async () => { test('type returns proper types', async () => {
await expect(`type 'hello'`).toEvaluateTo('string', globals) await expect(`type 'hello'`).toEvaluateTo('string')
await expect(`type 42`).toEvaluateTo('number', globals) await expect(`type 42`).toEvaluateTo('number')
await expect(`type true`).toEvaluateTo('boolean', globals) await expect(`type true`).toEvaluateTo('boolean')
await expect(`type false`).toEvaluateTo('boolean', globals) await expect(`type false`).toEvaluateTo('boolean')
await expect(`type null`).toEvaluateTo('null', globals) await expect(`type null`).toEvaluateTo('null')
await expect(`type [1 2 3]`).toEvaluateTo('array', globals) await expect(`type [1 2 3]`).toEvaluateTo('array')
await expect(`type [a=1 b=2]`).toEvaluateTo('dict', globals) await expect(`type [a=1 b=2]`).toEvaluateTo('dict')
}) })
test('inspect formats values', async () => { test('inspect formats values', async () => {
await expect(`inspect 'hello'`).toEvaluateTo("\u001b[32m'hello\u001b[32m'\u001b[0m", globals) await expect(`inspect 'hello'`).toEvaluateTo("\u001b[32m'hello\u001b[32m'\u001b[0m")
}) })
test('describe describes values', async () => { test('describe describes values', async () => {
await expect(`describe 'hello'`).toEvaluateTo("#<string: \u001b[32m'hello\u001b[32m'\u001b[0m>", globals) await expect(`describe 'hello'`).toEvaluateTo("#<string: \u001b[32m'hello\u001b[32m'\u001b[0m>")
}) })
}) })
describe('environment', () => { describe('environment', () => {
test('args is an array', async () => { test('args is an array', async () => {
await expect(`array? $.args`).toEvaluateTo(true, globals) await expect(`array? $.args`).toEvaluateTo(true)
}) })
test('args can be accessed', async () => { test('args can be accessed', async () => {
await expect(`type $.args`).toEvaluateTo('array', globals) await expect(`type $.args`).toEvaluateTo('array')
}) })
test('argv includes more than just the args', async () => { test('argv includes more than just the args', async () => {
@ -106,35 +105,35 @@ describe('ref', () => {
describe('$ global dictionary', () => { describe('$ global dictionary', () => {
test('$.args is an array', async () => { test('$.args is an array', async () => {
await expect(`$.args | array?`).toEvaluateTo(true, globals) await expect(`$.args | array?`).toEvaluateTo(true)
}) })
test('$.args can be accessed', async () => { test('$.args can be accessed', async () => {
await expect(`$.args | type`).toEvaluateTo('array', globals) await expect(`$.args | type`).toEvaluateTo('array')
}) })
test('$.script.name is a string', async () => { test('$.script.name is a string', async () => {
await expect(`$.script.name | string?`).toEvaluateTo(true, globals) await expect(`$.script.name | string?`).toEvaluateTo(true)
}) })
test('$.script.path is a string', async () => { test('$.script.path is a string', async () => {
await expect(`$.script.path | string?`).toEvaluateTo(true, globals) await expect(`$.script.path | string?`).toEvaluateTo(true)
}) })
test('$.env is a dict', async () => { test('$.env is a dict', async () => {
await expect(`$.env | dict?`).toEvaluateTo(true, globals) await expect(`$.env | dict?`).toEvaluateTo(true)
}) })
test('$.pid is a number', async () => { test('$.pid is a number', async () => {
await expect(`$.pid | number?`).toEvaluateTo(true, globals) await expect(`$.pid | number?`).toEvaluateTo(true)
await expect(`$.pid > 0`).toEvaluateTo(true, globals) await expect(`$.pid > 0`).toEvaluateTo(true)
}) })
test('$.cwd is a string', async () => { test('$.cwd is a string', async () => {
await expect(`$.cwd | string?`).toEvaluateTo(true, globals) await expect(`$.cwd | string?`).toEvaluateTo(true)
}) })
test('$.cwd returns current working directory', async () => { test('$.cwd returns current working directory', async () => {
await expect(`$.cwd`).toEvaluateTo(process.cwd(), globals) await expect(`$.cwd`).toEvaluateTo(process.cwd())
}) })
}) })

View File

@ -1,42 +1,41 @@
import { expect, describe, test } from 'bun:test' import { expect, describe, test } from 'bun:test'
import { globals } from '#prelude'
describe('loading a file', () => { describe('loading a file', () => {
test(`imports all a file's functions`, async () => { test(`imports all a file's functions`, async () => {
expect(` expect(`
math = load ./src/prelude/tests/math.sh math = load ./src/prelude/tests/math.sh
math.double 4 math.double 4
`).toEvaluateTo(8, globals) `).toEvaluateTo(8)
expect(` expect(`
math = load ./src/prelude/tests/math.sh math = load ./src/prelude/tests/math.sh
math.double (math.double 4) math.double (math.double 4)
`).toEvaluateTo(16, globals) `).toEvaluateTo(16)
expect(` expect(`
math = load ./src/prelude/tests/math.sh math = load ./src/prelude/tests/math.sh
dbl = ref math.double dbl = ref math.double
dbl (dbl 2) dbl (dbl 2)
`).toEvaluateTo(8, globals) `).toEvaluateTo(8)
expect(` expect(`
math = load ./src/prelude/tests/math.sh math = load ./src/prelude/tests/math.sh
math.pi math.pi
`).toEvaluateTo(3.14, globals) `).toEvaluateTo(3.14)
expect(` expect(`
math = load ./src/prelude/tests/math.sh math = load ./src/prelude/tests/math.sh
math | at 🥧 math | at 🥧
`).toEvaluateTo(3.14159265359, globals) `).toEvaluateTo(3.14159265359)
expect(` expect(`
math = load ./src/prelude/tests/math.sh math = load ./src/prelude/tests/math.sh
math.🥧 math.🥧
`).toEvaluateTo(3.14159265359, globals) `).toEvaluateTo(3.14159265359)
expect(` expect(`
math = load ./src/prelude/tests/math.sh math = load ./src/prelude/tests/math.sh
math.add1 5 math.add1 5
`).toEvaluateTo(6, globals) `).toEvaluateTo(6)
}) })
}) })

View File

@ -1,5 +1,4 @@
import { expect, describe, test } from 'bun:test' import { expect, describe, test } from 'bun:test'
import { globals } from '#prelude'
describe('string operations', () => { describe('string operations', () => {
test('to-upper converts to uppercase', async () => { test('to-upper converts to uppercase', async () => {

View File

@ -11,8 +11,9 @@ const nodeToString = (node: SyntaxNode, input: string, depth = 0): string => {
return `${indent}${nodeName}` return `${indent}${nodeName}`
} else { } else {
// Only strip quotes from whole String nodes (legacy DoubleQuote), not StringFragment/EscapeSeq/CurlyString // Only strip quotes from whole String nodes (legacy DoubleQuote), not StringFragment/EscapeSeq/CurlyString
const cleanText = nodeName === 'String' ? text.slice(1, -1) : text let cleanText = nodeName === 'String' ? text.slice(1, -1) : text
return `${indent}${nodeName} ${cleanText}` if (cleanText === ' ') cleanText = '(space)'
return cleanText ? `${indent}${nodeName} ${cleanText}` : `${indent}${nodeName}`
} }
} }