Compare commits

..

1 Commits

Author SHA1 Message Date
5d3f91b380 args and -- 2025-11-09 20:25:34 -08:00
113 changed files with 3147 additions and 6594 deletions

3
.gitignore vendored
View File

@ -34,7 +34,4 @@ report.[0-9]_.[0-9]_.[0-9]_.[0-9]_.json
.DS_Store
/tmp
vscode-extension/tmp
/docs
*.vsix

View File

@ -1,192 +0,0 @@
#!/usr/bin/env bun
// WARNING: [[ No human has been anywhere near this file. It's pure Claude slop.
// Enter at your own risk. ]]
import { readFileSync } from 'fs'
type CallInfo = {
method: string
line: number
calls: Set<string>
isRecursive?: boolean
}
// Parse the parser file and extract method calls
function analyzeParser(filePath: string): Map<string, CallInfo> {
const content = readFileSync(filePath, 'utf-8')
const lines = content.split('\n')
const methods = new Map<string, CallInfo>()
// Find all method definitions
const methodRegex = /^\s*(\w+)\s*\([^)]*\):\s*/
let currentMethod: string | null = null
let braceDepth = 0
let classDepth = 0
for (let i = 0; i < lines.length; i++) {
const line = lines[i] || ''
// Track if we're inside the Parser class
if (line.includes('class Parser')) {
classDepth = braceDepth + 1 // Will be the depth after we process this line's brace
}
// Check for method definition (only inside class, at class level)
// Check BEFORE incrementing braceDepth
if (classDepth > 0 && braceDepth === classDepth) {
const methodMatch = line.match(methodRegex)
if (methodMatch && !line.includes('class ')) {
currentMethod = methodMatch[1]!
methods.set(currentMethod, {
method: currentMethod,
line: i + 1,
calls: new Set(),
})
}
}
// Track brace depth
braceDepth += (line.match(/{/g) || []).length
braceDepth -= (line.match(/}/g) || []).length
// Find method calls within current method
if (currentMethod && braceDepth > 0) {
// Match this.methodName() calls
const callRegex = /this\.(\w+)\s*\(/g
let match
while ((match = callRegex.exec(line)) !== null) {
const calledMethod = match[1]!
const info = methods.get(currentMethod)!
info.calls.add(calledMethod)
// Mark recursive calls
if (calledMethod === currentMethod) {
info.isRecursive = true
}
}
}
// Reset when method ends
if (braceDepth === 0) {
currentMethod = null
}
}
return methods
}
// Build tree structure starting from a root method
function buildTree(
method: string,
callGraph: Map<string, CallInfo>,
visited: Set<string>,
indent = '',
isLast = true,
depth = 0,
maxDepth = 3,
): string[] {
const lines: string[] = []
const info = callGraph.get(method)
if (!info) return lines
// Add current method
const prefix = depth === 0 ? '' : isLast ? '└─> ' : '├─> '
const suffix = info.isRecursive ? ' (recursive)' : ''
const lineNum = `[line ${info.line}]`
lines.push(`${indent}${prefix}${method}() ${lineNum}${suffix}`)
// Stop if we've reached max depth
if (depth >= maxDepth) {
return lines
}
// Prevent infinite recursion in tree display
if (visited.has(method)) {
return lines
}
const newVisited = new Set(visited)
newVisited.add(method)
// Helper methods to filter out (low-level utilities)
const helperPatterns = /^(is|next|peek|expect|current|op)/i
// Get sorted unique calls (filter out recursive self-calls for display)
const calls = Array.from(info.calls)
.filter((c) => callGraph.has(c)) // Only show parser methods
.filter((c) => c !== method) // Don't show immediate self-recursion
.filter((c) => !helperPatterns.test(c)) // Filter out helpers
.sort()
// Add children
const newIndent = indent + (isLast ? ' ' : '│ ')
calls.forEach((call, idx) => {
const childLines = buildTree(
call,
callGraph,
newVisited,
newIndent,
idx === calls.length - 1,
depth + 1,
maxDepth,
)
lines.push(...childLines)
})
return lines
}
// Main
const parserPath = './src/parser/parser2.ts'
const maxDepth = parseInt(process.argv[2] || '5')
console.log('Parser Call Tree for', parserPath)
console.log(`Max depth: ${maxDepth}`)
console.log('═'.repeat(60))
console.log()
const callGraph = analyzeParser(parserPath)
// Start from parse() method
const tree = buildTree('parse', callGraph, new Set(), '', true, 0, maxDepth)
console.log(tree.join('\n'))
// Show some stats
console.log('\n' + '═'.repeat(60))
console.log('Stats:')
console.log(` Total methods: ${callGraph.size}`)
console.log(` Entry point: parse()`)
// Find methods that are never called (potential dead code or entry points)
const allCalled = new Set<string>()
for (const info of callGraph.values()) {
info.calls.forEach((c) => allCalled.add(c))
}
const uncalled = Array.from(callGraph.keys())
.filter((m) => !allCalled.has(m) && m !== 'parse')
.sort()
if (uncalled.length > 0) {
console.log(`\n Uncalled methods: ${uncalled.join(', ')}`)
}
// Find most-called methods
const callCount = new Map<string, number>()
for (const info of callGraph.values()) {
for (const called of info.calls) {
callCount.set(called, (callCount.get(called) || 0) + 1)
}
}
const topCalled = Array.from(callCount.entries())
.sort((a, b) => b[1] - a[1])
.slice(0, 5)
console.log(`\n Most-called methods:`)
for (const [method, count] of topCalled) {
console.log(` ${method}() - called ${count} times`)
}

View File

@ -7,9 +7,6 @@ import * as readline from 'readline'
import { readFileSync, writeFileSync } from 'fs'
import { basename } from 'path'
globals.$.script.name = '(repl)'
globals.$.script.path = '(repl)'
async function repl() {
const commands = ['/clear', '/reset', '/vars', '/funcs', '/history', '/bytecode', '/exit', '/save', '/quit']

View File

@ -1,9 +1,8 @@
#!/usr/bin/env bun
import { colors, globals as prelude } from '../src/prelude'
import { treeToString2 } from '../src/utils/tree'
import { colors } from '../src/prelude'
import { treeToString } from '../src/utils/tree'
import { runCode, runFile, compileFile, parseCode } from '../src'
import { resolve } from 'path'
import { bytecodeToString } from 'reefvm'
import { readFileSync } from 'fs'
import { spawn } from 'child_process'
@ -31,16 +30,13 @@ ${colors.bright}Options:${colors.reset}
}
function showVersion() {
console.log('🦐 v0.0.1 (non-lezer parser)')
console.log('🦐 v0.0.1')
}
async function evalCode(code: string, imports: string[]) {
const idx = Bun.argv.indexOf('--')
prelude.$.args = idx >= 0 ? Bun.argv.slice(idx + 1) : []
async function evalCode(code: string, imports: string[], globals?: Record<string, any>) {
const importStatement = imports.length > 0 ? `import ${imports.join(' ')}` : ''
if (importStatement) code = `${importStatement}; ${code}`
return await runCode(code)
return await runCode(code, globals)
}
async function main() {
@ -51,11 +47,29 @@ async function main() {
return
}
// Parse -I flags for imports (supports both "-I math" and "-Imath")
// cli command and arg
let command = ''
let cmdArg = ''
// parse -I flags for imports. supports both "-I math" and "-Imath"
const imports: string[] = []
while (args.length > 0) {
const arg = args[0]
// shrimp -E args -- arg1 arg2
let grabArgs = false
let scriptArgs = []
for (const arg of args) {
// everything after -- goes into "args" for this script
if (arg === '--') {
grabArgs = true
continue
}
// grab all args after --
if (grabArgs) {
scriptArgs.push(arg)
continue
}
if (arg === '-I') {
// "-I math" format
@ -74,8 +88,12 @@ async function main() {
}
imports.push(moduleName)
args = args.slice(1)
}
if (!command) {
command = arg
} else {
break
cmdArg = arg
}
}
@ -84,8 +102,6 @@ async function main() {
return
}
const command = args[0]
if (['help', '-help', '--help', '-h'].includes(command)) {
showHelp()
return
@ -105,29 +121,29 @@ async function main() {
}
if (['eval', '-eval', '--eval', '-e'].includes(command)) {
const code = args[1]
const code = cmdArg
if (!code) {
console.log(`${colors.bright}usage: shrimp eval <code>${colors.reset}`)
process.exit(1)
}
await evalCode(code, imports)
await evalCode(code, imports, { args: scriptArgs })
return
}
if (['print', '-print', '--print', '-E'].includes(command)) {
const code = args[1]
const code = cmdArg
if (!code) {
console.log(`${colors.bright}usage: shrimp print <code>${colors.reset}`)
process.exit(1)
}
console.log(await evalCode(code, imports))
console.log(await evalCode(code, imports, { args: scriptArgs }))
return
}
if (['bytecode', '-bytecode', '--bytecode', '-b'].includes(command)) {
const file = args[1]
const file = cmdArg
if (!file) {
console.log(`${colors.bright}usage: shrimp bytecode <file>${colors.reset}`)
process.exit(1)
@ -137,28 +153,26 @@ async function main() {
}
if (['parse', '-parse', '--parse', '-p'].includes(command)) {
const file = args[1]
const file = cmdArg
if (!file) {
console.log(`${colors.bright}usage: shrimp parse <file>${colors.reset}`)
process.exit(1)
}
const input = readFileSync(file, 'utf-8')
console.log(treeToString2(parseCode(input).topNode, input))
console.log(treeToString(parseCode(input), input))
return
}
if (['run', '-run', '--run', '-r'].includes(command)) {
const file = args[1]
const file = cmdArg
if (!file) {
console.log(`${colors.bright}usage: shrimp run <file>${colors.reset}`)
process.exit(1)
}
prelude.$.script.path = resolve(file)
await runFile(file)
return
}
prelude.$.script.path = resolve(command)
await runFile(command)
}

View File

@ -16,8 +16,6 @@
"@lezer/highlight": "^1.2.1",
"@lezer/lr": "^1.4.2",
"@types/bun": "latest",
"diff": "^8.0.2",
"kleur": "^4.1.5",
},
},
},
@ -62,12 +60,8 @@
"csstype": ["csstype@3.1.3", "", {}, "sha512-M1uQkMl8rQK/szD0LNhtqxIPLpimGm8sOBwU7lLnCpSbTyY3yeU1Vc7l4KT5zT4s/yOxHH5O7tIuuLOCnLADRw=="],
"diff": ["diff@8.0.2", "", {}, "sha512-sSuxWU5j5SR9QQji/o2qMvqRNYRDOcBTgsJ/DeCf4iSN4gW+gNMXM7wFIP+fdXZxoNiAnHUTGjCr+TSWXdRDKg=="],
"hono": ["hono@4.10.4", "", {}, "sha512-YG/fo7zlU3KwrBL5vDpWKisLYiM+nVstBQqfr7gCPbSYURnNEP9BDxEMz8KfsDR9JX0lJWDRNc6nXX31v7ZEyg=="],
"kleur": ["kleur@4.1.5", "", {}, "sha512-o+NO+8WrRiQEE4/7nwRJhN1HWpVmJm511pBHUxPLtp0BUISzlBplORYSmTclCnJvQq2tKu/sgl3xVpkc7ZWuQQ=="],
"reefvm": ["reefvm@git+https://git.nose.space/defunkt/reefvm#3e2e68b31f504347225a4d705c7568a0957d629e", { "peerDependencies": { "typescript": "^5" } }, "3e2e68b31f504347225a4d705c7568a0957d629e"],
"style-mod": ["style-mod@4.1.3", "", {}, "sha512-i/n8VsZydrugj3Iuzll8+x/00GH2vnYsk1eomD8QiRrSAeW6ItbCQDtfXCeJHd0iwiNagqjQkvpvREEPtW3IoQ=="],

View File

@ -1,18 +0,0 @@
#!/usr/bin/env shrimp
# usage: dice <sides>
import math only=random
import list only=first
import str only=[replace starts-with?]
sides = $.args | first
sides ??= 20
if sides | starts-with? d:
sides = replace sides //\D// ''
end
sides = number sides
echo 'Rolling d$sides...'
random 1 sides | echo

View File

@ -1 +0,0 @@
echo

View File

@ -1,31 +0,0 @@
#!/usr/bin/env shrimp
year = date.now | date.year
project = fs.pwd | fs.basename | str.titlecase
{
Copyright $year $project Authors
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the “Software”), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in
all copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED “AS IS”, WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS
IN THE SOFTWARE.
}
| str.trim
| echo

View File

@ -1,39 +0,0 @@
#!/usr/bin/env shrimp
# usage: password <length> [!spaced] [!symbols]
if ($.args | list.contains? -h):
echo 'usage: password <length> [!spaced] [!symbols]'
exit
end
password = do n=22 symbols=true spaced=true:
chars = 'abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789'
if symbols: chars += '!@#%^&*-=()[]<>' end
out = []
i = 0
max = length chars
while i < n:
idx = math.floor ((math.random) * max)
ch = chars | at idx
list.push out ch
i += 1
end
if spaced:
pos1 = math.floor((n - 2) / 3)
pos2 = math.floor((n - 2) * 2 / 3)
list.insert out pos2 ' '
list.insert out pos1 ' '
end
str.join out ''
end
missing-arg? = do x: $.args | list.contains? x | not end
num = $.args | list.reject (do x: x | str.starts-with? ! end) | list.first
password num symbols=(missing-arg? !symbols) spaced=(missing-arg? !spaced) | echo

View File

@ -1,9 +0,0 @@
#!/usr/bin/env shrimp
if not fs.exists? 'package.json':
echo '🦐 package.json not found'
exit 1
end
package = fs.read 'package.json' | json.decode
package.scripts | dict.keys | list.sort | each do x: echo x end

View File

@ -1,20 +1,16 @@
{
"name": "shrimp",
"version": "0.1.0",
"exports": {
".": "./src/index.ts",
"./editor": "./src/editor/index.ts",
"./editor.css": "./src/editor/editor.css"
},
"exports": "./src/index.ts",
"private": true,
"type": "module",
"scripts": {
"editor": "bun --hot src/editor/example/server.tsx",
"repl": "bun bin/repl",
"dev": "bun generate-parser && bun --hot src/server/server.tsx",
"generate-parser": "lezer-generator src/parser/shrimp.grammar --typeScript -o src/parser/shrimp.ts",
"repl": "bun generate-parser && bun bin/repl",
"update-reef": "rm -rf ~/.bun/install/cache/ && rm bun.lock && bun update reefvm",
"cli:install": "ln -s \"$(pwd)/bin/shrimp\" ~/.bun/bin/shrimp",
"cli:remove": "rm ~/.bun/bin/shrimp",
"check": "bunx tsc --noEmit"
"cli:remove": "rm ~/.bun/bin/shrimp"
},
"dependencies": {
"@codemirror/view": "^6.38.3",
@ -28,9 +24,7 @@
"devDependencies": {
"@lezer/highlight": "^1.2.1",
"@lezer/lr": "^1.4.2",
"@types/bun": "latest",
"diff": "^8.0.2",
"kleur": "^4.1.5"
"@types/bun": "latest"
},
"prettier": {
"semi": false,

View File

@ -1,7 +1,9 @@
import { CompilerError } from '#compiler/compilerError.ts'
import { parse, setGlobals } from '#parser/parser2'
import { SyntaxNode, Tree } from '#parser/node'
import { parser } from '#parser/shrimp.ts'
import * as terms from '#parser/shrimp.terms'
import { setGlobals } from '#parser/tokenizer'
import { tokenizeCurlyString } from '#parser/curlyTokenizer'
import type { SyntaxNode, Tree } from '@lezer/common'
import { assert, errorMessage } from '#utils/utils'
import { toBytecode, type Bytecode, type ProgramItem, bytecodeToString } from 'reefvm'
import {
@ -57,16 +59,11 @@ export class Compiler {
loopLabelCount = 0
bytecode: Bytecode
pipeCounter = 0
pipeVarStack: string[] = [] // Stack of pipe variable names for nested pipes
constructor(
public input: string,
globals?: string[] | Record<string, any>,
) {
constructor(public input: string, globals?: string[] | Record<string, any>) {
try {
if (globals) setGlobals(Array.isArray(globals) ? globals : Object.keys(globals))
const ast = parse(input)
const cst = new Tree(ast)
const cst = parser.parse(input)
const errors = checkTreeForErrors(cst)
const firstError = errors[0]
@ -92,7 +89,7 @@ export class Compiler {
}
#compileCst(cst: Tree, input: string) {
const isProgram = cst.topNode.type.is('Program')
const isProgram = cst.topNode.type.id === terms.Program
assert(isProgram, `Expected Program node, got ${cst.topNode.type.name}`)
let child = cst.topNode.firstChild
@ -108,20 +105,14 @@ export class Compiler {
const value = input.slice(node.from, node.to)
if (DEBUG) console.log(`🫦 ${node.name}: ${value}`)
switch (node.type.name) {
case 'Number':
switch (node.type.id) {
case terms.Number:
// Handle sign prefix for hex, binary, and octal literals
// Number() doesn't parse '-0xFF', '+0xFF', '-0o77', etc. correctly
let numberValue: number
if (
value.startsWith('-') &&
(value.includes('0x') || value.includes('0b') || value.includes('0o'))
) {
if (value.startsWith('-') && (value.includes('0x') || value.includes('0b') || value.includes('0o'))) {
numberValue = -Number(value.slice(1))
} else if (
value.startsWith('+') &&
(value.includes('0x') || value.includes('0b') || value.includes('0o'))
) {
} else if (value.startsWith('+') && (value.includes('0x') || value.includes('0b') || value.includes('0o'))) {
numberValue = Number(value.slice(1))
} else {
numberValue = Number(value)
@ -132,8 +123,9 @@ export class Compiler {
return [[`PUSH`, numberValue]]
case 'String': {
if (node.firstChild?.type.is('CurlyString')) return this.#compileCurlyString(value, input)
case terms.String: {
if (node.firstChild?.type.id === terms.CurlyString)
return this.#compileCurlyString(value, input)
const { parts, hasInterpolation } = getStringParts(node, input)
@ -149,19 +141,19 @@ export class Compiler {
parts.forEach((part) => {
const partValue = input.slice(part.from, part.to)
switch (part.type.name) {
case 'StringFragment':
switch (part.type.id) {
case terms.StringFragment:
// Plain text fragment - just push as-is
instructions.push(['PUSH', partValue])
break
case 'EscapeSeq':
case terms.EscapeSeq:
// Process escape sequence and push the result
const processed = processEscapeSeq(partValue)
instructions.push(['PUSH', processed])
break
case 'Interpolation':
case terms.Interpolation:
// Interpolation contains either Identifier or ParenExpr (the $ is anonymous)
const child = part.firstChild
if (!child) {
@ -175,7 +167,7 @@ export class Compiler {
throw new CompilerError(
`Unexpected string part: ${part.type.name}`,
part.from,
part.to,
part.to
)
}
})
@ -185,15 +177,15 @@ export class Compiler {
return instructions
}
case 'Boolean': {
case terms.Boolean: {
return [[`PUSH`, value === 'true']]
}
case 'Null': {
case terms.Null: {
return [[`PUSH`, null]]
}
case 'Regex': {
case terms.Regex: {
// remove the surrounding slashes and any flags
const [_, pattern, flags] = value.match(/^\/\/(.*)\/\/([gimsuy]*)$/) || []
if (!pattern) {
@ -210,50 +202,29 @@ export class Compiler {
return [['PUSH', regex]]
}
case 'Identifier': {
case terms.Identifier: {
return [[`TRY_LOAD`, value]]
}
case 'Word': {
case terms.Word: {
return [['PUSH', value]]
}
case 'DotGet': {
// DotGet is parsed into a nested tree because it's hard to parse it into a flat one.
// However, we want a flat tree - so we're going to pretend like we are getting one from the parser.
//
// This: DotGet(config, DotGet(script, name))
// Becomes: DotGet(config, script, name)
case terms.DotGet: {
const { objectName, property } = getDotGetParts(node, input)
const instructions: ProgramItem[] = []
instructions.push(['TRY_LOAD', objectName])
const flattenProperty = (prop: SyntaxNode): void => {
if (prop.type.is('DotGet')) {
const nestedParts = getDotGetParts(prop, input)
const nestedObjectValue = input.slice(nestedParts.object.from, nestedParts.object.to)
instructions.push(['PUSH', nestedObjectValue])
instructions.push(['DOT_GET'])
flattenProperty(nestedParts.property)
} else {
if (prop.type.is('ParenExpr')) {
instructions.push(...this.#compileNode(prop, input))
} else {
const propertyValue = input.slice(prop.from, prop.to)
instructions.push(['PUSH', propertyValue])
}
instructions.push(['DOT_GET'])
}
if (property.type.id === terms.ParenExpr) {
instructions.push(...this.#compileNode(property, input))
} else {
const propertyValue = input.slice(property.from, property.to)
instructions.push(['PUSH', propertyValue])
}
flattenProperty(property)
instructions.push(['DOT_GET'])
return instructions
}
case 'BinOp': {
case terms.BinOp: {
const { left, op, right } = getBinaryParts(node)
const instructions: ProgramItem[] = []
instructions.push(...this.#compileNode(left, input))
@ -301,7 +272,7 @@ export class Compiler {
return instructions
}
case 'Assign': {
case terms.Assign: {
const assignParts = getAssignmentParts(node)
const instructions: ProgramItem[] = []
@ -332,7 +303,7 @@ export class Compiler {
return instructions
}
case 'CompoundAssign': {
case terms.CompoundAssign: {
const { identifier, operator, right } = getCompoundAssignmentParts(node)
const identifierName = input.slice(identifier.from, identifier.to)
const instructions: ProgramItem[] = []
@ -342,17 +313,15 @@ export class Compiler {
if (opValue === '??=') {
instructions.push(['LOAD', identifierName])
const skipLabel: Label = `.skip_${this.labelCount++}`
const rightInstructions = this.#compileNode(right, input)
instructions.push(['DUP'])
instructions.push(['PUSH', null])
instructions.push(['NEQ'])
instructions.push(['JUMP_IF_TRUE', skipLabel])
instructions.push(['JUMP_IF_TRUE', rightInstructions.length + 1])
instructions.push(['POP'])
instructions.push(...rightInstructions)
instructions.push([`${skipLabel}:`])
instructions.push(['DUP'])
instructions.push(['STORE', identifierName])
@ -360,7 +329,7 @@ export class Compiler {
}
// Standard compound assignments: evaluate both sides, then operate
instructions.push(['LOAD', identifierName]) // will throw if undefined
instructions.push(['LOAD', identifierName]) // will throw if undefined
instructions.push(...this.#compileNode(right, input))
switch (opValue) {
@ -383,7 +352,7 @@ export class Compiler {
throw new CompilerError(
`Unknown compound operator: ${opValue}`,
operator.from,
operator.to,
operator.to
)
}
@ -394,14 +363,14 @@ export class Compiler {
return instructions
}
case 'ParenExpr': {
case terms.ParenExpr: {
const child = node.firstChild
if (!child) return [] // I guess it is empty parentheses?
return this.#compileNode(child, input)
}
case 'FunctionDef': {
case terms.FunctionDef: {
const { paramNames, bodyNodes, catchVariable, catchBody, finallyBody } =
getFunctionDefParts(node, input)
const instructions: ProgramItem[] = []
@ -431,8 +400,8 @@ export class Compiler {
catchVariable,
catchBody,
finallyBody,
input,
),
input
)
)
} else {
instructions.push(...compileFunctionBody())
@ -447,11 +416,11 @@ export class Compiler {
return instructions
}
case 'FunctionCallOrIdentifier': {
if (node.firstChild?.type.is('DotGet')) {
case terms.FunctionCallOrIdentifier: {
if (node.firstChild?.type.id === terms.DotGet) {
const instructions: ProgramItem[] = []
const callLabel: Label = `.call_dotget_${++this.labelCount}`
const afterLabel: Label = `.after_dotget_${++this.labelCount}`
const callLabel = `.call_dotget_${++this.labelCount}`
const afterLabel = `.after_dotget_${++this.labelCount}`
instructions.push(...this.#compileNode(node.firstChild, input))
instructions.push(['DUP'])
@ -490,8 +459,7 @@ export class Compiler {
PUSH 1 ; Named count
CALL
*/
case 'FunctionCall': {
case terms.FunctionCall: {
const { identifierNode, namedArgs, positionalArgs } = getFunctionCallParts(node, input)
const instructions: ProgramItem[] = []
instructions.push(...this.#compileNode(identifierNode, input))
@ -513,7 +481,7 @@ export class Compiler {
return instructions
}
case 'Block': {
case terms.Block: {
const children = getAllChildren(node)
const instructions: ProgramItem[] = []
@ -528,7 +496,7 @@ export class Compiler {
return instructions
}
case 'FunctionCallWithBlock': {
case terms.FunctionCallWithBlock: {
const [fn, _colon, ...block] = getAllChildren(node)
let instructions: ProgramItem[] = []
@ -541,18 +509,18 @@ export class Compiler {
...block
.filter((x) => x.type.name !== 'keyword')
.map((x) => this.#compileNode(x!, input))
.flat(),
.flat()
)
instructions.push(['RETURN'])
instructions.push([`${afterLabel}:`])
if (fn?.type.is('FunctionCallOrIdentifier')) {
if (fn?.type.id === terms.FunctionCallOrIdentifier) {
instructions.push(['LOAD', input.slice(fn!.from, fn!.to)])
instructions.push(['MAKE_FUNCTION', [], fnLabel])
instructions.push(['PUSH', 1])
instructions.push(['PUSH', 0])
instructions.push(['CALL'])
} else if (fn?.type.is('FunctionCall')) {
} else if (fn?.type.id === terms.FunctionCall) {
let body = this.#compileNode(fn!, input)
const namedArgCount = (body[body.length - 2]![1] as number) * 2
const startSlice = body.length - namedArgCount - 3
@ -568,14 +536,14 @@ export class Compiler {
instructions.push(...body)
} else {
throw new Error(
`FunctionCallWithBlock: Expected FunctionCallOrIdentifier or FunctionCall`,
`FunctionCallWithBlock: Expected FunctionCallOrIdentifier or FunctionCall`
)
}
return instructions
}
case 'TryExpr': {
case terms.TryExpr: {
const { tryBlock, catchVariable, catchBody, finallyBody } = getTryExprParts(node, input)
return this.#compileTryCatchFinally(
@ -583,57 +551,50 @@ export class Compiler {
catchVariable,
catchBody,
finallyBody,
input,
input
)
}
case 'Throw':
case 'Not': {
const keyword = node.type.is('Throw') ? 'Throw' : 'Not'
case terms.Throw: {
const children = getAllChildren(node)
const [_throwKeyword, expression] = children
if (!expression) {
throw new CompilerError(
`${keyword} expected expression, got ${children.length} children`,
`Throw expected expression, got ${children.length} children`,
node.from,
node.to,
node.to
)
}
const instructions: ProgramItem[] = []
instructions.push(...this.#compileNode(expression, input))
instructions.push([keyword.toUpperCase()]) // THROW or NOT
instructions.push(['THROW'])
return instructions
}
case 'IfExpr': {
case terms.IfExpr: {
const { conditionNode, thenBlock, elseIfBlocks, elseThenBlock } = getIfExprParts(
node,
input,
input
)
const instructions: ProgramItem[] = []
instructions.push(...this.#compileNode(conditionNode, input))
this.ifLabelCount++
const endLabel: Label = `.end_${this.ifLabelCount}`
const elseLabel: Label = `.else_${this.ifLabelCount}`
const thenBlockInstructions = this.#compileNode(thenBlock, input)
instructions.push(['JUMP_IF_FALSE', elseLabel])
instructions.push(['JUMP_IF_FALSE', thenBlockInstructions.length + 1])
instructions.push(...thenBlockInstructions)
instructions.push(['JUMP', endLabel])
instructions.push([`${elseLabel}:`])
// Else if
elseIfBlocks.forEach(({ conditional, thenBlock }, index) => {
elseIfBlocks.forEach(({ conditional, thenBlock }) => {
instructions.push(...this.#compileNode(conditional, input))
const nextLabel: Label = `.elsif_${this.ifLabelCount}_${index}`
const elseIfInstructions = this.#compileNode(thenBlock, input)
instructions.push(['JUMP_IF_FALSE', nextLabel])
instructions.push(['JUMP_IF_FALSE', elseIfInstructions.length + 1])
instructions.push(...elseIfInstructions)
instructions.push(['JUMP', endLabel])
instructions.push([`${nextLabel}:`])
})
// Else
@ -650,7 +611,7 @@ export class Compiler {
}
// - `EQ`, `NEQ`, `LT`, `GT`, `LTE`, `GTE` - Pop 2, push boolean
case 'ConditionalOp': {
case terms.ConditionalOp: {
const instructions: ProgramItem[] = []
const { left, op, right } = getBinaryParts(node)
const leftInstructions: ProgramItem[] = this.#compileNode(left, input)
@ -682,41 +643,34 @@ export class Compiler {
instructions.push(...leftInstructions, ...rightInstructions, ['GTE'])
break
case 'and': {
const skipLabel: Label = `.skip_${this.labelCount++}`
case 'and':
instructions.push(...leftInstructions)
instructions.push(['DUP'])
instructions.push(['JUMP_IF_FALSE', skipLabel])
instructions.push(['JUMP_IF_FALSE', rightInstructions.length + 1])
instructions.push(['POP'])
instructions.push(...rightInstructions)
instructions.push([`${skipLabel}:`])
break
}
case 'or': {
const skipLabel: Label = `.skip_${this.labelCount++}`
case 'or':
instructions.push(...leftInstructions)
instructions.push(['DUP'])
instructions.push(['JUMP_IF_TRUE', skipLabel])
instructions.push(['JUMP_IF_TRUE', rightInstructions.length + 1])
instructions.push(['POP'])
instructions.push(...rightInstructions)
instructions.push([`${skipLabel}:`])
break
}
case '??': {
break
case '??':
// Nullish coalescing: return left if not null, else right
const skipLabel: Label = `.skip_${this.labelCount++}`
instructions.push(...leftInstructions)
instructions.push(['DUP'])
instructions.push(['PUSH', null])
instructions.push(['NEQ'])
instructions.push(['JUMP_IF_TRUE', skipLabel])
instructions.push(['JUMP_IF_TRUE', rightInstructions.length + 1])
instructions.push(['POP'])
instructions.push(...rightInstructions)
instructions.push([`${skipLabel}:`])
break
}
default:
throw new CompilerError(`Unsupported conditional operator: ${opValue}`, op.from, op.to)
@ -725,7 +679,7 @@ export class Compiler {
return instructions
}
case 'PipeExpr': {
case terms.PipeExpr: {
const { pipedFunctionCall, pipeReceivers } = getPipeExprParts(node)
if (!pipedFunctionCall || pipeReceivers.length === 0) {
throw new CompilerError('PipeExpr must have at least two operands', node.from, node.to)
@ -734,77 +688,67 @@ export class Compiler {
const instructions: ProgramItem[] = []
instructions.push(...this.#compileNode(pipedFunctionCall, input))
// Use a unique variable name for this pipe level to handle nested pipes correctly
this.pipeCounter++
const pipeVarName = `_pipe_${this.pipeCounter}`
this.pipeVarStack.push(pipeVarName)
const pipeValName = `_pipe_value_${this.pipeCounter}`
pipeReceivers.forEach((pipeReceiver) => {
// Store the piped value in the current pipe's variable
// Also store as `_` for direct access in simple cases
instructions.push(['DUP'])
instructions.push(['STORE', pipeVarName])
instructions.push(['STORE', '_'])
instructions.push(['STORE', pipeValName])
const isFunctionCall =
pipeReceiver.type.is('FunctionCall') || pipeReceiver.type.is('FunctionCallOrIdentifier')
const { identifierNode, namedArgs, positionalArgs } = getFunctionCallParts(
pipeReceiver,
input
)
if (isFunctionCall) {
// Function call receiver: check for explicit _ usage to determine arg handling
const { identifierNode, namedArgs, positionalArgs } = getFunctionCallParts(
pipeReceiver,
input,
)
instructions.push(...this.#compileNode(identifierNode, input))
instructions.push(...this.#compileNode(identifierNode, input))
const isUnderscoreInPositionalArgs = positionalArgs.some(
(arg) => arg.type.id === terms.Underscore
)
const isUnderscoreInNamedArgs = namedArgs.some((arg) => {
const { valueNode } = getNamedArgParts(arg, input)
return valueNode.type.id === terms.Underscore
})
const isUnderscoreInPositionalArgs = positionalArgs.some((arg) =>
arg.type.is('Underscore'),
)
const isUnderscoreInNamedArgs = namedArgs.some((arg) => {
const { valueNode } = getNamedArgParts(arg, input)
return valueNode.type.is('Underscore')
})
const shouldPushPositionalArg = !isUnderscoreInPositionalArgs && !isUnderscoreInNamedArgs
const shouldPushPositionalArg = !isUnderscoreInPositionalArgs && !isUnderscoreInNamedArgs
// If no underscore is explicitly used, add the piped value as the first positional arg
if (shouldPushPositionalArg) {
instructions.push(['LOAD', pipeVarName])
}
positionalArgs.forEach((arg) => {
instructions.push(...this.#compileNode(arg, input))
})
namedArgs.forEach((arg) => {
const { name, valueNode } = getNamedArgParts(arg, input)
instructions.push(['PUSH', name])
instructions.push(...this.#compileNode(valueNode, input))
})
instructions.push(['PUSH', positionalArgs.length + (shouldPushPositionalArg ? 1 : 0)])
instructions.push(['PUSH', namedArgs.length])
instructions.push(['CALL'])
} else {
// Non-function-call receiver (Array, ParenExpr, etc.): compile directly
// The `_` variable is available for use in nested expressions
instructions.push(...this.#compileNode(pipeReceiver, input))
// If no underscore is explicitly used, add the piped value as the first positional arg
if (shouldPushPositionalArg) {
instructions.push(['LOAD', pipeValName])
}
positionalArgs.forEach((arg) => {
if (arg.type.id === terms.Underscore) {
instructions.push(['LOAD', pipeValName])
} else {
instructions.push(...this.#compileNode(arg, input))
}
})
namedArgs.forEach((arg) => {
const { name, valueNode } = getNamedArgParts(arg, input)
instructions.push(['PUSH', name])
if (valueNode.type.id === terms.Underscore) {
instructions.push(['LOAD', pipeValName])
} else {
instructions.push(...this.#compileNode(valueNode, input))
}
})
instructions.push(['PUSH', positionalArgs.length + (shouldPushPositionalArg ? 1 : 0)])
instructions.push(['PUSH', namedArgs.length])
instructions.push(['CALL'])
})
this.pipeVarStack.pop()
return instructions
}
case 'Array': {
case terms.Array: {
const children = getAllChildren(node)
// We can easily parse [=] as an empty dict, but `[ = ]` is tougher.
// = 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
// empty dicts
if (children.length === 1 && children[0]!.type.is('Word')) {
if (children.length === 1 && children[0]!.type.id === terms.Word) {
const child = children[0]!
if (input.slice(child.from, child.to) === '=') {
return [['MAKE_DICT', 0]]
@ -816,7 +760,7 @@ export class Compiler {
return instructions
}
case 'Dict': {
case terms.Dict: {
const children = getAllChildren(node)
const instructions: ProgramItem[] = []
@ -825,7 +769,7 @@ export class Compiler {
const valueNode = node.firstChild!.nextSibling
// name= -> name
const key = input.slice(keyNode!.from, keyNode!.to).replace(/\s*=$/, '')
const key = input.slice(keyNode!.from, keyNode!.to).slice(0, -1)
instructions.push(['PUSH', key])
instructions.push(...this.#compileNode(valueNode!, input))
@ -835,7 +779,7 @@ export class Compiler {
return instructions
}
case 'WhileExpr': {
case terms.WhileExpr: {
const [_while, test, _colon, block] = getAllChildren(node)
const instructions: ProgramItem[] = []
@ -853,15 +797,17 @@ export class Compiler {
return instructions
}
case 'Import': {
case terms.Import: {
const instructions: ProgramItem[] = []
const [_import, ...nodes] = getAllChildren(node)
const args = nodes.filter((node) => node.type.is('Identifier'))
const namedArgs = nodes.filter((node) => node.type.is('NamedArg'))
const args = nodes.filter(node => node.type.id === terms.Identifier)
const namedArgs = nodes.filter(node => node.type.id === terms.NamedArg)
instructions.push(['LOAD', 'import'])
args.forEach((dict) => instructions.push(['PUSH', input.slice(dict.from, dict.to)]))
args.forEach((dict) =>
instructions.push(['PUSH', input.slice(dict.from, dict.to)])
)
namedArgs.forEach((arg) => {
const { name, valueNode } = getNamedArgParts(arg, input)
@ -876,22 +822,15 @@ export class Compiler {
return instructions
}
case 'Comment': {
case terms.Comment: {
return [] // ignore comments
}
case 'Underscore': {
// _ refers to the piped value for the current (innermost) pipe
// Use the stack to handle nested pipes correctly
const pipeVar = this.pipeVarStack.at(-1) ?? '_'
return [['LOAD', pipeVar]]
}
default:
throw new CompilerError(
`Compiler doesn't know how to handle a "${node.type.name}" node.`,
node.from,
node.to,
node.to
)
}
}
@ -901,7 +840,7 @@ export class Compiler {
catchVariable: string | undefined,
catchBody: SyntaxNode | undefined,
finallyBody: SyntaxNode | undefined,
input: string,
input: string
): ProgramItem[] {
const instructions: ProgramItem[] = []
this.tryLabelCount++

View File

@ -1,9 +1,5 @@
export class CompilerError extends Error {
constructor(
message: string,
private from: number,
private to: number,
) {
constructor(message: string, private from: number, private to: number) {
super(message)
if (from < 0 || to < 0 || to < from) {

View File

@ -112,12 +112,8 @@ describe('compiler', () => {
test('function call with no args', () => {
expect(`bloop = do: 'bleep' end; bloop`).toEvaluateTo('bleep')
expect(`bloop = [ go=do: 'bleep' end ]; bloop.go`).toEvaluateTo('bleep')
expect(`bloop = [ go=do: 'bleep' end ]; abc = do x: x end; abc (bloop.go)`).toEvaluateTo(
'bleep',
)
expect(`num = ((math.random) * 10 + 1) | math.floor; num >= 1 and num <= 10 `).toEvaluateTo(
true,
)
expect(`bloop = [ go=do: 'bleep' end ]; abc = do x: x end; abc (bloop.go)`).toEvaluateTo('bleep')
expect(`num = ((math.random) * 10 + 1) | math.floor; num >= 1 and num <= 10 `).toEvaluateTo(true)
})
test('function call with if statement and multiple expressions', () => {
@ -192,16 +188,6 @@ describe('compiler', () => {
test('single line if', () => {
expect(`if 3 < 9: shire end`).toEvaluateTo('shire')
})
test('if statement with function definition (bytecode labels)', () => {
expect(`
if false:
abc = do x: x end
else:
nope
end
`).toEvaluateTo('nope')
})
})
describe('errors', () => {
@ -298,43 +284,6 @@ describe('dot get', () => {
test('use parens expr with dot-get', () => {
expect(`a = 1; arr = array 'a' 'b' 'c'; arr.(1 + a)`).toEvaluateTo('c', { array })
})
test('chained dot get: two levels', () => {
expect(`obj = [inner=[value=42]]; obj.inner.value`).toEvaluateTo(42)
})
test('chained dot get: three levels', () => {
expect(`obj = [a=[b=[c=123]]]; obj.a.b.c`).toEvaluateTo(123)
})
test('chained dot get: four levels', () => {
expect(`obj = [w=[x=[y=[z='deep']]]]; obj.w.x.y.z`).toEvaluateTo('deep')
})
test('chained dot get with numeric index', () => {
expect(`obj = [items=[1 2 3]]; obj.items.0`).toEvaluateTo(1)
})
test('chained dot get in expression', () => {
expect(`config = [server=[port=3000]]; config.server.port + 1`).toEvaluateTo(3001)
})
test('chained dot get as function argument', () => {
const double = (x: number) => x * 2
expect(`obj = [val=[num=21]]; double obj.val.num`).toEvaluateTo(42, { double })
})
test('chained dot get in binary operation', () => {
expect(`a = [x=[y=10]]; b = [x=[y=20]]; a.x.y + b.x.y`).toEvaluateTo(30)
})
test('chained dot get with parens at end', () => {
expect(`idx = 1; obj = [items=[10 20 30]]; obj.items.(idx)`).toEvaluateTo(20)
})
test('mixed chained and simple dot get', () => {
expect(`obj = [a=1 b=[c=2]]; obj.a + obj.b.c`).toEvaluateTo(3)
})
})
describe('default params', () => {
@ -380,7 +329,7 @@ describe('default params', () => {
age: 60,
})
expect(
'make-person = do person=[name=Bob age=60]: person end; make-person [name=Jon age=21]',
'make-person = do person=[name=Bob age=60]: person end; make-person [name=Jon age=21]'
).toEvaluateTo({ name: 'Jon', age: 21 })
})
})
@ -412,9 +361,7 @@ describe('Nullish coalescing operator (??)', () => {
})
test('short-circuits evaluation', () => {
const throwError = () => {
throw new Error('Should not evaluate')
}
const throwError = () => { throw new Error('Should not evaluate') }
expect('5 ?? throw-error').toEvaluateTo(5, { 'throw-error': throwError })
})
@ -430,7 +377,7 @@ describe('Nullish coalescing operator (??)', () => {
// Use explicit call syntax to invoke the function
expect('(get-value) ?? (get-default)').toEvaluateTo(42, {
'get-value': getValue,
'get-default': getDefault,
'get-default': getDefault
})
})
})
@ -462,9 +409,7 @@ describe('Nullish coalescing assignment (??=)', () => {
})
test('short-circuits evaluation when not null', () => {
const throwError = () => {
throw new Error('Should not evaluate')
}
const throwError = () => { throw new Error('Should not evaluate') }
expect('x = 5; x ??= throw-error; x').toEvaluateTo(5, { 'throw-error': throwError })
})

View File

@ -10,16 +10,12 @@ describe('single line function blocks', () => {
})
test('work with named args', () => {
expect(
`attach = do signal fn: [ signal (fn) ] end; attach signal='exit': true end`,
).toEvaluateTo(['exit', true])
expect(`attach = do signal fn: [ signal (fn) ] end; attach signal='exit': true end`).toEvaluateTo(['exit', true])
})
test('work with dot-get', () => {
expect(`signals = [trap=do x y: [x (y)] end]; signals.trap 'EXIT': true end`).toEvaluateTo([
'EXIT',
true,
])
expect(`signals = [trap=do x y: [x (y)] end]; signals.trap 'EXIT': true end`).toEvaluateTo(['EXIT', true])
})
})
@ -48,6 +44,7 @@ attach signal='exit':
end`).toEvaluateTo(['exit', true])
})
test('work with dot-get', () => {
expect(`
signals = [trap=do x y: [x (y)] end]

View File

@ -151,22 +151,18 @@ describe('array literals', () => {
describe('dict literals', () => {
test('work with numbers', () => {
expect('[a=1 b=2 c=3]').toEvaluateTo({ a: 1, b: 2, c: 3 })
expect('[a = 1 b = 2 c = 3]').toEvaluateTo({ a: 1, b: 2, c: 3 })
})
test('work with strings', () => {
expect("[a='one' b='two' c='three']").toEvaluateTo({ a: 'one', b: 'two', c: 'three' })
expect("[a = 'one' b = 'two' c = 'three']").toEvaluateTo({ a: 'one', b: 'two', c: 'three' })
})
test('work with identifiers', () => {
expect('[a=one b=two c=three]').toEvaluateTo({ a: 'one', b: 'two', c: 'three' })
expect('[a = one b = two c = three]').toEvaluateTo({ a: 'one', b: 'two', c: 'three' })
})
test('can be nested', () => {
expect('[a=one b=[two [c=three]]]').toEvaluateTo({ a: 'one', b: ['two', { c: 'three' }] })
expect('[a = one b = [two [c = three]]]').toEvaluateTo({ a: 'one', b: ['two', { c: 'three' }] })
})
test('can span multiple lines', () => {
@ -175,12 +171,6 @@ describe('dict literals', () => {
b=2
c=3
]`).toEvaluateTo({ a: 1, b: 2, c: 3 })
expect(`[
a = 1
b = 2
c = 3
]`).toEvaluateTo({ a: 1, b: 2, c: 3 })
})
test('empty dict', () => {
@ -200,12 +190,10 @@ describe('dict literals', () => {
test('semicolons as separators', () => {
expect('[a=1; b=2; c=3]').toEvaluateTo({ a: 1, b: 2, c: 3 })
expect('[a = 1; b = 2; c = 3]').toEvaluateTo({ a: 1, b: 2, c: 3 })
})
test('expressions in dicts', () => {
expect('[a=(1 + 2) b=(3 * 4)]').toEvaluateTo({ a: 3, b: 12 })
expect('[a = (1 + 2) b = (3 * 4)]').toEvaluateTo({ a: 3, b: 12 })
})
test('empty lines within dicts', () => {
@ -219,7 +207,7 @@ describe('dict literals', () => {
describe('curly strings', () => {
test('work on one line', () => {
expect('{ one two three }').toEvaluateTo(' one two three ')
expect('{ one two three }').toEvaluateTo(" one two three ")
})
test('work on multiple lines', () => {
@ -227,7 +215,7 @@ describe('curly strings', () => {
one
two
three
}`).toEvaluateTo('\n one\n two\n three\n ')
}`).toEvaluateTo("\n one\n two\n three\n ")
})
test('can contain other curlies', () => {
@ -235,7 +223,7 @@ describe('curly strings', () => {
{ one }
two
{ three }
}`).toEvaluateTo('\n { one }\n two\n { three }\n ')
}`).toEvaluateTo("\n { one }\n two\n { three }\n ")
})
test('interpolates variables', () => {
@ -258,12 +246,12 @@ describe('curly strings', () => {
test('interpolation edge cases', () => {
expect(`{[a=1 b=2 c={wild}]}`).toEvaluateTo(`[a=1 b=2 c={wild}]`)
expect(`a = 1;b = 2;c = 3;{$a $b $c}`).toEvaluateTo(`1 2 3`)
expect(`a = 1;b = 2;c = 3;{$(a)$(b)$(c)}`).toEvaluateTo(`123`)
expect(`a = 1;b = 2;c = 3;{$a$b$c}`).toEvaluateTo(`123`)
})
})
describe('double quoted strings', () => {
test('work', () => {
test("work", () => {
expect(`"hello world"`).toEvaluateTo('hello world')
})
@ -272,11 +260,11 @@ describe('double quoted strings', () => {
expect(`"hello $(1 + 2)"`).toEvaluateTo('hello $(1 + 2)')
})
test('equal regular strings', () => {
test("equal regular strings", () => {
expect(`"hello world" == 'hello world'`).toEvaluateTo(true)
})
test('can contain newlines', () => {
test("can contain newlines", () => {
expect(`
"hello
world"`).toEvaluateTo('hello\n world')

View File

@ -40,7 +40,7 @@ describe('Native Function Exceptions', () => {
const vm = new VM(compiler.bytecode)
vm.set('async-fail', async () => {
await new Promise((resolve) => setTimeout(resolve, 1))
await new Promise(resolve => setTimeout(resolve, 1))
throw new Error('async error')
})
@ -237,7 +237,7 @@ describe('Native Function Exceptions', () => {
const result = await vm.run()
expect(result).toEqual({
type: 'string',
value: 'This is a very specific error message with details',
value: 'This is a very specific error message with details'
})
})

View File

@ -93,6 +93,7 @@ describe('pipe expressions', () => {
`).toEvaluateTo(5)
})
test('string literals can be piped', () => {
expect(`'hey there' | str.to-upper`).toEvaluateTo('HEY THERE')
})
@ -117,42 +118,4 @@ describe('pipe expressions', () => {
test('dict literals can be piped', () => {
expect(`[a=1 b=2 c=3] | dict.values | list.sort | str.join '-'`).toEvaluateTo('1-2-3')
})
test('pipe to array literal using _ in nested expressions', () => {
// _ should be accessible inside nested function calls within array literals
const code = `
double = do x: x * 2 end
triple = do x: x * 3 end
5 | [(double _) (triple _)]`
expect(code).toEvaluateTo([10, 15])
})
test('pipe to array literal using _ multiple times', () => {
expect(`10 | [_ _ _]`).toEvaluateTo([10, 10, 10])
})
test('pipe to parenthesized expression using _', () => {
const code = `
double = do x: x * 2 end
5 | (double _)`
expect(code).toEvaluateTo(10)
})
test('pipe chain with array literal receiver', () => {
// Pipe to array, then pipe that array to a function
const code = `
double = do x: x * 2 end
5 | [(double _) _] | list.sum`
expect(code).toEvaluateTo(15) // [10, 5] -> 15
})
test('_ in deeply nested expressions within pipe', () => {
// _ should work in nested function calls within function arguments
const code = `
add = do a b: a + b end
mul = do a b: a * b end
10 | add (mul _ 2) _`
// add(mul(10, 2), 10) = add(20, 10) = 30
expect(code).toEvaluateTo(30)
})
})

View File

@ -5,7 +5,7 @@ const buffer: string[] = []
const ribbitGlobals = {
ribbit: async (cb: Function) => {
await cb()
return buffer.join('\n')
return buffer.join("\n")
},
tag: async (tagFn: Function, atDefaults = {}) => {
return (atNamed = {}, ...args: any[]) => tagFn(Object.assign({}, atDefaults, atNamed), ...args)
@ -20,12 +20,10 @@ const ribbitGlobals = {
ul: (atNamed: {}, ...args: any[]) => tag('ul', atNamed, ...args),
li: (atNamed: {}, ...args: any[]) => tag('li', atNamed, ...args),
nospace: () => NOSPACE_TOKEN,
echo: (...args: any[]) => console.log(...args),
echo: (...args: any[]) => console.log(...args)
}
function raw(fn: Function) {
;(fn as any).raw = true
}
function raw(fn: Function) { (fn as any).raw = true }
const tagBlock = async (tagName: string, props = {}, fn: Function) => {
const attrs = Object.entries(props).map(([key, value]) => `${key}="${value}"`)
@ -41,13 +39,14 @@ const tagCall = (tagName: string, atNamed = {}, ...args: any[]) => {
const space = attrs.length ? ' ' : ''
const children = args
.reverse()
.map((a) => (a === TAG_TOKEN ? buffer.pop() : a))
.reverse()
.join(' ')
.map(a => a === TAG_TOKEN ? buffer.pop() : a)
.reverse().join(' ')
.replaceAll(` ${NOSPACE_TOKEN} `, '')
if (SELF_CLOSING.includes(tagName)) buffer.push(`<${tagName}${space}${attrs.join(' ')} />`)
else buffer.push(`<${tagName}${space}${attrs.join(' ')}>${children}</${tagName}>`)
if (SELF_CLOSING.includes(tagName))
buffer.push(`<${tagName}${space}${attrs.join(' ')} />`)
else
buffer.push(`<${tagName}${space}${attrs.join(' ')}>${children}</${tagName}>`)
}
const tag = async (tagName: string, atNamed = {}, ...args: any[]) => {
@ -61,25 +60,10 @@ const tag = async (tagName: string, atNamed = {}, ...args: any[]) => {
const NOSPACE_TOKEN = '!!ribbit-nospace!!'
const TAG_TOKEN = '!!ribbit-tag!!'
const SELF_CLOSING = [
'area',
'base',
'br',
'col',
'embed',
'hr',
'img',
'input',
'link',
'meta',
'param',
'source',
'track',
'wbr',
]
const SELF_CLOSING = ["area", "base", "br", "col", "embed", "hr", "img", "input", "link", "meta", "param", "source", "track", "wbr"]
describe('ribbit', () => {
beforeEach(() => (buffer.length = 0))
beforeEach(() => buffer.length = 0)
test('head tag', () => {
expect(`
@ -90,14 +74,11 @@ ribbit:
meta name=viewport content='width=device-width, initial-scale=1, viewport-fit=cover'
end
end
`).toEvaluateTo(
`<head>
`).toEvaluateTo(`<head>
<title>What up</title>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover" />
</head>`,
ribbitGlobals,
)
</head>`, ribbitGlobals)
})
test('custom tags', () => {
@ -109,14 +90,11 @@ ribbit:
li two
li three
end
end`).toEvaluateTo(
`<ul class="list">
end`).toEvaluateTo(`<ul class="list">
<li border-bottom="1px solid black">one</li>
<li>two</li>
<li>three</li>
</ul>`,
ribbitGlobals,
)
</ul>`, ribbitGlobals)
})
test('inline expressions', () => {
@ -132,8 +110,6 @@ end`).toEvaluateTo(
<h1 class="bright" style="font-family: helvetica">Heya</h1>
<h2>man that is <b>wild</b>!</h2>
<p>Double the fun.</p>
</p>`,
ribbitGlobals,
)
</p>`, ribbitGlobals)
})
})

View File

@ -10,7 +10,8 @@ describe('while', () => {
a = false
b = done
end
b`).toEvaluateTo('done')
b`)
.toEvaluateTo('done')
})
test('basic expression', () => {
@ -19,7 +20,8 @@ describe('while', () => {
while a < 10:
a += 1
end
a`).toEvaluateTo(10)
a`)
.toEvaluateTo(10)
})
test('compound expression', () => {
@ -29,7 +31,8 @@ describe('while', () => {
while a > 0 and b < 100:
b += 1
end
b`).toEvaluateTo(100)
b`)
.toEvaluateTo(100)
})
test('returns value', () => {
@ -39,6 +42,7 @@ describe('while', () => {
a += 1
done
end
ret`).toEvaluateTo('done')
ret`)
.toEvaluateTo('done')
})
})

View File

@ -1,9 +1,9 @@
import { CompilerError } from '#compiler/compilerError.ts'
import type { SyntaxNode, Tree } from '#parser/node'
import * as terms from '#parser/shrimp.terms'
import type { SyntaxNode, Tree } from '@lezer/common'
export const checkTreeForErrors = (tree: Tree): CompilerError[] => {
const errors: CompilerError[] = []
tree.iterate({
enter: (node) => {
if (node.type.isError) {
@ -23,7 +23,7 @@ export const getAllChildren = (node: SyntaxNode): SyntaxNode[] => {
child = child.nextSibling
}
return children.filter((n) => !n.type.is('Comment'))
return children.filter((n) => n.type.id !== terms.Comment)
}
export const getBinaryParts = (node: SyntaxNode) => {
@ -45,23 +45,23 @@ export const getAssignmentParts = (node: SyntaxNode) => {
throw new CompilerError(
`Assign expected 3 children, got ${children.length}`,
node.from,
node.to,
node.to
)
}
// array destructuring
if (left && left.type.is('Array')) {
const identifiers = getAllChildren(left).filter((child) => child.type.is('Identifier'))
if (left && left.type.id === terms.Array) {
const identifiers = getAllChildren(left).filter((child) => child.type.id === terms.Identifier)
return { arrayPattern: identifiers, right }
}
if (!left || !left.type.is('AssignableIdentifier')) {
if (!left || left.type.id !== terms.AssignableIdentifier) {
throw new CompilerError(
`Assign left child must be an AssignableIdentifier or Array, got ${
left ? left.type.name : 'none'
}`,
node.from,
node.to,
node.to
)
}
@ -72,19 +72,19 @@ export const getCompoundAssignmentParts = (node: SyntaxNode) => {
const children = getAllChildren(node)
const [left, operator, right] = children
if (!left || !left.type.is('AssignableIdentifier')) {
if (!left || left.type.id !== terms.AssignableIdentifier) {
throw new CompilerError(
`CompoundAssign left child must be an AssignableIdentifier, got ${
left ? left.type.name : 'none'
}`,
node.from,
node.to,
node.to
)
} else if (!operator || !right) {
throw new CompilerError(
`CompoundAssign expected 3 children, got ${children.length}`,
node.from,
node.to,
node.to
)
}
@ -99,16 +99,16 @@ export const getFunctionDefParts = (node: SyntaxNode, input: string) => {
throw new CompilerError(
`FunctionDef expected at least 4 children, got ${children.length}`,
node.from,
node.to,
node.to
)
}
const paramNames = getAllChildren(paramsNode).map((param) => {
if (!param.type.is('Identifier') && !param.type.is('NamedParam')) {
if (param.type.id !== terms.Identifier && param.type.id !== terms.NamedParam) {
throw new CompilerError(
`FunctionDef params must be Identifier or NamedParam, got ${param.type.name}`,
param.from,
param.to,
param.to
)
}
return input.slice(param.from, param.to)
@ -123,7 +123,7 @@ export const getFunctionDefParts = (node: SyntaxNode, input: string) => {
let finallyBody: SyntaxNode | undefined
for (const child of rest) {
if (child.type.is('CatchExpr')) {
if (child.type.id === terms.CatchExpr) {
catchExpr = child
const catchChildren = getAllChildren(child)
const [_catchKeyword, identifierNode, _colon, body] = catchChildren
@ -131,12 +131,12 @@ export const getFunctionDefParts = (node: SyntaxNode, input: string) => {
throw new CompilerError(
`CatchExpr expected identifier and body, got ${catchChildren.length} children`,
child.from,
child.to,
child.to
)
}
catchVariable = input.slice(identifierNode.from, identifierNode.to)
catchBody = body
} else if (child.type.is('FinallyExpr')) {
} else if (child.type.id === terms.FinallyExpr) {
finallyExpr = child
const finallyChildren = getAllChildren(child)
const [_finallyKeyword, _colon, body] = finallyChildren
@ -144,7 +144,7 @@ export const getFunctionDefParts = (node: SyntaxNode, input: string) => {
throw new CompilerError(
`FinallyExpr expected body, got ${finallyChildren.length} children`,
child.from,
child.to,
child.to
)
}
finallyBody = body
@ -165,9 +165,9 @@ export const getFunctionCallParts = (node: SyntaxNode, input: string) => {
throw new CompilerError(`FunctionCall expected at least 1 child, got 0`, node.from, node.to)
}
const namedArgs = args.filter((arg) => arg.type.is('NamedArg'))
const namedArgs = args.filter((arg) => arg.type.id === terms.NamedArg)
const positionalArgs = args
.filter((arg) => arg.type.is('PositionalArg'))
.filter((arg) => arg.type.id === terms.PositionalArg)
.map((arg) => {
const child = arg.firstChild
if (!child) throw new CompilerError(`PositionalArg has no child`, arg.from, arg.to)
@ -199,7 +199,7 @@ export const getIfExprParts = (node: SyntaxNode, input: string) => {
throw new CompilerError(
`IfExpr expected at least 4 children, got ${children.length}`,
node.from,
node.to,
node.to
)
}
@ -208,13 +208,13 @@ export const getIfExprParts = (node: SyntaxNode, input: string) => {
rest.forEach((child) => {
const parts = getAllChildren(child)
if (child.type.is('ElseExpr')) {
if (child.type.id === terms.ElseExpr) {
if (parts.length !== 3) {
const message = `ElseExpr expected 1 child, got ${parts.length}`
throw new CompilerError(message, child.from, child.to)
}
elseThenBlock = parts.at(-1)
} else if (child.type.is('ElseIfExpr')) {
} else if (child.type.id === terms.ElseIfExpr) {
const [_else, _if, conditional, _colon, thenBlock] = parts
if (!conditional || !thenBlock) {
const names = parts.map((p) => p.type.name).join(', ')
@ -249,32 +249,35 @@ export const getStringParts = (node: SyntaxNode, input: string) => {
// The text is just between the quotes
const parts = children.filter((child) => {
return (
child.type.is('StringFragment') ||
child.type.is('Interpolation') ||
child.type.is('EscapeSeq') ||
child.type.is('CurlyString')
child.type.id === terms.StringFragment ||
child.type.id === terms.Interpolation ||
child.type.id === terms.EscapeSeq ||
child.type.id === terms.CurlyString
)
})
// Validate each part is the expected type
parts.forEach((part) => {
if (
part.type.is('StringFragment') &&
part.type.is('Interpolation') &&
part.type.is('EscapeSeq') &&
part.type.is('CurlyString')
part.type.id !== terms.StringFragment &&
part.type.id !== terms.Interpolation &&
part.type.id !== terms.EscapeSeq &&
part.type.id !== terms.CurlyString
) {
throw new CompilerError(
`String child must be StringFragment, Interpolation, or EscapeSeq, got ${part.type.name}`,
part.from,
part.to,
part.to
)
}
})
// 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.is('Interpolation') || p.type.is('EscapeSeq'))
const hasInterpolation = parts.some(
(p) => p.type.id === terms.Interpolation || p.type.id === terms.EscapeSeq
)
return { parts, hasInterpolation }
}
@ -286,29 +289,29 @@ export const getDotGetParts = (node: SyntaxNode, input: string) => {
throw new CompilerError(
`DotGet expected 2 identifier children, got ${children.length}`,
node.from,
node.to,
node.to
)
}
if (!object.type.is('IdentifierBeforeDot')) {
if (object.type.id !== terms.IdentifierBeforeDot) {
throw new CompilerError(
`DotGet object must be an IdentifierBeforeDot, got ${object.type.name}`,
object.from,
object.to,
object.to
)
}
if (!['Identifier', 'Number', 'ParenExpr', 'DotGet'].includes(property.type.name)) {
if (![terms.Identifier, terms.Number, terms.ParenExpr].includes(property.type.id)) {
throw new CompilerError(
`DotGet property must be an Identifier, Number, ParenExpr, or DotGet, got ${property.type.name}`,
`DotGet property must be an Identifier or Number, got ${property.type.name}`,
property.from,
property.to,
property.to
)
}
const objectName = input.slice(object.from, object.to)
return { object, objectName, property }
return { objectName, property }
}
export const getTryExprParts = (node: SyntaxNode, input: string) => {
@ -321,7 +324,7 @@ export const getTryExprParts = (node: SyntaxNode, input: string) => {
throw new CompilerError(
`TryExpr expected at least 3 children, got ${children.length}`,
node.from,
node.to,
node.to
)
}
@ -332,7 +335,7 @@ export const getTryExprParts = (node: SyntaxNode, input: string) => {
let finallyBody: SyntaxNode | undefined
rest.forEach((child) => {
if (child.type.is('CatchExpr')) {
if (child.type.id === terms.CatchExpr) {
catchExpr = child
const catchChildren = getAllChildren(child)
const [_catchKeyword, identifierNode, _colon, body] = catchChildren
@ -340,12 +343,12 @@ export const getTryExprParts = (node: SyntaxNode, input: string) => {
throw new CompilerError(
`CatchExpr expected identifier and body, got ${catchChildren.length} children`,
child.from,
child.to,
child.to
)
}
catchVariable = input.slice(identifierNode.from, identifierNode.to)
catchBody = body
} else if (child.type.is('FinallyExpr')) {
} else if (child.type.id === terms.FinallyExpr) {
finallyExpr = child
const finallyChildren = getAllChildren(child)
const [_finallyKeyword, _colon, body] = finallyChildren
@ -353,7 +356,7 @@ export const getTryExprParts = (node: SyntaxNode, input: string) => {
throw new CompilerError(
`FinallyExpr expected body, got ${finallyChildren.length} children`,
child.from,
child.to,
child.to
)
}
finallyBody = body

261
src/editor/commands.ts Normal file
View File

@ -0,0 +1,261 @@
export type CommandShape = {
command: string
description?: string
args: ArgShape[]
execute: string | ((...args: any[]) => any)
}
type ArgShape<T extends keyof ArgTypeMap = keyof ArgTypeMap> = {
name: string
type: T
description?: string
optional?: boolean
default?: ArgTypeMap[T]
}
type ArgTypeMap = {
string: string
number: number
boolean: boolean
}
const commandShapes: CommandShape[] = [
{
command: 'ls',
description: 'List the contents of a directory',
execute: './commands/ls.ts',
args: [
{ name: 'path', type: 'string', description: 'The path to list' },
{ name: 'all', type: 'boolean', description: 'Show hidden files', default: false },
{ name: 'long', type: 'boolean', description: 'List in long format', default: false },
{
name: 'short-names',
type: 'boolean',
description: 'Only print file names',
default: false,
},
{ name: 'full-paths', type: 'boolean', description: 'Display full paths', default: false },
],
},
{
command: 'cd',
description: 'Change the current working directory',
execute: './commands/cd.ts',
args: [{ name: 'path', type: 'string', description: 'The path to change to' }],
},
{
command: 'cp',
description: 'Copy files or directories',
execute: './commands/cp.ts',
args: [
{ name: 'source', type: 'string', description: 'Source file or directory' },
{ name: 'destination', type: 'string', description: 'Destination path' },
{ name: 'recursive', type: 'boolean', description: 'Copy recursively', default: false },
{ name: 'verbose', type: 'boolean', description: 'Verbose output', default: false },
],
},
{
command: 'mv',
description: 'Move files or directories',
execute: './commands/mv.ts',
args: [
{ name: 'source', type: 'string', description: 'Source file or directory' },
{ name: 'destination', type: 'string', description: 'Destination path' },
{ name: 'verbose', type: 'boolean', description: 'Verbose output', default: false },
],
},
{
command: 'rm',
description: 'Remove files or directories',
execute: './commands/rm.ts',
args: [
{ name: 'path', type: 'string', description: 'Path to remove' },
{ name: 'recursive', type: 'boolean', description: 'Remove recursively', default: false },
{ name: 'force', type: 'boolean', description: 'Force removal', default: false },
{ name: 'verbose', type: 'boolean', description: 'Verbose output', default: false },
],
},
{
command: 'mkdir',
description: 'Create directories',
execute: './commands/mkdir.ts',
args: [
{ name: 'path', type: 'string', description: 'Directory path to create' },
{ name: 'verbose', type: 'boolean', description: 'Verbose output', default: false },
],
},
{
command: 'touch',
description: 'Create empty files or update timestamps',
execute: './commands/touch.ts',
args: [
{ name: 'path', type: 'string', description: 'File path to touch' },
{ name: 'access', type: 'boolean', description: 'Update access time only', default: false },
{
name: 'modified',
type: 'boolean',
description: 'Update modified time only',
default: false,
},
],
},
{
command: 'echo',
description: 'Display a string',
execute: './commands/echo.ts',
args: [
{ name: 'text', type: 'string', description: 'Text to display' },
{ name: 'no-newline', type: 'boolean', description: "Don't append newline", default: false },
],
},
{
command: 'cat',
description: 'Display file contents',
execute: './commands/cat.ts',
args: [
{ name: 'path', type: 'string', description: 'File to display' },
{ name: 'numbered', type: 'boolean', description: 'Show line numbers', default: false },
],
},
{
command: 'head',
description: 'Show first lines of input',
execute: './commands/head.ts',
args: [
{ name: 'path', type: 'string', description: 'File to read from' },
{ name: 'lines', type: 'number', description: 'Number of lines', default: 10 },
],
},
{
command: 'tail',
description: 'Show last lines of input',
execute: './commands/tail.ts',
args: [
{ name: 'path', type: 'string', description: 'File to read from' },
{ name: 'lines', type: 'number', description: 'Number of lines', default: 10 },
{ name: 'follow', type: 'boolean', description: 'Follow file changes', default: false },
],
},
{
command: 'grep',
description: 'Search for patterns in text',
execute: './commands/grep.ts',
args: [
{ name: 'pattern', type: 'string', description: 'Pattern to search for' },
{
name: 'ignore-case',
type: 'boolean',
description: 'Case insensitive search',
default: false,
},
{ name: 'invert-match', type: 'boolean', description: 'Invert match', default: false },
{ name: 'line-number', type: 'boolean', description: 'Show line numbers', default: false },
],
},
{
command: 'sort',
description: 'Sort input',
execute: './commands/sort.ts',
args: [
{ name: 'reverse', type: 'boolean', description: 'Sort in reverse order', default: false },
{
name: 'ignore-case',
type: 'boolean',
description: 'Case insensitive sort',
default: false,
},
{ name: 'numeric', type: 'boolean', description: 'Numeric sort', default: false },
],
},
{
command: 'uniq',
description: 'Filter out repeated lines',
execute: './commands/uniq.ts',
args: [
{ name: 'count', type: 'boolean', description: 'Show count of occurrences', default: false },
{
name: 'repeated',
type: 'boolean',
description: 'Show only repeated lines',
default: false,
},
{ name: 'unique', type: 'boolean', description: 'Show only unique lines', default: false },
],
},
{
command: 'select',
description: 'Select specific columns from data',
execute: './commands/select.ts',
args: [{ name: 'columns', type: 'string', description: 'Columns to select' }],
},
{
command: 'where',
description: 'Filter data based on conditions',
execute: './commands/where.ts',
args: [{ name: 'condition', type: 'string', description: 'Filter condition' }],
},
{
command: 'group-by',
description: 'Group data by column values',
execute: './commands/group-by.ts',
args: [{ name: 'column', type: 'string', description: 'Column to group by' }],
},
{
command: 'ps',
description: 'List running processes',
execute: './commands/ps.ts',
args: [
{ name: 'long', type: 'boolean', description: 'Show detailed information', default: false },
],
},
{
command: 'sys',
description: 'Show system information',
execute: './commands/sys.ts',
args: [],
},
{
command: 'which',
description: 'Find the location of a command',
execute: './commands/which.ts',
args: [
{ name: 'command', type: 'string', description: 'Command to locate' },
{ name: 'all', type: 'boolean', description: 'Show all matches', default: false },
],
},
] as const
let commandSource = () => commandShapes
export const setCommandSource = (do: () => CommandShape[]) => {
commandSource = fn
}
export const resetCommandSource = () => {
commandSource = () => commandShapes
}
export const matchingCommands = (prefix: string) => {
const match = commandSource().find((cmd) => cmd.command === prefix)
const partialMatches = commandSource().filter((cmd) => cmd.command.startsWith(prefix))
return { match, partialMatches }
}

View File

@ -1,87 +0,0 @@
import { autocompletion, type CompletionContext, type Completion } from '@codemirror/autocomplete'
import { Shrimp } from '#/index'
const keywords = [
'import',
'end',
'do',
'if',
'else',
'while',
'try',
'catch',
'finally',
'throw',
'not',
'and',
'or',
'true',
'false',
'null',
]
const keywordCompletions: Completion[] = keywords.map((k) => ({
label: k,
type: 'keyword',
boost: -1,
}))
export const createShrimpCompletions = (shrimp: Shrimp) => {
// Build completions from all names in the shrimp scope
const scopeNames = shrimp.vm.scope.vars()
console.log(`🌭`, shrimp.vm.vars())
const functionCompletions: Completion[] = scopeNames.map((name) => {
const value = shrimp.vm.scope.get(name)
let type
if (value?.type === 'function' || value?.type === 'native') {
type = 'function'
} else if (value?.type === 'dict') {
type = 'namespace'
} else {
type = 'variable'
}
return { type, label: name }
})
const allCompletions = [...keywordCompletions, ...functionCompletions]
// Get methods for a module (e.g., math, str, list)
const getModuleMethods = (moduleName: string): Completion[] => {
const module = shrimp.get(moduleName)
if (!module || typeof module !== 'object') return []
return Object.keys(module).map((m) => ({ label: m, type: 'function' }))
}
const shrimpCompletionSource = (context: CompletionContext) => {
// Check for module.method pattern (e.g., "math.")
const dotMatch = context.matchBefore(/[\w\-]+\.[\w\-\?]*/)
if (dotMatch) {
const [moduleName, methodPrefix] = dotMatch.text.split('.')
const methods = getModuleMethods(moduleName!)
if (methods.length > 0) {
const dotPos = dotMatch.from + moduleName!.length + 1
return {
from: dotPos,
options: methods.filter((m) => m.label.startsWith(methodPrefix ?? '')),
}
}
}
// Regular completions
const word = context.matchBefore(/[\w\-\?\$]+/)
if (!word || (word.from === word.to && !context.explicit)) return null
return {
from: word.from,
options: allCompletions.filter((c) => c.label.startsWith(word.text)),
}
}
return autocompletion({
override: [shrimpCompletionSource],
})
}

View File

@ -1,24 +0,0 @@
import { linter, type Diagnostic } from '@codemirror/lint'
import { Shrimp } from '#/index'
import { CompilerError } from '#compiler/compilerError'
export const createShrimpDiagnostics = (shrimp: Shrimp) =>
linter((view) => {
const code = view.state.doc.toString()
const diagnostics: Diagnostic[] = []
try {
shrimp.parse(code)
} catch (err) {
if (err instanceof CompilerError) {
diagnostics.push({
from: err.from,
to: err.to,
severity: 'error',
message: err.message,
})
}
}
return diagnostics
})

View File

@ -1,78 +1,53 @@
:root {
/* Background colors */
--bg-editor: #011627;
--bg-output: #40318D;
--bg-status-bar: #1E2A4A;
--bg-status-border: #0E1A3A;
--bg-selection: #1D3B53;
/* Text colors */
--text-editor: #D6DEEB;
--text-output: #7C70DA;
--text-status: #B3A9FF55;
--caret: #80A4C2;
/* Syntax highlighting colors */
--color-keyword: #C792EA;
--color-function: #82AAFF;
--color-string: #C3E88D;
--color-number: #F78C6C;
--color-bool: #FF5370;
--color-operator: #89DDFF;
--color-paren: #676E95;
--color-function-call: #FF9CAC;
--color-variable-def: #FFCB6B;
--color-error: #FF6E6E;
--color-regex: #E1ACFF;
/* ANSI terminal colors */
--ansi-black: #011627;
--ansi-red: #FF5370;
--ansi-green: #C3E88D;
--ansi-yellow: #FFCB6B;
--ansi-blue: #82AAFF;
--ansi-magenta: #C792EA;
--ansi-cyan: #89DDFF;
--ansi-white: #D6DEEB;
/* ANSI bright colors (slightly more vibrant) */
--ansi-bright-black: #676E95;
--ansi-bright-red: #FF6E90;
--ansi-bright-green: #D4F6A8;
--ansi-bright-yellow: #FFE082;
--ansi-bright-blue: #A8C7FA;
--ansi-bright-magenta: #E1ACFF;
--ansi-bright-cyan: #A8F5FF;
--ansi-bright-white: #FFFFFF;
}
@font-face {
font-family: 'C64ProMono';
src: url('../../assets/C64_Pro_Mono-STYLE.woff2') format('woff2');
font-weight: normal;
font-style: normal;
}
@font-face {
font-family: 'Pixeloid Mono';
src: url('../../assets/PixeloidMono.ttf') format('truetype');
font-weight: normal;
font-style: normal;
}
* {
margin: 0;
padding: 0;
box-sizing: border-box;
}
body {
background-color: var(--bg-editor);
#output {
flex: 1;
background: var(--bg-output);
color: var(--text-output);
padding: 20px;
overflow-y: auto;
white-space: pre-wrap;
font-family: 'Pixeloid Mono', 'Courier New', monospace;
font-size: 18px;
height: 100vh;
}
#root {
height: 100vh;
#status-bar {
height: 30px;
background: var(--bg-status-bar);
color: var(--text-status);
display: flex;
align-items: center;
padding: 0 10px;
font-size: 14px;
border-top: 3px solid var(--bg-status-border);
border-bottom: 3px solid var(--bg-status-border);
display: flex;
justify-content: space-between;
}
#status-bar .left,
#status-bar .right {
display: flex;
justify-content: center;
gap: 2rem;
}
#status-bar .multiline {
display: flex;
.dot {
padding-top: 1px;
margin-right: 4px;
}
.active {
color: var(--color-string);
}
.inactive {
color: inherit;
}
}
.syntax-error {
text-decoration: underline dotted var(--color-error);
}

View File

@ -1,70 +1,140 @@
import { EditorView, basicSetup } from 'codemirror'
import { Shrimp } from '#/index'
import { EditorView } from '@codemirror/view'
import { asciiEscapeToHtml, assertNever, log, toElement } from '#utils/utils'
import { Signal } from '#utils/signal'
import { getContent } from '#editor/plugins/persistence'
import type { HtmlEscapedString } from 'hono/utils/html'
import { connectToNose, noseSignals } from '#editor/noseClient'
import type { Value } from 'reefvm'
import { Compartment } from '@codemirror/state'
import { lineNumbers } from '@codemirror/view'
import { shrimpSetup } from '#editor/plugins/shrimpSetup'
import { shrimpTheme } from './theme'
import { createShrimpDiagnostics } from './diagnostics'
import { shrimpHighlighter } from './highlighter'
import { createShrimpCompletions } from './completions'
import { shrimpKeymap } from './keymap'
import { getContent, persistence } from './persistence'
import '#editor/editor.css'
type EditorProps = {
initialCode?: string
onChange?: (code: string) => void
extensions?: import('@codemirror/state').Extension[]
shrimp?: Shrimp
}
const lineNumbersCompartment = new Compartment()
export const Editor = ({
initialCode = '',
onChange,
extensions: customExtensions = [],
shrimp = new Shrimp(),
}: EditorProps) => {
connectToNose()
export const outputSignal = new Signal<Value | string>()
export const errorSignal = new Signal<string>()
export const multilineModeSignal = new Signal<boolean>()
export const Editor = () => {
return (
<div
ref={(el: Element) => {
if (!el?.querySelector('.cm-editor'))
createEditorView(el, getContent() ?? initialCode, onChange, customExtensions, shrimp)
}}
/>
<>
<div
ref={(ref: Element) => {
if (ref?.querySelector('.cm-editor')) return
const view = new EditorView({
parent: ref,
doc: getContent(),
extensions: shrimpSetup(lineNumbersCompartment),
})
multilineModeSignal.connect((isMultiline) => {
view.dispatch({
effects: lineNumbersCompartment.reconfigure(isMultiline ? lineNumbers() : []),
})
})
requestAnimationFrame(() => view.focus())
}}
/>
<div id="status-bar">
<div className="left"></div>
<div className="right"></div>
</div>
<div id="output"></div>
<div id="error"></div>
</>
)
}
const createEditorView = (
el: Element,
initialCode: string,
onChange: ((code: string) => void) | undefined,
customExtensions: import('@codemirror/state').Extension[],
shrimp: Shrimp
) => {
const extensions = [
basicSetup,
shrimpTheme,
createShrimpDiagnostics(shrimp),
createShrimpCompletions(shrimp),
shrimpHighlighter,
shrimpKeymap,
persistence,
...customExtensions,
]
if (onChange) {
extensions.push(
EditorView.updateListener.of((update) => {
if (update.docChanged) {
onChange(update.state.doc.toString())
}
})
)
noseSignals.connect((message) => {
if (message.type === 'error') {
log.error(`Nose error: ${message.data}`)
errorSignal.emit(`Nose error: ${message.data}`)
} else if (message.type === 'reef-output') {
const x = outputSignal.emit(message.data)
} else if (message.type === 'connected') {
outputSignal.emit(`╞ Connected to Nose VM`)
}
})
new EditorView({
parent: el,
doc: initialCode,
extensions,
outputSignal.connect((value) => {
const el = document.querySelector('#output')!
el.innerHTML = ''
el.innerHTML = asciiEscapeToHtml(valueToString(value))
})
errorSignal.connect((error) => {
const el = document.querySelector('#output')!
el.innerHTML = ''
el.classList.add('error')
el.innerHTML = asciiEscapeToHtml(error)
})
type StatusBarMessage = {
side: 'left' | 'right'
message: string | Promise<HtmlEscapedString>
className: string
order?: number
}
export const statusBarSignal = new Signal<StatusBarMessage>()
statusBarSignal.connect(async ({ side, message, className, order }) => {
document.querySelector(`#status-bar .${className}`)?.remove()
const sideEl = document.querySelector(`#status-bar .${side}`)!
const messageEl = (
<div data-order={order ?? 0} className={className}>
{await message}
</div>
)
// Now go through the nodes and put it in the right spot based on order. Higher number means further right
const nodes = Array.from(sideEl.childNodes)
const index = nodes.findIndex((node) => {
if (!(node instanceof HTMLElement)) return false
return Number(node.dataset.order) > (order ?? 0)
})
// Trigger onChange with initial content
onChange?.(initialCode)
if (index === -1) {
sideEl.appendChild(toElement(messageEl))
} else {
sideEl.insertBefore(toElement(messageEl), nodes[index]!)
}
})
const valueToString = (value: Value | string): string => {
if (typeof value === 'string') {
return value
}
switch (value.type) {
case 'null':
return 'null'
case 'boolean':
return value.value ? 'true' : 'false'
case 'number':
return value.value.toString()
case 'string':
return value.value
case 'array':
return `${value.value.map(valueToString).join('\n')}`
case 'dict': {
const entries = Array.from(value.value.entries()).map(
([key, val]) => `"${key}": ${valueToString(val)}`
)
return `{${entries.join(', ')}}`
}
case 'regex':
return `/${value.value.source}/`
case 'function':
return `<function>`
case 'native':
return `<function ${value.fn.name}>`
default:
assertNever(value)
return `<unknown value type: ${(value as any).type}>`
}
}

View File

@ -1,6 +0,0 @@
import { Editor } from '#editor/editor'
import { render } from 'hono/jsx/dom'
if (typeof document !== 'undefined') {
render(<Editor initialCode={'# type some code'} />, document.getElementById('root')!)
}

View File

@ -1,11 +0,0 @@
<!DOCTYPE html>
<html>
<head>
<meta charset="UTF-8" />
<link rel="stylesheet" href="../editor.css" />
</head>
<body>
<div id="root"></div>
<script type="module" src="./frontend.tsx"></script>
</body>
</html>

View File

@ -1,14 +0,0 @@
import index from './index.html'
const server = Bun.serve({
port: 3000,
routes: {
'/': index,
},
development: {
hmr: true,
console: true,
},
})
console.log(`Editor running at ${server.url}`)

View File

@ -1,73 +0,0 @@
import { EditorView, Decoration, DecorationSet, ViewPlugin, ViewUpdate } from '@codemirror/view'
import { RangeSetBuilder } from '@codemirror/state'
import { Shrimp } from '#/index'
import { type SyntaxNode } from '#parser/node'
import { log } from '#utils/utils'
const shrimp = new Shrimp()
export const shrimpHighlighter = ViewPlugin.fromClass(
class {
decorations: DecorationSet
constructor(view: EditorView) {
this.decorations = this.highlight(view)
}
update(update: ViewUpdate) {
if (!update.docChanged) return
this.decorations = this.highlight(update.view)
}
highlight(view: EditorView): DecorationSet {
const builder = new RangeSetBuilder<Decoration>()
const code = view.state.doc.toString()
try {
const tree = shrimp.parse(code)
const decorations: { from: number; to: number; class: string }[] = []
tree.iterate({
enter: (node) => {
const cls = tokenStyles[node.type.name]
const isLeaf = node.children.length === 0
if (cls && isLeaf) {
decorations.push({ from: node.from, to: node.to, class: cls })
}
},
})
// Sort by position (required by RangeSetBuilder)
decorations.sort((a, b) => a.from - b.from)
for (const d of decorations) {
builder.add(d.from, d.to, Decoration.mark({ class: d.class }))
}
} catch (error) {
log('Parsing error in highlighter', error)
}
return builder.finish()
}
},
{
decorations: (v) => v.decorations,
}
)
// Map node types to CSS classes
const tokenStyles: Record<string, string> = {
keyword: 'tok-keyword',
String: 'tok-string',
StringFragment: 'tok-string',
CurlyString: 'tok-string',
Number: 'tok-number',
Boolean: 'tok-bool',
Null: 'tok-null',
Identifier: 'tok-identifier',
AssignableIdentifier: 'tok-variable-def',
Comment: 'tok-comment',
operator: 'tok-operator',
Regex: 'tok-regex',
}

View File

@ -1 +0,0 @@
export { Editor } from './editor'

View File

@ -1,8 +0,0 @@
import { keymap } from '@codemirror/view'
import { acceptCompletion } from '@codemirror/autocomplete'
import { indentWithTab } from '@codemirror/commands'
export const shrimpKeymap = keymap.of([
{ key: 'Tab', run: acceptCompletion },
indentWithTab,
])

59
src/editor/noseClient.ts Normal file
View File

@ -0,0 +1,59 @@
import { Signal } from '#utils/signal'
import type { Bytecode, Value } from 'reefvm'
let ws: WebSocket
type IncomingMessage =
| { type: 'connected' }
| { type: 'ping'; data: number }
| { type: 'commands'; data: number }
| {
type: 'apps'
data: {
name: string
type: 'browser' | 'server'
}[]
}
| {
type: 'session:start'
data: {
NOSE_DIR: string
cwd: string
hostname: string
mode: string
project: string
}
}
| { type: 'reef-output'; data: Value }
| { type: 'error'; data: string }
export const noseSignals = new Signal<IncomingMessage>()
export const connectToNose = (url: string = 'ws://localhost:3000/ws') => {
ws = new WebSocket(url)
ws.onopen = () => noseSignals.emit({ type: 'connected' })
ws.onmessage = (event) => {
const message = JSON.parse(event.data)
noseSignals.emit(message)
}
ws.onerror = (event) => {
console.error(`💥WebSocket error:`, event)
}
ws.onclose = () => {
console.log(`🚪 Connection closed`)
}
}
let id = 0
export const sendToNose = (code: Bytecode) => {
if (!ws) {
throw new Error('WebSocket is not connected.')
} else if (ws.readyState !== WebSocket.OPEN) {
throw new Error(`WebSocket is not open, current status is ${ws.readyState}.`)
}
id += 1
ws.send(JSON.stringify({ type: 'reef-bytecode', data: code, id }))
}

View File

@ -0,0 +1,9 @@
import { errorSignal } from '#editor/editor'
import { EditorView } from '@codemirror/view'
export const catchErrors = EditorView.exceptionSink.of((exception) => {
console.error('CodeMirror error:', exception)
errorSignal.emit(
`Editor error: ${exception instanceof Error ? exception.message : String(exception)}`
)
})

View File

@ -0,0 +1,35 @@
import { EditorView, ViewPlugin, ViewUpdate } from '@codemirror/view'
import { syntaxTree } from '@codemirror/language'
import { statusBarSignal } from '#editor/editor'
export const debugTags = ViewPlugin.fromClass(
class {
update(update: ViewUpdate) {
if (update.docChanged || update.selectionSet || update.geometryChanged) {
this.updateStatusBar(update.view)
}
}
updateStatusBar(view: EditorView) {
const pos = view.state.selection.main.head + 1
const tree = syntaxTree(view.state)
let tags: string[] = []
let node = tree.resolveInner(pos, -1)
while (node) {
tags.push(node.type.name)
node = node.parent!
if (!node) break
}
const debugText = tags.length ? tags.reverse().slice(1).join(' > ') : 'No nodes'
statusBarSignal.emit({
side: 'right',
message: debugText,
className: 'debug-tags',
order: -1,
})
}
}
)

View File

@ -0,0 +1,62 @@
import { parser } from '#parser/shrimp'
import type { Timeout } from '#utils/utils'
import { Range } from '@codemirror/state'
import {
Decoration,
EditorView,
ViewPlugin,
ViewUpdate,
type DecorationSet,
} from '@codemirror/view'
export const shrimpErrors = ViewPlugin.fromClass(
class {
timeout?: Timeout
decorations: DecorationSet = Decoration.none
constructor(view: EditorView) {
this.updateErrors(view)
}
update(update: ViewUpdate) {
if (update.docChanged) {
this.debounceUpdate(update.view)
}
}
updateErrors(view: EditorView) {
this.decorations = Decoration.none
try {
const decorations: Range<Decoration>[] = []
const tree = parser.parse(view.state.doc.toString())
tree.iterate({
enter: (node) => {
if (!node.type.isError) return
// Skip empty error nodes
if (node.from === node.to) return
const decoration = Decoration.mark({
class: 'syntax-error',
attributes: { title: 'COREY REPLACE THIS' },
}).range(node.from, node.to)
decorations.push(decoration)
},
})
this.decorations = Decoration.set(decorations)
// requestAnimationFrame(() => view.dispatch({}))
} catch (e) {
console.error('🙈 Error parsing document', e)
}
}
debounceUpdate = (view: EditorView) => {
clearTimeout(this.timeout)
this.timeout = setTimeout(() => this.updateErrors(view), 250)
}
},
{
decorations: (v) => v.decorations,
}
)

View File

@ -0,0 +1,232 @@
import {
ViewPlugin,
ViewUpdate,
EditorView,
Decoration,
type DecorationSet,
} from '@codemirror/view'
import { syntaxTree } from '@codemirror/language'
import { type SyntaxNode } from '@lezer/common'
import { WidgetType } from '@codemirror/view'
import { toElement } from '#utils/utils'
import { matchingCommands } from '#editor/commands'
import * as Terms from '#parser/shrimp.terms'
const ghostTextTheme = EditorView.theme({
'.ghost-text': {
color: '#666',
opacity: '0.6',
fontStyle: 'italic',
},
})
type Hint = { cursor: number; hintText?: string; completionText?: string }
export const inlineHints = [
ViewPlugin.fromClass(
class {
decorations: DecorationSet = Decoration.none
currentHint?: Hint
update(update: ViewUpdate) {
if (!update.docChanged && !update.selectionSet) return
this.clearHints()
let hint = this.getContext(update.view)
this.currentHint = hint
this.showHint(hint)
}
handleTab(view: EditorView) {
if (!this.currentHint?.completionText) return false
this.decorations = Decoration.none
view.dispatch({
changes: {
from: this.currentHint.cursor,
insert: this.currentHint.completionText,
},
selection: {
anchor: this.currentHint.cursor + this.currentHint.completionText.length,
},
})
this.currentHint = undefined
return true
}
clearHints() {
this.currentHint = undefined
this.decorations = Decoration.none
}
getContext(view: EditorView): Hint {
const cursor = view.state.selection.main.head
const isCursorAtEnd = cursor === view.state.doc.length
if (!isCursorAtEnd) return { cursor }
const token = this.getCommandContextToken(view, cursor)
if (!token) return { cursor }
const text = view.state.doc.sliceString(token.from, token.to)
const tokenId = token.type.id
let completionText = ''
let hintText = ''
const justSpaces = view.state.doc.sliceString(cursor - 1, cursor) === ' '
if (tokenId === Terms.CommandPartial) {
const { partialMatches } = matchingCommands(text)
const match = partialMatches[0]
if (match) {
completionText = match.command.slice(text.length) + ' '
hintText = completionText
}
} else if (
tokenId === Terms.Identifier &&
token.parent?.type.id === Terms.Arg &&
!justSpaces
) {
const { availableArgs } = this.getCommandContext(view, token)
const matchingArgs = availableArgs.filter((arg) => arg.name.startsWith(text))
const match = matchingArgs[0]
if (match) {
hintText = `${match.name.slice(text.length)}=<${match.type}>`
completionText = `${match.name.slice(text.length)}=`
}
} else if (this.containedBy(token, Terms.PartialNamedArg)) {
const { availableArgs } = this.getCommandContext(view, token)
const textWithoutEquals = text.slice(0, -1)
const matchingArgs = availableArgs.filter((arg) => arg.name == textWithoutEquals)
const match = matchingArgs[0]
if (match) {
hintText = `<${match.type}>`
completionText = 'default' in match ? `${match.default}` : ''
}
} else {
const { availableArgs } = this.getCommandContext(view, token)
const nextArg = Array.from(availableArgs)[0]
const space = justSpaces ? '' : ' '
if (nextArg) {
hintText = `${space}${nextArg.name}=<${nextArg.type}>`
if (nextArg) {
completionText = `${space}${nextArg.name}=`
}
}
}
return { completionText, hintText, cursor }
}
getCommandContextToken(view: EditorView, cursor: number) {
const tree = syntaxTree(view.state)
let node = tree.resolveInner(cursor, -1)
// If we're in a CommandCall, return the token before cursor
if (this.containedBy(node, Terms.CommandCall)) {
return tree.resolveInner(cursor, -1)
}
// If we're in Program, look backward
while (node.name === 'Program' && cursor > 0) {
cursor -= 1
node = tree.resolveInner(cursor, -1)
if (this.containedBy(node, Terms.CommandCall)) {
return tree.resolveInner(cursor, -1)
}
}
}
containedBy(node: SyntaxNode, nodeId: number): SyntaxNode | undefined {
let current: SyntaxNode | undefined = node
while (current) {
if (current.type.id === nodeId) {
return current
}
current = current.parent ?? undefined
}
}
showHint(hint: Hint) {
if (!hint.hintText) return
const widget = new GhostTextWidget(hint.hintText)
const afterCursor = 1
const decoration = Decoration.widget({ widget, side: afterCursor }).range(hint.cursor)
this.decorations = Decoration.set([decoration])
}
getCommandContext(view: EditorView, currentToken: SyntaxNode) {
let commandCallNode = currentToken.parent
while (commandCallNode?.type.name !== 'CommandCall') {
if (!commandCallNode) {
throw new Error('No CommandCall parent found, must be an error in the grammar')
}
commandCallNode = commandCallNode.parent
}
const commandToken = commandCallNode.firstChild
if (!commandToken) {
throw new Error('CommandCall has no children, must be an error in the grammar')
}
const commandText = view.state.doc.sliceString(commandToken.from, commandToken.to)
const { match: commandShape } = matchingCommands(commandText)
if (!commandShape) {
throw new Error(`No command shape found for command "${commandText}"`)
}
let availableArgs = [...commandShape.args]
// Walk through all NamedArg children
let child = commandToken.nextSibling
while (child) {
console.log('child', child.type.name, child.to - child.from)
if (child.type.id === Terms.NamedArg) {
const argName = child.firstChild // Should be the Identifier
if (argName) {
const argText = view.state.doc.sliceString(argName.from, argName.to - 1)
availableArgs = availableArgs.filter((arg) => arg.name !== argText)
}
} else if (child.type.id == Terms.Arg) {
const hasSpaceAfter = view.state.doc.sliceString(child.to, child.to + 1) === ' '
if (hasSpaceAfter) {
availableArgs.shift()
}
}
child = child.nextSibling
}
return { commandShape, availableArgs }
}
},
{
decorations: (v) => v.decorations,
eventHandlers: {
keydown(event, view) {
if (event.key === 'Tab') {
event.preventDefault()
const plugin = view.plugin(inlineHints[0]! as ViewPlugin<any>)
plugin?.handleTab(view)
}
},
},
}
),
ghostTextTheme,
]
class GhostTextWidget extends WidgetType {
constructor(private text: string) {
super()
}
toDOM() {
const el = <span className="ghost-text">{this.text}</span>
return toElement(el)
}
}

View File

@ -0,0 +1,184 @@
import { multilineModeSignal, outputSignal } from '#editor/editor'
import { printBytecodeOutput, printParserOutput, runCode } from '#editor/runCode'
import { EditorState } from '@codemirror/state'
import { keymap } from '@codemirror/view'
let multilineMode = false
const customKeymap = keymap.of([
{
key: 'Enter',
run: (view) => {
if (multilineMode) return false
const input = view.state.doc.toString()
history.push(input)
runCode(input)
view.dispatch({
changes: { from: 0, to: view.state.doc.length, insert: '' },
selection: { anchor: 0 },
})
return true
},
},
{
key: 'Shift-Enter',
run: (view) => {
if (multilineMode) {
const input = view.state.doc.toString()
runCode(input)
return true
} else {
outputSignal.emit('Press Shift+Enter to insert run the code.')
}
multilineModeSignal.emit(true)
multilineMode = true
view.dispatch({
changes: { from: view.state.doc.length, insert: '\n' },
selection: { anchor: view.state.doc.length + 1 },
})
return true
},
},
{
key: 'Tab',
preventDefault: true,
run: (view) => {
view.dispatch({
changes: { from: view.state.selection.main.from, insert: ' ' },
selection: { anchor: view.state.selection.main.from + 2 },
})
return true
},
},
{
key: 'ArrowUp',
run: (view) => {
if (multilineMode) return false
const command = history.previous()
if (command === undefined) return false
view.dispatch({
changes: { from: 0, to: view.state.doc.length, insert: command },
selection: { anchor: command.length },
})
return true
},
},
{
key: 'ArrowDown',
run: (view) => {
if (multilineMode) return false
const command = history.next()
if (command === undefined) return false
view.dispatch({
changes: { from: 0, to: view.state.doc.length, insert: command },
selection: { anchor: command.length },
})
return true
},
},
{
key: 'Mod-k 1',
preventDefault: true,
run: (view) => {
const input = view.state.doc.toString()
printParserOutput(input)
return true
},
},
{
key: 'Mod-k 2',
preventDefault: true,
run: (view) => {
const input = view.state.doc.toString()
printBytecodeOutput(input)
return true
},
},
])
let firstTime = true
const singleLineFilter = EditorState.transactionFilter.of((transaction) => {
if (multilineMode) return transaction // Allow everything in multiline mode
if (firstTime) {
firstTime = false
if (transaction.newDoc.toString().includes('\n')) {
multilineMode = true
return transaction
}
}
transaction.changes.iterChanges((fromA, toA, fromB, toB, inserted) => {
if (inserted.toString().includes('\n')) {
multilineMode = true
return
}
})
return transaction
})
export const shrimpKeymap = [customKeymap, singleLineFilter]
class History {
private commands: string[] = []
private index: number | undefined
private storageKey = 'shrimp-command-history'
constructor() {
try {
this.commands = JSON.parse(localStorage.getItem(this.storageKey) || '[]')
} catch {
console.warn('Failed to load command history from localStorage')
}
}
push(command: string) {
this.commands.push(command)
// Limit to last 50 commands
this.commands = this.commands.slice(-50)
localStorage.setItem(this.storageKey, JSON.stringify(this.commands))
this.index = undefined
}
previous(): string | undefined {
if (this.commands.length === 0) return
if (this.index === undefined) {
this.index = this.commands.length - 1
} else if (this.index > 0) {
this.index -= 1
}
return this.commands[this.index]
}
next(): string | undefined {
if (this.commands.length === 0 || this.index === undefined) return
if (this.index < this.commands.length - 1) {
this.index += 1
return this.commands[this.index]
} else {
this.index = undefined
return ''
}
}
}
const history = new History()

View File

@ -1,6 +1,6 @@
import { ViewPlugin, ViewUpdate } from '@codemirror/view'
export const persistence = ViewPlugin.fromClass(
export const persistencePlugin = ViewPlugin.fromClass(
class {
saveTimeout?: ReturnType<typeof setTimeout>
@ -24,6 +24,6 @@ export const getContent = () => {
return localStorage.getItem('shrimp-editor-content') || ''
}
export const setContent = (data: string) => {
const setContent = (data: string) => {
localStorage.setItem('shrimp-editor-content', data)
}

View File

@ -0,0 +1,9 @@
import { parser } from '#/parser/shrimp'
import { LRLanguage, LanguageSupport } from '@codemirror/language'
import { highlighting } from '#/parser/highlight.js'
const language = LRLanguage.define({
parser: parser.configure({ props: [highlighting] }),
})
export const shrimpLanguage = new LanguageSupport(language)

View File

@ -0,0 +1,35 @@
import { history, defaultKeymap, historyKeymap } from '@codemirror/commands'
import { bracketMatching, indentOnInput } from '@codemirror/language'
import { highlightSpecialChars, drawSelection, dropCursor, keymap } from '@codemirror/view'
import { closeBrackets, autocompletion, completionKeymap } from '@codemirror/autocomplete'
import { EditorState, Compartment } from '@codemirror/state'
import { searchKeymap } from '@codemirror/search'
import { shrimpKeymap } from './keymap'
import { shrimpTheme, shrimpHighlighting } from './theme'
import { shrimpLanguage } from './shrimpLanguage'
import { shrimpErrors } from './errors'
import { persistencePlugin } from './persistence'
import { catchErrors } from './catchErrors'
export const shrimpSetup = (lineNumbersCompartment: Compartment) => {
return [
catchErrors,
shrimpKeymap,
highlightSpecialChars(),
history(),
drawSelection(),
dropCursor(),
EditorState.allowMultipleSelections.of(true),
bracketMatching(),
closeBrackets(),
autocompletion(),
indentOnInput(),
keymap.of([...defaultKeymap, ...historyKeymap, ...searchKeymap, ...completionKeymap]),
lineNumbersCompartment.of([]),
shrimpTheme,
shrimpLanguage,
shrimpHighlighting,
shrimpErrors,
persistencePlugin,
]
}

View File

@ -0,0 +1,60 @@
import { EditorView } from '@codemirror/view'
import { HighlightStyle, syntaxHighlighting } from '@codemirror/language'
import { tags } from '@lezer/highlight'
const highlightStyle = HighlightStyle.define([
{ tag: tags.keyword, color: 'var(--color-keyword)' },
{ tag: tags.name, color: 'var(--color-function)' },
{ tag: tags.string, color: 'var(--color-string)' },
{ tag: tags.number, color: 'var(--color-number)' },
{ tag: tags.bool, color: 'var(--color-bool)' },
{ tag: tags.operator, color: 'var(--color-operator)' },
{ tag: tags.paren, color: 'var(--color-paren)' },
{ tag: tags.regexp, color: 'var(--color-regex)' },
{ tag: tags.function(tags.variableName), color: 'var(--color-function-call)' },
{ tag: tags.function(tags.invalid), color: 'white' },
{
tag: tags.definition(tags.variableName),
color: 'var(--color-variable-def)',
backgroundColor: 'var(--bg-variable-def)',
padding: '1px 2px',
borderRadius: '2px',
fontWeight: '500',
},
])
export const shrimpHighlighting = syntaxHighlighting(highlightStyle)
export const shrimpTheme = EditorView.theme(
{
'&': {
color: 'var(--text-editor)',
backgroundColor: 'var(--bg-editor)',
height: '100%',
fontSize: '18px',
},
'.cm-content': {
fontFamily: '"Pixeloid Mono", "Courier New", monospace',
caretColor: 'var(--caret)',
padding: '0px',
},
'&.cm-focused .cm-cursor': {
borderLeftColor: 'var(--caret)',
},
'&.cm-focused .cm-selectionBackground, ::selection': {
backgroundColor: 'var(--bg-selection)',
},
'.cm-editor': {
border: 'none',
outline: 'none',
height: '100%',
},
'.cm-matchingBracket': {
backgroundColor: 'var(--color-bool)',
},
'.cm-nonmatchingBracket': {
backgroundColor: 'var(--color-string)',
},
},
{ dark: true }
)

38
src/editor/runCode.tsx Normal file
View File

@ -0,0 +1,38 @@
import { outputSignal, errorSignal } from '#editor/editor'
import { Compiler } from '#compiler/compiler'
import { errorMessage, log } from '#utils/utils'
import { bytecodeToString } from 'reefvm'
import { parser } from '#parser/shrimp'
import { sendToNose } from '#editor/noseClient'
import { treeToString } from '#utils/tree'
export const runCode = async (input: string) => {
try {
const compiler = new Compiler(input)
sendToNose(compiler.bytecode)
} catch (error) {
log.error(error)
errorSignal.emit(`${errorMessage(error)}`)
}
}
export const printParserOutput = (input: string) => {
try {
const cst = parser.parse(input)
const string = treeToString(cst, input)
outputSignal.emit(string)
} catch (error) {
log.error(error)
errorSignal.emit(`${errorMessage(error)}`)
}
}
export const printBytecodeOutput = (input: string) => {
try {
const compiler = new Compiler(input)
outputSignal.emit(bytecodeToString(compiler.bytecode))
} catch (error) {
log.error(error)
errorSignal.emit(`${errorMessage(error)}`)
}
}

View File

@ -1,51 +0,0 @@
import { EditorView } from '@codemirror/view'
export const shrimpTheme = EditorView.theme(
{
'&': {
color: 'var(--text-editor)',
backgroundColor: 'var(--bg-editor)',
height: '100%',
fontSize: '18px',
},
'.cm-content': {
fontFamily: '"Pixeloid Mono", "Courier New", monospace',
caretColor: 'var(--caret)',
padding: '0px',
borderTop: '1px solid var(--bg-editor)',
},
'&.cm-focused .cm-cursor': {
borderLeftColor: 'var(--caret)',
},
'.cm-selectionLayer .cm-selectionBackground': {
backgroundColor: 'var(--bg-selection)',
},
'.cm-editor': {
border: 'none',
outline: 'none',
height: '100%',
},
'.cm-matchingBracket': {
backgroundColor: 'var(--color-bool)',
},
'.cm-nonmatchingBracket': {
backgroundColor: 'var(--color-string)',
},
'.cm-activeLine': {
backgroundColor: 'rgba(255, 255, 255, 0.03)',
},
// Token highlighting
'.tok-keyword': { color: 'var(--color-keyword)' },
'.tok-string': { color: 'var(--color-string)' },
'.tok-number': { color: 'var(--color-number)' },
'.tok-bool': { color: 'var(--color-bool)' },
'.tok-null': { color: 'var(--color-number)', fontStyle: 'italic' },
'.tok-identifier': { color: 'var(--color-function)' },
'.tok-variable-def': { color: 'var(--color-variable-def)' },
'.tok-comment': { color: 'var(--ansi-bright-black)', fontStyle: 'italic' },
'.tok-operator': { color: 'var(--color-operator)' },
'.tok-regex': { color: 'var(--color-regex)' },
},
{ dark: true }
)

View File

@ -1,114 +1,112 @@
import { readFileSync } from 'fs'
import { VM, fromValue, toValue, isValue, type Bytecode } from 'reefvm'
import { type Tree } from '@lezer/common'
import { Compiler } from '#compiler/compiler'
import { parse } from '#parser/parser2'
import { Tree } from '#parser/node'
import { globals as parserGlobals, setGlobals as setParserGlobals } from '#parser/parser2'
import { globals as prelude } from '#prelude'
import { parser } from '#parser/shrimp'
import { globals as parserGlobals, setGlobals as setParserGlobals } from '#parser/tokenizer'
import { globals as shrimpGlobals } from '#prelude'
export { Compiler } from '#compiler/compiler'
export { parse } from '#parser/parser2'
export { type SyntaxNode, Tree } from '#parser/node'
export { parser } from '#parser/shrimp'
export { globals as prelude } from '#prelude'
export type { Tree } from '@lezer/common'
export { type Value, type Bytecode } from 'reefvm'
export { toValue, fromValue, isValue, Scope, VM, bytecodeToString } from 'reefvm'
export class Shrimp {
vm: VM
private globals?: Record<string, any>
vm: VM
private globals?: Record<string, any>
constructor(globals?: Record<string, any>) {
const emptyBytecode = { instructions: [], constants: [], labels: new Map() }
this.vm = new VM(emptyBytecode, Object.assign({}, prelude, globals ?? {}))
this.globals = globals
}
get(name: string): any {
const value = this.vm.scope.get(name)
return value ? fromValue(value, this.vm) : null
}
set(name: string, value: any) {
this.vm.scope.set(name, toValue(value, this.vm))
}
has(name: string): boolean {
return this.vm.scope.has(name)
}
async call(name: string, ...args: any[]): Promise<any> {
const result = await this.vm.call(name, ...args)
return isValue(result) ? fromValue(result, this.vm) : result
}
parse(code: string): Tree {
return parseCode(code, this.globals)
}
compile(code: string): Bytecode {
return compileCode(code, this.globals)
}
async run(code: string | Bytecode, locals?: Record<string, any>): Promise<any> {
let bytecode
if (typeof code === 'string') {
const compiler = new Compiler(
code,
Object.keys(Object.assign({}, prelude, this.globals ?? {}, locals ?? {})),
)
bytecode = compiler.bytecode
} else {
bytecode = code
constructor(globals?: Record<string, any>) {
const emptyBytecode = { instructions: [], constants: [], labels: new Map() }
this.vm = new VM(emptyBytecode, Object.assign({}, shrimpGlobals, globals ?? {}))
this.globals = globals
}
if (locals) this.vm.pushScope(locals)
this.vm.appendBytecode(bytecode)
await this.vm.continue()
if (locals) this.vm.popScope()
get(name: string): any {
const value = this.vm.scope.get(name)
return value ? fromValue(value, this.vm) : null
}
set(name: string, value: any) {
this.vm.scope.set(name, toValue(value, this.vm))
}
has(name: string): boolean {
return this.vm.scope.has(name)
}
async call(name: string, ...args: any[]): Promise<any> {
const result = await this.vm.call(name, ...args)
return isValue(result) ? fromValue(result, this.vm) : result
}
parse(code: string): Tree {
return parseCode(code, this.globals)
}
compile(code: string): Bytecode {
return compileCode(code, this.globals)
}
async run(code: string | Bytecode, locals?: Record<string, any>): Promise<any> {
let bytecode
if (typeof code === 'string') {
const compiler = new Compiler(code, Object.keys(Object.assign({}, shrimpGlobals, this.globals ?? {}, locals ?? {})))
bytecode = compiler.bytecode
} else {
bytecode = code
}
if (locals) this.vm.pushScope(locals)
this.vm.appendBytecode(bytecode)
await this.vm.continue()
if (locals) this.vm.popScope()
return this.vm.stack.length ? fromValue(this.vm.stack.at(-1)!, this.vm) : null
}
return this.vm.stack.length ? fromValue(this.vm.stack.at(-1)!, this.vm) : null
}
}
export async function runFile(path: string, globals?: Record<string, any>): Promise<any> {
const code = readFileSync(path, 'utf-8')
return await runCode(code, globals)
const code = readFileSync(path, 'utf-8')
return await runCode(code, globals)
}
export async function runCode(code: string, globals?: Record<string, any>): Promise<any> {
return await runBytecode(compileCode(code, globals), globals)
return await runBytecode(compileCode(code, globals), globals)
}
export async function runBytecode(bytecode: Bytecode, globals?: Record<string, any>): Promise<any> {
const vm = new VM(bytecode, Object.assign({}, prelude, globals))
await vm.run()
return vm.stack.length ? fromValue(vm.stack[vm.stack.length - 1]!, vm) : null
const vm = new VM(bytecode, Object.assign({}, shrimpGlobals, globals))
await vm.run()
return vm.stack.length ? fromValue(vm.stack[vm.stack.length - 1]!, vm) : null
}
export function compileFile(path: string, globals?: Record<string, any>): Bytecode {
const code = readFileSync(path, 'utf-8')
return compileCode(code, globals)
const code = readFileSync(path, 'utf-8')
return compileCode(code, globals)
}
export function compileCode(code: string, globals?: Record<string, any>): Bytecode {
const globalNames = [...Object.keys(prelude), ...(globals ? Object.keys(globals) : [])]
const compiler = new Compiler(code, globalNames)
return compiler.bytecode
const globalNames = [...Object.keys(shrimpGlobals), ...(globals ? Object.keys(globals) : [])]
const compiler = new Compiler(code, globalNames)
return compiler.bytecode
}
export function parseFile(path: string, globals?: Record<string, any>): Tree {
const code = readFileSync(path, 'utf-8')
return parseCode(code, globals)
const code = readFileSync(path, 'utf-8')
return parseCode(code, globals)
}
export function parseCode(code: string, globals?: Record<string, any>): Tree {
const oldGlobals = [...parserGlobals]
const globalNames = [...Object.keys(prelude), ...(globals ? Object.keys(globals) : [])]
const oldGlobals = [...parserGlobals]
const globalNames = [...Object.keys(shrimpGlobals), ...(globals ? Object.keys(globals) : [])]
setParserGlobals(globalNames)
const result = parse(code)
setParserGlobals(oldGlobals)
setParserGlobals(globalNames)
const result = parser.parse(code)
setParserGlobals(oldGlobals)
return new Tree(result)
return result
}

View File

@ -1,6 +1,6 @@
import { parse } from '#parser/parser2'
import type { SyntaxNode } from '#parser/node'
import { isIdentStart, isIdentChar } from './tokenizer2'
import { parser } from '#parser/shrimp.ts'
import type { SyntaxNode } from '@lezer/common'
import { isIdentStart, isIdentChar } from './tokenizer'
// Turns a { curly string } into strings and nodes for interpolation
export const tokenizeCurlyString = (value: string): (string | [string, SyntaxNode])[] => {
@ -37,17 +37,18 @@ export const tokenizeCurlyString = (value: string): (string | [string, SyntaxNod
}
const input = value.slice(start + 2, pos) // skip '$('
tokens.push([input, parse(input)])
start = pos + 1 // start after ')'
tokens.push([input, parser.parse(input).topNode])
start = ++pos // skip ')'
} else {
char = value[++pos]
if (!char) break
if (!isIdentStart(char.charCodeAt(0))) break
while (char && isIdentChar(char.charCodeAt(0))) char = value[++pos]
while (char && isIdentChar(char.charCodeAt(0)))
char = value[++pos]
const input = value.slice(start + 1, pos) // skip '$'
tokens.push([input, parse(input)])
tokens.push([input, parser.parse(input).topNode])
start = pos-- // backtrack and start over
}
}

View File

@ -1,264 +0,0 @@
import { type Token, TokenType } from './tokenizer2'
export type NodeType =
| 'Program'
| 'Block'
| 'FunctionCall'
| 'FunctionCallOrIdentifier'
| 'FunctionCallWithBlock'
| 'PositionalArg'
| 'NamedArg'
| 'NamedArgPrefix'
| 'FunctionDef'
| 'Params'
| 'NamedParam'
| 'Null'
| 'Boolean'
| 'Number'
| 'String'
| 'StringFragment'
| 'CurlyString'
| 'DoubleQuote'
| 'EscapeSeq'
| 'Interpolation'
| 'Regex'
| 'Identifier'
| 'AssignableIdentifier'
| 'IdentifierBeforeDot'
| 'Word'
| 'Array'
| 'Dict'
| 'Comment'
| 'BinOp'
| 'ConditionalOp'
| 'ParenExpr'
| 'Assign'
| 'CompoundAssign'
| 'DotGet'
| 'PipeExpr'
| 'IfExpr'
| 'ElseIfExpr'
| 'ElseExpr'
| 'WhileExpr'
| 'TryExpr'
| 'CatchExpr'
| 'FinallyExpr'
| 'Throw'
| 'Not'
| 'Eq'
| 'Modulo'
| 'Plus'
| 'Star'
| 'Slash'
| 'Import'
| 'Do'
| 'Underscore'
| 'colon'
| 'keyword'
| 'operator'
// TODO: remove this when we switch from lezer
export const operators: Record<string, any> = {
// Logic
and: 'And',
or: 'Or',
// Bitwise
band: 'Band',
bor: 'Bor',
bxor: 'Bxor',
'>>>': 'Ushr',
'>>': 'Shr',
'<<': 'Shl',
// Comparison
'>=': 'Gte',
'<=': 'Lte',
'>': 'Gt',
'<': 'Lt',
'!=': 'Neq',
'==': 'EqEq',
// Compound assignment operators
'??=': 'NullishEq',
'+=': 'PlusEq',
'-=': 'MinusEq',
'*=': 'StarEq',
'/=': 'SlashEq',
'%=': 'ModuloEq',
// Nullish coalescing
'??': 'NullishCoalesce',
// Math
'*': 'Star',
'**': 'StarStar',
'=': 'Eq',
'/': 'Slash',
'+': 'Plus',
'-': 'Minus',
'%': 'Modulo',
// Dotget
'.': 'Dot',
// Pipe
'|': 'operator',
}
export class Tree {
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,
}
}
iterate(options: { enter: (node: SyntaxNode) => void }) {
const iter = (node: SyntaxNode) => {
for (const n of node.children) iter(n)
options.enter(node)
}
iter(this.topNode)
}
}
export class SyntaxNode {
#type: NodeType
#isError = false
from: number
to: number
parent: SyntaxNode | null
children: SyntaxNode[] = []
constructor(type: NodeType, from: number, to: number, parent: SyntaxNode | null = null) {
this.#type = type
this.from = from
this.to = to
this.parent = parent
}
static from(token: Token, parent?: SyntaxNode): SyntaxNode {
return new SyntaxNode(TokenType[token.type] as NodeType, token.from, token.to, parent ?? null)
}
get type(): {
type: NodeType
name: NodeType
isError: boolean
is: (other: NodeType) => boolean
} {
return {
type: this.#type,
name: this.#type,
isError: this.#isError,
is: (other: NodeType) => other === this.#type,
}
}
set type(name: NodeType) {
this.#type = name
}
get name(): string {
return this.type.name
}
get isError(): boolean {
return this.#isError
}
set isError(err: boolean) {
this.#isError = err
}
get firstChild(): SyntaxNode | null {
return this.children[0] ?? null
}
get lastChild(): SyntaxNode | null {
return this.children.at(-1) ?? null
}
get nextSibling(): SyntaxNode | null {
if (!this.parent) return null
const siblings = this.parent.children
const index = siblings.indexOf(this)
return index >= 0 && index < siblings.length - 1 ? siblings[index + 1]! : null
}
get prevSibling(): SyntaxNode | null {
if (!this.parent) return null
const siblings = this.parent.children
const index = siblings.indexOf(this)
return index > 0 ? siblings[index - 1]! : null
}
add(node: SyntaxNode) {
node.parent = this
this.children.push(node)
}
push(...nodes: SyntaxNode[]): SyntaxNode {
nodes.forEach((child) => (child.parent = this))
this.children.push(...nodes)
return this
}
toString(): string {
return this.type.name
}
}
// Operator precedence (binding power) - higher = tighter binding
export const precedence: Record<string, number> = {
// Logical
or: 10,
and: 20,
// Comparison
'==': 30,
'!=': 30,
'<': 30,
'>': 30,
'<=': 30,
'>=': 30,
// Nullish coalescing
'??': 35,
// Bitwise shifts (lower precedence than addition)
'<<': 37,
'>>': 37,
'>>>': 37,
// Addition/Subtraction
'+': 40,
'-': 40,
// Bitwise AND/OR/XOR (higher precedence than addition)
band: 45,
bor: 45,
bxor: 45,
// Multiplication/Division/Modulo
'*': 50,
'/': 50,
'%': 50,
// Exponentiation (right-associative)
'**': 60,
}
export const conditionals = new Set(['==', '!=', '<', '>', '<=', '>=', '??', 'and', 'or'])
export const compounds = ['??=', '+=', '-=', '*=', '/=', '%=']

View File

@ -0,0 +1,99 @@
import { ExternalTokenizer, InputStream } from '@lezer/lr'
import * as terms from './shrimp.terms'
type Operator = { str: string; tokenName: keyof typeof terms }
const operators: Array<Operator> = [
{ str: 'and', tokenName: 'And' },
{ str: 'or', tokenName: 'Or' },
{ str: 'band', tokenName: 'Band' },
{ str: 'bor', tokenName: 'Bor' },
{ str: 'bxor', tokenName: 'Bxor' },
{ str: '>>>', tokenName: 'Ushr' }, // Must come before >>
{ str: '>>', tokenName: 'Shr' },
{ str: '<<', tokenName: 'Shl' },
{ str: '>=', tokenName: 'Gte' },
{ str: '<=', tokenName: 'Lte' },
{ str: '!=', tokenName: 'Neq' },
{ str: '==', tokenName: 'EqEq' },
// Compound assignment operators (must come before single-char operators)
{ str: '??=', tokenName: 'NullishEq' },
{ str: '+=', tokenName: 'PlusEq' },
{ str: '-=', tokenName: 'MinusEq' },
{ str: '*=', tokenName: 'StarEq' },
{ str: '/=', tokenName: 'SlashEq' },
{ str: '%=', tokenName: 'ModuloEq' },
// Nullish coalescing (must come before it could be mistaken for other tokens)
{ str: '??', tokenName: 'NullishCoalesce' },
// Single-char operators
{ str: '*', tokenName: 'Star' },
{ str: '=', tokenName: 'Eq' },
{ str: '/', tokenName: 'Slash' },
{ str: '+', tokenName: 'Plus' },
{ str: '-', tokenName: 'Minus' },
{ str: '>', tokenName: 'Gt' },
{ str: '<', tokenName: 'Lt' },
{ str: '%', tokenName: 'Modulo' },
]
export const operatorTokenizer = new ExternalTokenizer((input: InputStream) => {
for (let operator of operators) {
if (!matchesString(input, 0, operator.str)) continue
const afterOpPos = operator.str.length
const charAfterOp = input.peek(afterOpPos)
if (!isWhitespace(charAfterOp)) continue
// Accept the operator token
const token = terms[operator.tokenName]
if (token === undefined) {
throw new Error(`Unknown token name: ${operator.tokenName}`)
}
input.advance(afterOpPos)
input.acceptToken(token)
return
}
})
const isWhitespace = (ch: number): boolean => {
return matchesChar(ch, [' ', '\t', '\n'])
}
const matchesChar = (ch: number, chars: (string | number)[]): boolean => {
for (const c of chars) {
if (typeof c === 'number') {
if (ch === c) {
return true
}
} else if (ch === c.charCodeAt(0)) {
return true
}
}
return false
}
const matchesString = (input: InputStream, pos: number, str: string): boolean => {
for (let i = 0; i < str.length; i++) {
if (input.peek(pos + i) !== str.charCodeAt(i)) {
return false
}
}
return true
}
const peek = (numChars: number, input: InputStream): string => {
let result = ''
for (let i = 0; i < numChars; i++) {
const ch = input.peek(i)
if (ch === -1) {
result += 'EOF'
break
} else {
result += String.fromCharCode(ch)
}
}
return result
}

View File

@ -1,968 +0,0 @@
import { CompilerError } from '#compiler/compilerError'
import { Scanner, type Token, TokenType } from './tokenizer2'
import { SyntaxNode, operators, precedence, conditionals, compounds } from './node'
import { parseString } from './stringParser'
const $T = TokenType
// tell the dotGet searcher about builtin globals
export const globals: string[] = []
export const setGlobals = (newGlobals: string[] | Record<string, any>) => {
globals.length = 0
globals.push(...(Array.isArray(newGlobals) ? newGlobals : Object.keys(newGlobals)))
}
export const parse = (input: string): SyntaxNode => {
const parser = new Parser()
return parser.parse(input)
}
class Scope {
parent?: Scope
set = new Set<string>()
constructor(parent?: Scope) {
this.parent = parent
// no parent means this is global scope
if (!parent) for (const name of globals) this.add(name)
}
add(key: string) {
this.set.add(key)
}
has(key: string): boolean {
return this.set.has(key) || this.parent?.has(key) || false
}
}
export class Parser {
tokens: Token[] = []
pos = 0
inParens = 0
input = ''
scope = new Scope()
inTestExpr = false
parse(input: string): SyntaxNode {
const scanner = new Scanner()
this.tokens = scanner.tokenize(input)
this.pos = 0
this.input = input
this.scope = new Scope()
this.inTestExpr = false
const node = new SyntaxNode('Program', 0, input.length)
while (!this.isEOF()) {
if (this.is($T.Newline) || this.is($T.Semicolon)) {
this.next()
continue
}
const prevPos = this.pos
const stmt = this.statement()
if (stmt) node.add(stmt)
if (this.pos === prevPos && !this.isEOF())
throw `parser didn't advance - you need to call next()\n\n ${this.input}\n`
}
return node
}
//
// parse foundation nodes - statements, expressions
//
// statement is a line of code
statement(): SyntaxNode | null {
if (this.is($T.Comment)) return this.comment()
while (this.is($T.Newline) || this.is($T.Semicolon)) this.next()
if (this.isEOF() || this.isExprEndKeyword()) return null
return this.expression()
}
// expressions can be found in four places:
// 1. line of code
// 2. right side of assignment
// 3. if/while conditions
// 4. inside (parens)
expression(allowPipe = true): SyntaxNode {
let expr
// x = value
if (
this.is($T.Identifier) &&
(this.nextIs($T.Operator, '=') || compounds.some((x) => this.nextIs($T.Operator, x)))
)
expr = this.assign()
// if, while, do, etc
else if (this.is($T.Keyword)) expr = this.keywords()
// dotget
else if (this.nextIs($T.Operator, '.')) expr = this.dotGetFunctionCall()
// echo hello world
else if (this.is($T.Identifier) && !this.nextIs($T.Operator) && !this.nextIsExprEnd())
expr = this.functionCall()
// bare-function-call
else if (this.is($T.Identifier) && this.nextIsExprEnd()) expr = this.functionCallOrIdentifier()
// everything else
else expr = this.exprWithPrecedence()
// check for destructuring
if (expr.type.is('Array') && this.is($T.Operator, '=')) return this.destructure(expr)
// check for parens function call
// ex: (ref my-func) my-arg
if (expr.type.is('ParenExpr') && !this.isExprEnd()) expr = this.functionCall(expr)
// if dotget is followed by binary operator, continue parsing as binary expression
if (expr.type.is('DotGet') && this.is($T.Operator) && !this.is($T.Operator, '|'))
expr = this.dotGetBinOp(expr)
// one | echo
if (allowPipe && this.isPipe()) return this.pipe(expr)
// regular
else return expr
}
// piping | stuff | is | cool
pipe(left: SyntaxNode): SyntaxNode {
const canLookPastNewlines = this.inParens === 0
const parts: SyntaxNode[] = [left]
while (this.isPipe()) {
// consume newlines before pipe (only if not in parens)
if (canLookPastNewlines) {
while (this.is($T.Newline)) this.next()
}
const pipeOp = this.op('|')
pipeOp.type = 'operator'
parts.push(pipeOp)
// consume newlines after pipe (only if not in parens)
if (canLookPastNewlines) {
while (this.is($T.Newline)) this.next()
}
// parse right side - don't allow nested pipes
parts.push(this.expression(false))
}
const node = new SyntaxNode('PipeExpr', parts[0]!.from, parts.at(-1)!.to)
return node.push(...parts)
}
// Pratt parser - parses expressions with precedence climbing
// bp = binding precedence
exprWithPrecedence(minBp = 0): SyntaxNode {
let left = this.value()
// infix operators with precedence
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
}
// if, while, do, etc
keywords(): SyntaxNode {
if (this.is($T.Keyword, 'if')) return this.if()
if (this.is($T.Keyword, 'while')) return this.while()
if (this.is($T.Keyword, 'do')) return this.do()
if (this.is($T.Keyword, 'try')) return this.try()
if (this.is($T.Keyword, 'throw')) return this.throw()
if (this.is($T.Keyword, 'not')) return this.not()
if (this.is($T.Keyword, 'import')) return this.import()
return this.expect($T.Keyword, 'if/while/do/import') as never
}
// value can be an atom or a (parens that gets turned into an atom)
// values are used in a few places:
// 1. function arguments
// 2. array/dict members
// 3. binary operations
// 4. anywhere an expression can be used
value(): SyntaxNode {
if (this.is($T.OpenParen)) return this.parens()
if (this.is($T.OpenBracket)) return this.arrayOrDict()
// dotget
if (this.nextIs($T.Operator, '.')) return this.dotGet()
return this.atom()
}
//
// 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)
if (val.isError) arg.isError = true
arg.add(val)
return arg
}
}
// [ 1 2 3 ]
array(): SyntaxNode {
const open = this.expect($T.OpenBracket)
const values = []
while (!this.is($T.CloseBracket) && !this.isEOF()) {
if (this.is($T.Semicolon) || this.is($T.Newline)) {
this.next()
continue
}
if (this.is($T.Comment)) {
values.push(this.comment())
continue
}
values.push(this.value())
}
const close = this.expect($T.CloseBracket)
const node = new SyntaxNode('Array', open.from, close.to)
return node.push(...values)
}
// which are we dealing with? ignores leading newlines and comments
arrayOrDict(): SyntaxNode {
let peek = 1
let curr = this.peek(peek++)
let isDict = false
while (curr && curr.type !== $T.CloseBracket) {
// definitely a dict
if (curr.type === $T.NamedArgPrefix) {
isDict = true
break
}
// empty dict
if (curr.type === $T.Operator && curr.value === '=') {
isDict = true
break
}
// [ a = true ]
const next = this.peek(peek)
if (next?.type === $T.Operator && next.value === '=') {
isDict = true
break
}
// probably an array
if (curr.type !== $T.Comment && curr.type !== $T.Semicolon && curr.type !== $T.Newline) break
curr = this.peek(peek++)
}
return isDict ? this.dict() : this.array()
}
// x = true
assign(): SyntaxNode {
const ident = this.assignableIdentifier()
const opToken = this.current()!
const op = this.op()
const expr = this.expression()
const node = new SyntaxNode(
opToken.value === '=' ? 'Assign' : 'CompoundAssign',
ident.from,
expr.to,
)
return node.push(ident, op, expr)
}
// identifier used in assignment (TODO: legacy lezer quirk)
assignableIdentifier(): SyntaxNode {
const token = this.expect($T.Identifier)
this.scope.add(token.value!)
const node = SyntaxNode.from(token)
node.type = 'AssignableIdentifier'
return node
}
// atoms are the basic building blocks: literals, identifiers, words
atom(): SyntaxNode {
if (this.is($T.String)) return this.string()
if (this.isAny($T.Null, $T.Boolean, $T.Number, $T.Identifier, $T.Word, $T.Regex, $T.Underscore))
return SyntaxNode.from(this.next())
const next = this.next()
throw new CompilerError(`Unexpected token: ${TokenType[next.type]}`, next.from, next.to)
}
// blocks in if, do, special calls, etc
// `: something end`
//
// `blockNode` determines whether we return [colon, BlockNode, end] or
// just a list of statements like [colon, stmt1, stmt2, end]
block(blockNode = true): SyntaxNode[] {
const stmts: SyntaxNode[] = []
const colon = this.colon()
while (!this.isExprEndKeyword() && !this.isEOF()) {
const stmt = this.statement()
if (stmt) stmts.push(stmt)
}
const out = [colon]
if (blockNode) {
const block = new SyntaxNode('Block', stmts[0]!.from, stmts.at(-1)!.to)
block.push(...stmts)
out.push(block)
} else {
out.push(...stmts)
}
return out
}
// catch err: block
catch(): SyntaxNode {
const keyword = this.keyword('catch')
let catchVar
if (this.is($T.Identifier)) catchVar = this.identifier()
const block = this.block()
const node = new SyntaxNode('CatchExpr', keyword.from, block.at(-1)!.to)
node.push(keyword)
if (catchVar) node.push(catchVar)
return node.push(...block)
}
// colon
colon(): SyntaxNode {
const colon = SyntaxNode.from(this.expect($T.Colon))
colon.type = 'colon' // TODO lezer legacy
return colon
}
// # comment
comment(): SyntaxNode {
return SyntaxNode.from(this.expect($T.Comment))
}
// [ a b c ] = [ 1 2 3 ]
destructure(array: SyntaxNode): SyntaxNode {
const eq = this.op('=')
const val = this.expression()
for (const ident of array.children) {
const varName = this.input.slice(ident.from, ident.to)
this.scope.add(varName)
}
const node = new SyntaxNode('Assign', array.from, val.to)
return node.push(array, eq, val)
}
// [ a=1 b=true c='three' ]
dict(): SyntaxNode {
const open = this.expect($T.OpenBracket)
let isError = false
// empty dict [=] or [ = ]
if (this.is($T.Operator, '=') && this.nextIs($T.CloseBracket)) {
const _op = this.next()
const close = this.next()
return new SyntaxNode('Dict', open.from, close.to)
}
const values = []
while (!this.is($T.CloseBracket) && !this.isEOF()) {
if (this.is($T.Semicolon) || this.is($T.Newline)) {
this.next()
continue
}
if (this.is($T.Comment)) {
values.push(this.comment())
continue
}
// check for named arg with space after it (vs connected)
if (this.nextIs($T.Operator, '=')) {
const ident = this.identifier()
const op = this.op('=')
const prefix = new SyntaxNode('NamedArgPrefix', ident.from, op.to)
if (this.is($T.CloseBracket) || this.is($T.Semicolon) || this.is($T.Newline)) {
const node = new SyntaxNode('NamedArg', ident.from, op.to)
node.isError = true
isError = true
values.push(node.push(prefix))
} else {
const val = this.arg(true)
const node = new SyntaxNode('NamedArg', ident.from, val.to)
values.push(node.push(prefix, val))
}
} else {
const arg = this.is($T.NamedArgPrefix) ? this.namedArg() : this.arg()
if (arg.isError) isError = true
values.push(arg)
}
}
const close = this.expect($T.CloseBracket)
const node = new SyntaxNode('Dict', open.from, close.to)
node.isError = isError
return node.push(...values)
}
// FunctionDef `do x y: something end`
do(): SyntaxNode {
const doNode = this.keyword('do')
doNode.type = 'Do'
this.scope = new Scope(this.scope)
const params = []
while (!this.is($T.Colon) && !this.isExprEnd()) {
let varName = this.current().value!
if (varName.endsWith('=')) varName = varName.slice(0, varName.length - 1)
this.scope.add(varName)
let arg
if (this.is($T.Identifier)) arg = this.identifier()
else if (this.is($T.NamedArgPrefix)) arg = this.namedParam()
else
throw new CompilerError(
`Expected Identifier or NamedArgPrefix, got ${TokenType[this.current().type]}`,
this.current().from,
this.current().to,
)
params.push(arg)
}
const block = this.block(false)
let catchNode, finalNode
if (this.is($T.Keyword, 'catch')) catchNode = this.catch()
if (this.is($T.Keyword, 'finally')) finalNode = this.finally()
const end = this.keyword('end')
let last = block.at(-1)
if (finalNode) last = finalNode.children.at(-1)!
else if (catchNode) last = catchNode.children.at(-1)!
const node = new SyntaxNode('FunctionDef', doNode.from, last!.to)
node.add(doNode)
const paramsNode = new SyntaxNode('Params', params[0]?.from ?? 0, params.at(-1)?.to ?? 0)
if (params.length) paramsNode.push(...params)
node.add(paramsNode)
this.scope = this.scope.parent!
node.push(...block)
if (catchNode) node.push(catchNode)
if (finalNode) node.push(finalNode)
return node.push(end)
}
// config.path
dotGet(): SyntaxNode {
const left = this.identifier()
const ident = this.input.slice(left.from, left.to)
// not in scope, just return Word
if (!this.scope.has(ident)) return this.word(left)
if (left.type.is('Identifier')) left.type = 'IdentifierBeforeDot'
let parts = []
while (this.is($T.Operator, '.')) {
this.next()
parts.push(this.is($T.OpenParen) ? this.parens() : this.atom())
}
// TODO lezer legacy - we can do a flat DotGet if we remove this
const nodes = parts.length > 1 ? collapseDotGets(parts) : undefined
const node = new SyntaxNode('DotGet', left.from, parts.at(-1)!.to)
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)
dotGetFunctionCall(): SyntaxNode {
const dotGet = this.dotGet()
// if followed by a binary operator (not pipe), return dotGet/Word as-is for expression parser
if (this.is($T.Operator) && !this.is($T.Operator, '|')) return dotGet
// dotget not in scope, regular Word
if (dotGet.type.is('Word')) return dotGet
if (this.isExprEnd()) return this.functionCallOrIdentifier(dotGet)
else return this.functionCall(dotGet)
}
// can be used in functions or try block
finally(): SyntaxNode {
const keyword = this.keyword('finally')
const block = this.block()
const node = new SyntaxNode('FinallyExpr', keyword.from, block.at(-1)!.to)
return node.push(keyword, ...block)
}
// you're lookin at it
functionCall(fn?: SyntaxNode): SyntaxNode {
const ident = fn ?? this.identifier()
let isError = false
const args: SyntaxNode[] = []
while (!this.isExprEnd()) {
const arg = this.is($T.NamedArgPrefix) ? this.namedArg() : this.arg()
if (arg.isError) isError = true
args.push(arg)
}
const node = new SyntaxNode('FunctionCall', ident.from, (args.at(-1) || ident).to)
node.push(ident, ...args)
if (isError) node.isError = true
if (!this.inTestExpr && this.is($T.Colon)) {
const block = this.block()
const end = this.keyword('end')
const blockNode = new SyntaxNode('FunctionCallWithBlock', node.from, end.to)
return blockNode.push(node, ...block, end)
}
return node
}
// bare identifier in an expression
functionCallOrIdentifier(inner?: SyntaxNode) {
if (!inner && this.nextIs($T.Operator, '.')) {
inner = this.dotGet()
// if the dotGet was just a Word, bail
if (inner.type.is('Word')) return inner
}
inner ??= this.identifier()
const wrapper = new SyntaxNode('FunctionCallOrIdentifier', inner.from, inner.to)
wrapper.push(inner)
if (!this.inTestExpr && this.is($T.Colon)) {
const block = this.block()
const end = this.keyword('end')
const node = new SyntaxNode('FunctionCallWithBlock', wrapper.from, end.to)
return node.push(wrapper, ...block, end)
}
return wrapper
}
// function and variable names
identifier(): SyntaxNode {
return SyntaxNode.from(this.expect($T.Identifier))
}
// if something: blah end
// if something: blah else: blah end
// if something: blah else if something: blah else: blah end
if(): SyntaxNode {
const ifNode = this.keyword('if')
const test = this.testExpr()
const ifBlock = this.block()
const node = new SyntaxNode('IfExpr', ifNode.from, ifBlock.at(-1)!.to)
node.push(ifNode, test)
node.push(...ifBlock)
while (this.is($T.Keyword, 'else') && this.nextIs($T.Keyword, 'if')) {
const elseWord = this.keyword('else')
const ifWord = this.keyword('if')
const elseIfTest = this.testExpr()
const elseIfBlock = this.block()
const elseIfNode = new SyntaxNode('ElseIfExpr', elseWord.from, elseIfBlock.at(-1)!.to)
elseIfNode.push(elseWord, ifWord, elseIfTest)
elseIfNode.push(...elseIfBlock)
node.push(elseIfNode)
}
if (this.is($T.Keyword, 'else') && this.nextIs($T.Colon)) {
const elseWord = this.keyword('else')
const elseBlock = this.block()
const elseNode = new SyntaxNode('ElseExpr', elseWord.from, elseBlock.at(-1)!.to)
elseNode.push(elseWord)
elseNode.push(...elseBlock)
node.push(elseNode)
}
return node.push(this.keyword('end'))
}
import(): SyntaxNode {
const keyword = this.keyword('import')
const args: SyntaxNode[] = []
while (!this.isExprEnd()) {
if (this.is($T.NamedArgPrefix)) {
const prefix = SyntaxNode.from(this.next())
const val = this.value()
const arg = new SyntaxNode('NamedArg', prefix.from, val.to)
arg.push(prefix, val)
args.push(arg)
} else {
args.push(this.identifier())
}
}
const node = new SyntaxNode('Import', keyword.from, args.at(-1)!.to)
node.add(keyword)
return node.push(...args)
}
// if, while, do, etc
keyword(name: string): SyntaxNode {
const node = SyntaxNode.from(this.expect($T.Keyword, name))
node.type = 'keyword' // TODO lezer legacy
return node
}
// abc= true
namedArg(): SyntaxNode {
const prefix = SyntaxNode.from(this.expect($T.NamedArgPrefix))
if (this.isExprEnd()) {
const node = new SyntaxNode('NamedArg', prefix.from, prefix.to)
node.isError = true
return node.push(prefix)
}
const val = this.arg(true)
const node = new SyntaxNode('NamedArg', prefix.from, val.to)
return node.push(prefix, val)
}
// abc= null|true|123|'hi'
namedParam(): SyntaxNode {
const prefix = SyntaxNode.from(this.expect($T.NamedArgPrefix))
const val = this.value()
if (!['Null', 'Boolean', 'Number', 'String'].includes(val.type.name))
throw new CompilerError(
`Default value must be null, boolean, number, or string, got ${val.type.name}`,
val.from,
val.to,
)
const node = new SyntaxNode('NamedParam', prefix.from, val.to)
return node.push(prefix, val)
}
// not blah
not(): SyntaxNode {
const keyword = this.keyword('not')
const val = this.expression()
const node = new SyntaxNode('Not', keyword.from, val.to)
return node.push(keyword, val)
}
// operators like + - =
op(op?: string): SyntaxNode {
const token = op ? this.expect($T.Operator, op) : this.expect($T.Operator)
const name = operators[token.value!]
if (!name)
throw new CompilerError(`Operator not registered: ${token.value!}`, token.from, token.to)
return new SyntaxNode(name, token.from, token.to)
}
// ( expressions in parens )
parens(): SyntaxNode {
this.inParens++
const open = this.expect($T.OpenParen)
const child = this.expression()
const close = this.expect($T.CloseParen)
this.inParens--
const node = new SyntaxNode('ParenExpr', open.from, close.to)
node.add(child)
return node
}
// 'hell yes' "hell no" { hell if i know }
string(): SyntaxNode {
const token = this.expect($T.String)
return parseString(this.input, token.from, token.to, this)
}
// if TEST: blah end
testExpr(): SyntaxNode {
this.inTestExpr = true
const expr = this.expression()
this.inTestExpr = false
return expr
}
// throw blah
throw(): SyntaxNode {
const keyword = this.keyword('throw')
const val = this.expression()
const node = new SyntaxNode('Throw', keyword.from, val.to)
return node.push(keyword, val)
}
// try: blah catch e: blah end
try(): SyntaxNode {
const tryNode = this.keyword('try')
const tryBlock = this.block()
let last = tryBlock.at(-1)
let catchNode, finalNode
if (this.is($T.Keyword, 'catch')) catchNode = this.catch()
if (this.is($T.Keyword, 'finally')) finalNode = this.finally()
const end = this.keyword('end')
if (finalNode) last = finalNode.children.at(-1)
else if (catchNode) last = catchNode.children.at(-1)
const node = new SyntaxNode('TryExpr', tryNode.from, last!.to)
node.push(tryNode, ...tryBlock)
if (catchNode) node.push(catchNode)
if (finalNode) node.push(finalNode)
return node.push(end)
}
// while test: blah end
while(): SyntaxNode {
const keyword = this.keyword('while')
const test = this.testExpr()
const block = this.block()
const end = this.keyword('end')
const node = new SyntaxNode('WhileExpr', keyword.from, end.to)
return node.push(keyword, test, ...block, end)
}
// readme.txt (when `readme` isn't in scope)
word(start?: SyntaxNode): SyntaxNode {
const parts = [start ?? this.expect($T.Word)]
while (this.is($T.Operator, '.')) {
this.next()
if (this.isAny($T.Word, $T.Identifier, $T.Number)) parts.push(this.next())
}
return new SyntaxNode('Word', parts[0]!.from, parts.at(-1)!.to)
}
//
// helpers
//
current(): Token {
return this.tokens[this.pos] || { type: TokenType.Newline, from: 0, to: 0 }
}
peek(offset = 1): Token | undefined {
return this.tokens[this.pos + offset]
}
// look past newlines to check for a specific token
peekPastNewlines(type: TokenType, value?: string): boolean {
let offset = 1
let peek = this.peek(offset)
while (peek && peek.type === $T.Newline) peek = this.peek(++offset)
if (!peek || peek.type !== type) return false
if (value !== undefined && peek.value !== value) return false
return true
}
next(): Token {
const token = this.current()
this.pos++
return token
}
is(type: TokenType, value?: string): boolean {
const token = this.current()
if (!token || token.type !== type) return false
if (value !== undefined && token.value !== value) return false
return true
}
isAny(...type: TokenType[]): boolean {
return type.some((x) => this.is(x))
}
nextIs(type: TokenType, value?: string): boolean {
const token = this.peek()
if (!token || token.type !== type) return false
if (value !== undefined && token.value !== value) return false
return true
}
nextIsAny(...type: TokenType[]): boolean {
return type.some((x) => this.nextIs(x))
}
isExprEnd(): boolean {
return (
this.isAny($T.Colon, $T.Semicolon, $T.Newline, $T.CloseParen, $T.CloseBracket) ||
this.is($T.Operator, '|') ||
this.isExprEndKeyword() ||
!this.current()
)
}
nextIsExprEnd(): boolean {
// pipes act like expression end for function arg parsing
if (this.nextIs($T.Operator, '|')) return true
return (
this.nextIsAny($T.Colon, $T.Semicolon, $T.Newline, $T.CloseBracket, $T.CloseParen) ||
this.nextIs($T.Keyword, 'end') ||
this.nextIs($T.Keyword, 'else') ||
this.nextIs($T.Keyword, 'catch') ||
this.nextIs($T.Keyword, 'finally') ||
!this.peek()
)
}
isExprEndKeyword(): boolean {
return (
this.is($T.Keyword, 'end') ||
this.is($T.Keyword, 'else') ||
this.is($T.Keyword, 'catch') ||
this.is($T.Keyword, 'finally')
)
}
isPipe(): boolean {
// inside parens, only look for pipes on same line (don't look past newlines)
const canLookPastNewlines = this.inParens === 0
return (
this.is($T.Operator, '|') || (canLookPastNewlines && this.peekPastNewlines($T.Operator, '|'))
)
}
expect(type: TokenType, value?: string): Token | never {
if (!this.is(type, value)) {
const token = this.current()
throw new CompilerError(
`Expected ${TokenType[type]}${value ? ` "${value}"` : ''}, got ${TokenType[token?.type || 0]}${token?.value ? ` "${token.value}"` : ''} at position ${this.pos}`,
token.from,
token.to,
)
}
return this.next()
}
isEOF(): boolean {
return this.pos >= this.tokens.length
}
}
// TODO lezer legacy
function collapseDotGets(origNodes: SyntaxNode[]): SyntaxNode {
const nodes = [...origNodes]
let right = nodes.pop()!
while (nodes.length > 0) {
const left = nodes.pop()!
if (left.type.is('Identifier')) left.type = 'IdentifierBeforeDot'
const dot = new SyntaxNode('DotGet', left.from, right.to)
dot.push(left, right)
right = dot
}
return right
}

View File

@ -0,0 +1,129 @@
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>()) { }
has(name: string): boolean {
return this.vars.has(name) || (this.parent?.has(name) ?? false)
}
hash(): number {
let h = 0
for (const name of this.vars) {
for (let i = 0; i < name.length; i++) {
h = (h << 5) - h + name.charCodeAt(i)
h |= 0
}
}
if (this.parent) {
h = (h << 5) - h + this.parent.hash()
h |= 0
}
return h
}
// Static methods that return new Scopes (immutable operations)
static add(scope: Scope, ...names: string[]): Scope {
const newVars = new Set(scope.vars)
names.forEach((name) => newVars.add(name))
return new Scope(scope.parent, newVars)
}
push(): Scope {
return new Scope(this, new Set())
}
pop(): Scope {
return this.parent ?? this
}
}
// Tracker context that combines Scope with temporary pending identifiers
class TrackerContext {
constructor(public scope: Scope, public pendingIds: string[] = []) { }
}
// Extract identifier text from input stream
const readIdentifierText = (input: InputStream, start: number, end: number): string => {
let text = ''
for (let i = start; i < end; i++) {
const offset = i - input.pos
const ch = input.peek(offset)
if (ch === -1) break
text += String.fromCharCode(ch)
}
return text
}
let inParams = false
export const trackScope = new ContextTracker<TrackerContext>({
start: new TrackerContext(new Scope(null, new Set())),
shift(context, term, stack, input) {
if (term == terms.Do) inParams = true
if (term === terms.AssignableIdentifier) {
const text = readIdentifierText(input, input.pos, stack.pos)
return new TrackerContext(Scope.add(context.scope, text), context.pendingIds)
}
if (inParams && term === terms.Identifier) {
const text = readIdentifierText(input, input.pos, stack.pos)
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
},
reduce(context, term) {
if (term === terms.Params) {
inParams = false
let newScope = context.scope.push()
if (context.pendingIds.length > 0) {
newScope = Scope.add(newScope, ...context.pendingIds)
}
return new TrackerContext(newScope, [])
}
// Pop scope when exiting function
if (term === terms.FunctionDef) {
return new TrackerContext(context.scope.pop(), [])
}
return context
},
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 /* = */
}

288
src/parser/shrimp.grammar Normal file
View File

@ -0,0 +1,288 @@
@external propSource highlighting from "./highlight"
@context trackScope from "./parserScopeContext"
@skip { space | Comment }
@top Program { item* }
@external tokens operatorTokenizer from "./operatorTokenizer" { Star, Slash, Plus, Minus, And, Or, Eq, EqEq, Neq, Lt, Lte, Gt, Gte, Modulo, PlusEq, MinusEq, StarEq, SlashEq, ModuloEq, Band, Bor, Bxor, Shl, Shr, Ushr, NullishCoalesce, NullishEq }
@tokens {
@precedence { Number Regex }
StringFragment { !['\\$]+ }
DoubleQuote { '"' !["]* '"' }
NamedArgPrefix { $[a-z] $[a-z0-9-]* "=" }
Number {
("-" | "+")? "0x" $[0-9a-fA-F]+ |
("-" | "+")? "0b" $[01]+ |
("-" | "+")? "0o" $[0-7]+ |
("-" | "+")? $[0-9]+ ("_"? $[0-9]+)* ('.' $[0-9]+ ("_"? $[0-9]+)*)?
}
Boolean { "true" | "false" }
newlineOrSemicolon { "\n" | ";" }
eof { @eof }
space { " " | "\t" }
Comment { "#" ![\n]* }
leftParen { "(" }
rightParen { ")" }
colon[closedBy="end", @name="colon"] { ":" }
Underscore { "_" }
Regex { "//" (![/\\\n[] | "\\" ![\n] | "[" (![\n\\\]] | "\\" ![\n])* "]")+ ("//" $[gimsuy]*)? } // Stolen from the lezer JavaScript grammar
"|"[@name=operator]
}
end { @specialize[@name=keyword]<Identifier, "end"> }
while { @specialize[@name=keyword]<Identifier, "while"> }
if { @specialize[@name=keyword]<Identifier, "if"> }
else { @specialize[@name=keyword]<Identifier, "else"> }
try { @specialize[@name=keyword]<Identifier, "try"> }
catch { @specialize[@name=keyword]<Identifier, "catch"> }
finally { @specialize[@name=keyword]<Identifier, "finally"> }
throw { @specialize[@name=keyword]<Identifier, "throw"> }
import { @specialize[@name=keyword]<Identifier, "import"> }
null { @specialize[@name=Null]<Identifier, "null"> }
@external tokens tokenizer from "./tokenizer" { Identifier, AssignableIdentifier, Word, IdentifierBeforeDot, CurlyString }
@external specialize {Identifier} specializeKeyword from "./tokenizer" { Do }
@precedence {
pipe @left,
or @left,
and @left,
nullish @left,
comparison @left,
multiplicative @left,
additive @left,
bitwise @left,
call,
functionWithNewlines
}
item {
consumeToTerminator newlineOrSemicolon |
consumeToTerminator eof |
newlineOrSemicolon // allow blank lines
}
consumeToTerminator {
PipeExpr |
WhileExpr |
FunctionCallWithBlock |
ambiguousFunctionCall |
TryExpr |
Throw |
Import |
IfExpr |
FunctionDef |
CompoundAssign |
Assign |
BinOp |
ConditionalOp |
expressionWithoutIdentifier
}
PipeExpr {
pipeOperand (!pipe "|" pipeOperand)+
}
pipeOperand {
consumeToTerminator
}
WhileExpr {
while (ConditionalOp | expression) colon Block end
}
Block {
consumeToTerminator | newlineOrSemicolon block
}
FunctionCallWithBlock {
ambiguousFunctionCall colon Block CatchExpr? FinallyExpr? end
}
FunctionCallOrIdentifier {
DotGet | Identifier
}
ambiguousFunctionCall {
FunctionCall | FunctionCallOrIdentifier
}
FunctionCall {
(DotGet | Identifier | ParenExpr) arg+
}
arg {
PositionalArg | NamedArg
}
PositionalArg {
expression | FunctionDef | Underscore
}
NamedArg {
NamedArgPrefix (expression | FunctionDef | Underscore)
}
FunctionDef {
Do Params colon (consumeToTerminator | newlineOrSemicolon block) CatchExpr? FinallyExpr? end
}
ifTest {
ConditionalOp | expression | FunctionCall
}
IfExpr {
if ifTest colon Block ElseIfExpr* ElseExpr? end
}
ElseIfExpr {
else if ifTest colon Block
}
ElseExpr {
else colon Block
}
TryExpr {
try colon Block CatchExpr? FinallyExpr? end
}
CatchExpr {
catch Identifier colon Block
}
FinallyExpr {
finally colon Block
}
Throw {
throw (BinOp | ConditionalOp | expression)
}
// this has to be in the parse tree so the scope tracker can use it
Import {
import NamedArg* Identifier+ NamedArg*
}
ConditionalOp {
expression !comparison EqEq expression |
expression !comparison Neq expression |
expression !comparison Lt expression |
expression !comparison Lte expression |
expression !comparison Gt expression |
expression !comparison Gte expression |
(expression | ConditionalOp) !and And (expression | ConditionalOp) |
(expression | ConditionalOp) !or Or (expression | ConditionalOp) |
(expression | ConditionalOp) !nullish NullishCoalesce (expression | ConditionalOp)
}
Params {
Identifier* NamedParam*
}
NamedParam {
NamedArgPrefix (String | Number | Boolean | null)
}
Assign {
(AssignableIdentifier | Array) Eq consumeToTerminator
}
CompoundAssign {
AssignableIdentifier (PlusEq | MinusEq | StarEq | SlashEq | ModuloEq | NullishEq) consumeToTerminator
}
BinOp {
expression !multiplicative Modulo expression |
(expression | BinOp) !multiplicative Star (expression | BinOp) |
(expression | BinOp) !multiplicative Slash (expression | BinOp) |
(expression | BinOp) !additive Plus (expression | BinOp) |
(expression | BinOp) !additive Minus (expression | BinOp) |
(expression | BinOp) !bitwise Band (expression | BinOp) |
(expression | BinOp) !bitwise Bor (expression | BinOp) |
(expression | BinOp) !bitwise Bxor (expression | BinOp) |
(expression | BinOp) !bitwise Shl (expression | BinOp) |
(expression | BinOp) !bitwise Shr (expression | BinOp) |
(expression | BinOp) !bitwise Ushr (expression | BinOp)
}
ParenExpr {
leftParen newlineOrSemicolon* (
FunctionCallWithNewlines |
IfExpr |
ambiguousFunctionCall |
BinOp newlineOrSemicolon* |
expressionWithoutIdentifier |
ConditionalOp newlineOrSemicolon* |
PipeExpr |
FunctionDef
)
rightParen
}
FunctionCallWithNewlines[@name=FunctionCall] {
(DotGet | Identifier | ParenExpr) newlineOrSemicolon+ arg !functionWithNewlines (newlineOrSemicolon+ arg)* newlineOrSemicolon*
}
expression {
expressionWithoutIdentifier | DotGet | Identifier
}
@local tokens {
dot { "." }
}
@skip {} {
DotGet {
IdentifierBeforeDot dot (Number | Identifier | ParenExpr)
}
String {
"'" stringContent* "'" | CurlyString | DoubleQuote
}
}
stringContent {
StringFragment |
Interpolation |
EscapeSeq
}
Interpolation {
"$" Identifier |
"$" ParenExpr
}
EscapeSeq {
"\\" ("$" | "n" | "t" | "r" | "\\" | "'")
}
Dict {
"[=]" |
"[" newlineOrSemicolon* NamedArg (newlineOrSemicolon | NamedArg)* "]"
}
Array {
"[" newlineOrSemicolon* (expression (newlineOrSemicolon | expression)*)? "]"
}
// We need expressionWithoutIdentifier to avoid conflicts in consumeToTerminator.
// Without this, when parsing "my-var" at statement level, the parser can't decide:
// - ambiguousFunctionCall → FunctionCallOrIdentifier → Identifier
// - expression → Identifier
// Both want the same Identifier token! So we use expressionWithoutIdentifier
// to remove Identifier from the second path, forcing standalone identifiers
// to go through ambiguousFunctionCall (which is what we want semantically).
// Yes, it is annoying and I gave up trying to use GLR to fix it.
expressionWithoutIdentifier {
ParenExpr | Word | String | Number | Boolean | Regex | Dict | Array | null
}
block {
(consumeToTerminator? newlineOrSemicolon)*
}

4
src/parser/shrimp.grammar.d.ts vendored Normal file
View File

@ -0,0 +1,4 @@
declare module '*.grammar' {
const content: string
export default content
}

View File

@ -0,0 +1,78 @@
// This file was generated by lezer-generator. You probably shouldn't edit it.
export const
Star = 1,
Slash = 2,
Plus = 3,
Minus = 4,
And = 5,
Or = 6,
Eq = 7,
EqEq = 8,
Neq = 9,
Lt = 10,
Lte = 11,
Gt = 12,
Gte = 13,
Modulo = 14,
PlusEq = 15,
MinusEq = 16,
StarEq = 17,
SlashEq = 18,
ModuloEq = 19,
Band = 20,
Bor = 21,
Bxor = 22,
Shl = 23,
Shr = 24,
Ushr = 25,
NullishCoalesce = 26,
NullishEq = 27,
Identifier = 28,
AssignableIdentifier = 29,
Word = 30,
IdentifierBeforeDot = 31,
CurlyString = 32,
Do = 33,
Comment = 34,
Program = 35,
PipeExpr = 36,
WhileExpr = 38,
keyword = 83,
ConditionalOp = 40,
ParenExpr = 41,
FunctionCallWithNewlines = 42,
DotGet = 43,
Number = 44,
PositionalArg = 45,
FunctionDef = 46,
Params = 47,
NamedParam = 48,
NamedArgPrefix = 49,
String = 50,
StringFragment = 51,
Interpolation = 52,
EscapeSeq = 53,
DoubleQuote = 54,
Boolean = 55,
Null = 56,
colon = 57,
CatchExpr = 58,
Block = 60,
FinallyExpr = 61,
Underscore = 64,
NamedArg = 65,
IfExpr = 66,
FunctionCall = 68,
ElseIfExpr = 69,
ElseExpr = 71,
FunctionCallOrIdentifier = 72,
BinOp = 73,
Regex = 74,
Dict = 75,
Array = 76,
FunctionCallWithBlock = 77,
TryExpr = 78,
Throw = 80,
Import = 82,
CompoundAssign = 84,
Assign = 85

27
src/parser/shrimp.ts Normal file
View File

@ -0,0 +1,27 @@
// This file was generated by lezer-generator. You probably shouldn't edit it.
import {LRParser, LocalTokenGroup} from "@lezer/lr"
import {operatorTokenizer} from "./operatorTokenizer"
import {tokenizer, specializeKeyword} from "./tokenizer"
import {trackScope} from "./parserScopeContext"
import {highlighting} from "./highlight"
const spec_Identifier = {__proto__:null,while:78, null:112, catch:118, finally:124, end:126, if:134, else:140, try:158, throw:162, import:166}
export const parser = LRParser.deserialize({
version: 14,
states: "=|QYQbOOO!mOpO'#DXO!rOSO'#D`OOQa'#D`'#D`O%mQcO'#DvO(mQcO'#EiOOQ`'#Ew'#EwO)WQRO'#DwO+]QcO'#EgO+vQbO'#DVOOQa'#Dy'#DyO.[QbO'#DzOOQa'#Ei'#EiO.cQcO'#EiO0aQcO'#EhO1fQcO'#EgO1sQRO'#ESOOQ`'#Eg'#EgO2[QbO'#EgO2cQQO'#EfOOQ`'#Ef'#EfOOQ`'#EU'#EUQYQbOOO2nQbO'#D[O2yQbO'#DpO3tQbO'#DSO4oQQO'#D|O3tQbO'#EOO4tQbO'#EQO4|ObO,59sO5XQbO'#DbO5aQWO'#DcOOOO'#Eo'#EoOOOO'#EZ'#EZO5uOSO,59zOOQa,59z,59zOOQ`'#DZ'#DZO6TQbO'#DoOOQ`'#Em'#EmOOQ`'#E^'#E^O6_QbO,5:^OOQa'#Eh'#EhO3tQbO,5:cO3tQbO,5:cO3tQbO,5:cO3tQbO,5:cO3tQbO,59pO3tQbO,59pO3tQbO,59pO3tQbO,59pOOQ`'#EW'#EWO+vQbO,59qO7XQcO'#DvO7`QcO'#EiO7gQRO,59qO7qQQO,59qO7vQQO,59qO8OQQO,59qO8ZQRO,59qO8sQRO,59qO8zQQO'#DQO9PQbO,5:fO9WQQO,5:eOOQa,5:f,5:fO9cQbO,5:fO9mQbO,5:oO9mQbO,5:nO:}QbO,5:gO;UQbO,59lOOQ`,5;Q,5;QO9mQbO'#EVOOQ`-E8S-E8SOOQ`'#EX'#EXO;pQbO'#D]O;{QbO'#D^OOQO'#EY'#EYO;sQQO'#D]O<aQQO,59vO<fQcO'#EhO=cQRO'#EvO>`QRO'#EvOOQO'#Ev'#EvO>gQQO,5:[O>lQRO,59nO>sQRO,59nO:}QbO,5:hO?RQcO,5:jO@aQcO,5:jO@}QcO,5:jOArQbO,5:lOOQ`'#Eb'#EbO4tQbO,5:lOOQa1G/_1G/_OOOO,59|,59|OOOO,59},59}OOOO-E8X-E8XOOQa1G/f1G/fOOQ`,5:Z,5:ZOOQ`-E8[-E8[OOQa1G/}1G/}OCkQcO1G/}OCuQcO1G/}OETQcO1G/}OE_QcO1G/}OElQcO1G/}OOQa1G/[1G/[OF}QcO1G/[OGUQcO1G/[OG]QcO1G/[OH[QcO1G/[OGdQcO1G/[OOQ`-E8U-E8UOHrQRO1G/]OH|QQO1G/]OIRQQO1G/]OIZQQO1G/]OIfQRO1G/]OImQRO1G/]OItQbO,59rOJOQQO1G/]OOQa1G/]1G/]OJWQQO1G0POOQa1G0Q1G0QOJcQbO1G0QOOQO'#E`'#E`OJWQQO1G0POOQa1G0P1G0POOQ`'#Ea'#EaOJcQbO1G0QOJmQbO1G0ZOKXQbO1G0YOKsQbO'#DjOLUQbO'#DjOLiQbO1G0ROOQ`-E8T-E8TOOQ`,5:q,5:qOOQ`-E8V-E8VOLtQQO,59wOOQO,59x,59xOOQO-E8W-E8WOL|QbO1G/bO:}QbO1G/vO:}QbO1G/YOMTQbO1G0SOM`QbO1G0WOM}QbO1G0WOOQ`-E8`-E8`ONUQQO7+$wOOQa7+$w7+$wON^QQO1G/^ONfQQO7+%kOOQa7+%k7+%kONqQbO7+%lOOQa7+%l7+%lOOQO-E8^-E8^OOQ`-E8_-E8_OOQ`'#E['#E[ON{QQO'#E[O! TQbO'#EuOOQ`,5:U,5:UO! hQbO'#DhO! mQQO'#DkOOQ`7+%m7+%mO! rQbO7+%mO! wQbO7+%mO!!PQbO7+$|O!!_QbO7+$|O!!oQbO7+%bO!!wQbO7+$tOOQ`7+%n7+%nO!!|QbO7+%nO!#RQbO7+%nO!#ZQbO7+%rOOQa<<Hc<<HcO!#xQbO7+$xO!$VQQO7+$xOOQa<<IV<<IVOOQa<<IW<<IWOOQ`,5:v,5:vOOQ`-E8Y-E8YO!$_QQO,5:SO:}QbO,5:VOOQ`<<IX<<IXO!$dQbO<<IXOOQ`<<Hh<<HhO!$iQbO<<HhO!$nQbO<<HhO!$vQbO<<HhOOQ`'#E_'#E_O!%RQbO<<H|O!%ZQbO'#DuOOQ`<<H|<<H|O!%cQbO<<H|OOQ`<<H`<<H`OOQ`<<IY<<IYO!%hQbO<<IYOOQO,5:w,5:wO!%mQbO<<HdOOQO-E8Z-E8ZO:}QbO1G/nOOQ`1G/q1G/qOOQ`AN>sAN>sOOQ`AN>SAN>SO!%zQbOAN>SO!&PQbOAN>SOOQ`-E8]-E8]OOQ`AN>hAN>hO!&XQbOAN>hO2yQbO,5:_O:}QbO,5:aOOQ`AN>tAN>tPItQbO'#EWOOQ`7+%Y7+%YOOQ`G23nG23nO!&^QbOG23nP!%^QbO'#DsOOQ`G24SG24SO!&cQQO1G/yOOQ`1G/{1G/{OOQ`LD)YLD)YO:}QbO7+%eOOQ`<<IP<<IP",
stateData: "!&k~O#XOSrOS~OlSOm`On[OoPOpROqgOwiO|[O!WRO!X[O!Y[O!ehO!l[O!qjO!skO!ulO#^XO#_dO#bQO#mYO#nZO~O#`mO~O!TpO#bsO#dnO#eoO~OlyOn[OoPOpROqgO|[O!RuO!WRO!X[O!Y[O!btO!l[O#^XO#bQO#mYO#nZOP#[XQ#[XR#[XS#[XT#[XU#[XW#[XX#[XY#[XZ#[X[#[X]#[X^#[Xd#[Xe#[Xf#[Xg#[Xh#[Xi#[Xj#[Xu!jX!Z!jX#l!jX~O#_!jX#p!jX!]!jX!`!jX!a!jX!h!jX~P#QOlyOn[OoPOpROqgO|[O!RuO!WRO!X[O!Y[O!btO!l[O#^XO#bQO#mYO#nZOP#]XQ#]XR#]XS#]XT#]XU#]XW#]XX#]XY#]XZ#]X[#]X]#]X^#]Xd#]Xe#]Xf#]Xg#]Xh#]Xi#]Xj#]Xu#]X#l#]X~O#_#]X#p#]X!Z#]X!]#]X!`#]X!a#]X!h#]X~P&TOP{OQ{OR|OS|OT!POU!QOW!OOX!OOY!OOZ!OO[!OO]!OO^zOd}Oe}Of}Og}Oh}Oi}Oj!RO~OP{OQ{OR|OS|Od}Oe}Of}Og}Oh}Oi}Ou#ZX~O#_#ZX#p#ZX!]#ZX!`#ZX!a#ZX#l#ZX!h#ZX~P*hOl!UOm`On[OoPOpROqgOwiO|[O!WRO!X[O!Y[O!ehO!l[O!qjO!skO!ulO#^XO#_!SO#bQO#mYO#nZO~OlyOn[OoPOpRO|[O!RuO!WRO!X[O!Y[O!l[O#^XO#_!SO#bQO#mYO#nZO~O#o!aO~P-ZOV!cO#_#]X#p#]X!]#]X!`#]X!a#]X!h#]X~P'VOP#[XQ#[XR#[XS#[XT#[XU#[XW#[XX#[XY#[XZ#[X[#[X]#[X^#[Xd#[Xe#[Xf#[Xg#[Xh#[Xi#[Xj#[Xu#ZX~O#_#ZX#p#ZX!]#ZX!`#ZX!a#ZX#l#ZX!h#ZX~P.|Ou#ZX#_#ZX#p#ZX!]#ZX!`#ZX!a#ZX#l#ZX!h#ZX~OT!POU!QOj!RO~P0zOV!cO_!dO`!dOa!dOb!dOc!dOk!dO~O!Z!eO~P0zOu!hO#_!gO#p!gO~Ol!jO!R!lO!Z!PP~Ol!pOn[OoPOpRO|[O!WRO!X[O!Y[O!l[O#^XO#bQO#mYO#nZO~OlyOn[OoPOpRO|[O!WRO!X[O!Y[O!l[O#^XO#bQO#mYO#nZO~O!Z!wO~Ol!jO!RuO~Ol#OO|#OO#^XO~Ol#PO#^XO~O#b#QO#d#QO#e#QO#f#QO#g#QO#h#QO~O!TpO#b#SO#dnO#eoO~OqgO!b#TO~P3tOqgO!RuO!btOu!fa!Z!fa#_!fa#p!fa#l!fa!]!fa!`!fa!a!fa!h!fa~P3tO#_!SO~P#QO#_!SO~P&TO#_!SO#l#lO~P*hO#l#lO~O#l#lOu#ZX~O!Z!eO#l#lOu#ZX~O#l#lO~P.|OT!POU!QOj!RO#_!SOu#ZX~O#l#lO~P8bOu!hO~O#o#nO~P-ZO!RuO#_#pO#o#rO~O#_#sO#o#nO~P3tOlSOm`On[OoPOpROqgOwiO|[O!WRO!X[O!Y[O!ehO!l[O!qjO!skO!ulO#^XO#bQO#mYO#nZO~O#_#xO~P9mOu!hO#_ta#pta#lta!]ta!`ta!ata!hta~Ol!jO!R!lO!Z!PX~OpRO|$OO!WRO!X$OO!Y$OO#bQO~O!Z$QO~OqgO!RuO!btOT#[XU#[XW#[XX#[XY#[XZ#[X[#[X]#[Xj#[X!Z#[X~P3tOT!POU!QOj!RO!Z#jX~OT!POU!QOW!OOX!OOY!OOZ!OO[!OO]!OOj!RO~O!Z#jX~P=qO!Z$RO~O!Z$SO~P=qOT!POU!QOj!RO!Z$SO~Ou!ra#_!ra#p!ra!]!ra!`!ra!a!ra#l!ra!h!ra~P)WOP{OQ{OR|OS|Od}Oe}Of}Og}Oh}Oi}O~Ou!ra#_!ra#p!ra!]!ra!`!ra!a!ra#l!ra!h!ra~P?oOT!POU!QOj!ROu!ra#_!ra#p!ra!]!ra!`!ra!a!ra#l!ra!h!ra~Ol!jO!RuOu!ta#_!ta#p!ta!]!ta!`!ta!a!ta#l!ta!h!ta~O^zOR!kiS!kid!kie!kif!kig!kih!kii!kiu!ki#_!ki#p!ki#l!ki!]!ki!`!ki!a!ki!h!ki~OP!kiQ!ki~PBdOP{OQ{O~PBdOP{OQ{Od!kie!kif!kig!kih!kii!kiu!ki#_!ki#p!ki#l!ki!]!ki!`!ki!a!ki!h!ki~OR!kiS!ki~PDPOR|OS|O^zO~PDPOR|OS|O~PDPOW!OOX!OOY!OOZ!OO[!OO]!OOTxijxiuxi#_xi#pxi#lxi!Zxi!]xi!`xi!axi!hxi~OU!QO~PEvOU!QO~PFYOUxi~PEvOT!POU!QOjxiuxi#_xi#pxi#lxi!Zxi!]xi!`xi!axi!hxi~OW!OOX!OOY!OOZ!OO[!OO]!OO~PGdO#_!SO#l$YO~P*hO#l$YO~O#l$YOu#ZX~O!Z!eO#l$YOu#ZX~O#l$YO~P.|O#l$YO~P8bOqgO!btO~P-ZO#_!SO#l$YO~O!RuO#_#pO#o$]O~O#_#sO#o$_O~P3tOu!hO#_!wi#p!wi!]!wi!`!wi!a!wi#l!wi!h!wi~Ou!hO#_!vi#p!vi!]!vi!`!vi!a!vi#l!vi!h!vi~Ou!hO!]!^X!`!^X!a!^X!h!^X~O#_$bO!]#iP!`#iP!a#iP!h#iP~P9mO!]$fO!`$gO!a$hO~O!R!lO!Z!Pa~O#_$lO~P9mO!]$fO!`$gO!a$oO~O!RuOu!ti#_!ti#p!ti!]!ti!`!ti!a!ti#l!ti!h!ti~Ol!jO~PM`O#_!SO#l$sO~O#_!SO#lzi~O!RuO#_#pO#o$vO~O#_#sO#o$wO~P3tOu!hO#_$xO~O#_$bO!]#iX!`#iX!a#iX!h#iX~P9mOl$zO~O!Z${O~O!a$|O~O!`$gO!a$|O~Ou!hO!]$fO!`$gO!a%OO~O#_$bO!]#iP!`#iP!a#iP~P9mO!a%VO!h%UO~O!a%XO~O!a%YO~O!`$gO!a%YO~O!RuOu!tq#_!tq#p!tq!]!tq!`!tq!a!tq#l!tq!h!tq~OqgO!btO#lzq~P-ZO#_!SO#lzq~O!Z%_O~O!a%aO~O!a%bO~O!`$gO!a%bO~O!]$fO!`$gO!a%bO~O!a%fO!h%UO~O!Z%iO!e%hO~O!a%fO~O!a%jO~OqgO!btO#lzy~P-ZO!a%mO~O!`$gO!a%mO~O!a%pO~O!a%sO~O!Z%tO~O|!l~",
goto: "9Z#lPPPPPPPPPPPPPPPPPPPPPPPPPPPPPPPPPPPP#mP$WP$m%k&y'PP(Z(g)a)dP)jP*q*qPPPP*uP+R+kPPP,R#mP,s-^P-b-h-}P.t/x$W$WP$WP$WP$W$W1O1U1b2U2d2n2t2{3R3]3c3m3wPPP4V4Z5O6tPPP8OP8`PPPPP8d8j8praOf!c!d!e!h!w#x$Q$R$S$d$l${%_%i%tQ!YXR#f!TwaOXf!T!c!d!e!h!w#x$Q$R$S$d$l${%_%i%tr_Of!c!d!e!h!w#x$Q$R$S$d$l${%_%i%tQ!]XS!qh%hQ!viQ!zkQ#]!QQ#_!PQ#b!RR#i!TvTOfh!c!d!e!h!w#x$Q$R$S$d$l${%_%h%i%t!W[STZikuxz{|}!O!P!Q!R!U!V!_!b!p#j#o#t$^$t%]%kS!VX!TQ#OmR#PnQ!XXR#e!TrSOf!c!d!e!h!w#x$Q$R$S$d$l${%_%i%t!WySTZikuxz{|}!O!P!Q!R!U!V!_!b!p#j#o#t$^$t%]%kS!UX!TT!ph%hevSTx!U!V!p#j$t%]%kraOf!c!d!e!h!w#x$Q$R$S$d$l${%_%i%tdtSTx!U!V!p#j$t%]%kQ!YXQ#TuR#f!TR!ogX!mg!k!n#}#S[OSTXZfhikuxz{|}!O!P!Q!R!T!U!V!_!b!c!d!e!h!p!w#j#o#t#x$Q$R$S$^$d$l$t${%]%_%h%i%k%tR$O!lTpQrQ$j#yQ$q$TQ%Q$kR%d%RQ#y!eQ$T!wQ$m$RQ$n$SQ%`${Q%l%_Q%r%iR%u%tQ$i#yQ$p$TQ$}$jQ%P$kQ%Z$qS%c%Q%RR%n%ddvSTx!U!V!p#j$t%]%kQ!`Z[!|l!{!}$U$V$rQ#m!_X#p!`#m#q$[vUOXf!T!c!d!e!h!w#x$Q$R$S$d$l${%_%i%tT!sh%hT%S$m%TQ%W$mR%g%TwUOXf!T!c!d!e!h!w#x$Q$R$S$d$l${%_%i%trWOf!c!d!e!h!w#x$Q$R$S$d$l${%_%i%tQ!WXQ!ykQ#V{Q#Y|Q#[}R#d!T#T[OSTXZfhikuxz{|}!O!P!Q!R!T!U!V!_!b!c!d!e!h!p!w#j#o#t#x$Q$R$S$^$d$l$t${%]%_%h%i%k%t![[STZhikuxz{|}!O!P!Q!R!U!V!_!b!p#j#o#t$^$t%]%h%kw]OXf!T!c!d!e!h!w#x$Q$R$S$d$l${%_%i%tQfOR!if^!fc!^#u#v#w$c$kR#z!fQ!TXQ!_Z`#c!T!_#j#k$X$t%]%kS#j!U!VS#k!W!]S$X#d#iQ$t$ZR%]$uQ!kgQ!{lU#|!k!{$VR$V!}Q!ngQ#}!kT$P!n#}QrQR#RrS$d#x$lR$y$dQ$u$ZR%^$uYxST!U!V!pR#UxQ%T$mR%e%TQ#q!`Q$[#mT$`#q$[Q#t!bQ$^#oT$a#t$^Q!}lQ$U!{U$W!}$U$rR$r$VTeOfScOfS!^X!TQ#u!cQ#v!d`#w!e!w$R$S${%_%i%tQ#{!hU$c#x$d$lR$k$QvVOXf!T!c!d!e!h!w#x$Q$R$S$d$l${%_%i%tdtSTx!U!V!p#j$t%]%kQ!bZS!rh%hQ!uiQ!xkQ#TuQ#VzQ#W{Q#X|Q#Z}Q#]!OQ#^!PQ#`!QQ#a!RQ#o!_X#s!b#o#t$^r^Of!c!d!e!h!w#x$Q$R$S$d$l${%_%i%t![ySTZhikuxz{|}!O!P!Q!R!U!V!_!b!p#j#o#t$^$t%]%h%kQ![XR#h!T[wSTx!U!V!pQ$Z#jV%[$t%]%kTqQrQ$e#xR%R$lQ!thR%q%hrbOf!c!d!e!h!w#x$Q$R$S$d$l${%_%i%tQ!ZXR#g!T",
nodeNames: "⚠ Star Slash Plus Minus And Or Eq EqEq Neq Lt Lte Gt Gte Modulo PlusEq MinusEq StarEq SlashEq ModuloEq Band Bor Bxor Shl Shr Ushr NullishCoalesce NullishEq Identifier AssignableIdentifier Word IdentifierBeforeDot CurlyString Do Comment Program PipeExpr operator WhileExpr keyword ConditionalOp ParenExpr FunctionCall DotGet Number PositionalArg FunctionDef Params NamedParam NamedArgPrefix String StringFragment Interpolation EscapeSeq DoubleQuote Boolean Null colon CatchExpr keyword Block FinallyExpr keyword keyword Underscore NamedArg IfExpr keyword FunctionCall ElseIfExpr keyword ElseExpr FunctionCallOrIdentifier BinOp Regex Dict Array FunctionCallWithBlock TryExpr keyword Throw keyword Import keyword CompoundAssign Assign",
maxTerm: 124,
context: trackScope,
nodeProps: [
["closedBy", 57,"end"]
],
propSources: [highlighting],
skippedNodes: [0,34],
repeatNodeCount: 13,
tokenData: "Lq~R!OOX$RXY$pYZ%ZZp$Rpq$pqr$Rrs%tst'ztu)cuw$Rwx)hxy)myz*Wz{$R{|*q|}$R}!O*q!O!P$R!P!Q4^!Q!R+c!R![.W![!]<y!]!^%Z!^!}$R!}#O=d#O#P?Y#P#Q?_#Q#R$R#R#S?x#S#T$R#T#Y@c#Y#ZA}#Z#b@c#b#cGk#c#f@c#f#gHn#g#h@c#h#iIq#i#o@c#o#p$R#p#qLR#q;'S$R;'S;=`$j<%l~$R~O$R~~LlS$WU!TSOt$Ruw$Rx#O$R#P;'S$R;'S;=`$j<%lO$RS$mP;=`<%l$R^$wU!TS#XYOt$Ruw$Rx#O$R#P;'S$R;'S;=`$j<%lO$RU%bU!TS#_QOt$Ruw$Rx#O$R#P;'S$R;'S;=`$j<%lO$RU%yZ!TSOr%trs&lst%ttu'Vuw%twx'Vx#O%t#O#P'V#P;'S%t;'S;=`'t<%lO%tU&sU!WQ!TSOt$Ruw$Rx#O$R#P;'S$R;'S;=`$j<%lO$RQ'YTOr'Vrs'is;'S'V;'S;=`'n<%lO'VQ'nO!WQQ'qP;=`<%l'VU'wP;=`<%l%t^(RZrY!TSOY'zYZ$RZt'ztu(tuw'zwx(tx#O'z#O#P(t#P;'S'z;'S;=`)]<%lO'zY(ySrYOY(tZ;'S(t;'S;=`)V<%lO(tY)YP;=`<%l(t^)`P;=`<%l'z~)hO#d~~)mO#b~U)tU!TS#^QOt$Ruw$Rx#O$R#P;'S$R;'S;=`$j<%lO$RU*_U!TS#lQOt$Ruw$Rx#O$R#P;'S$R;'S;=`$j<%lO$RU*vX!TSOt$Ruw$Rx!Q$R!Q!R+c!R![.W![#O$R#P;'S$R;'S;=`$j<%lO$RU+jb!TS|QOt$Ruw$Rx!O$R!O!P,r!P!Q$R!Q![.W![#O$R#P#R$R#R#S/T#S#U$R#U#V/r#V#c$R#c#d1W#d#l$R#l#m2f#m;'S$R;'S;=`$j<%lO$RU,wW!TSOt$Ruw$Rx!Q$R!Q![-a![#O$R#P;'S$R;'S;=`$j<%lO$RU-hY!TS|QOt$Ruw$Rx!Q$R!Q![-a![#O$R#P#R$R#R#S,r#S;'S$R;'S;=`$j<%lO$RU._[!TS|QOt$Ruw$Rx!O$R!O!P,r!P!Q$R!Q![.W![#O$R#P#R$R#R#S/T#S;'S$R;'S;=`$j<%lO$RU/YW!TSOt$Ruw$Rx!Q$R!Q![.W![#O$R#P;'S$R;'S;=`$j<%lO$RU/wX!TSOt$Ruw$Rx!Q$R!Q!R0d!R!S0d!S#O$R#P;'S$R;'S;=`$j<%lO$RU0kX!TS|QOt$Ruw$Rx!Q$R!Q!R0d!R!S0d!S#O$R#P;'S$R;'S;=`$j<%lO$RU1]W!TSOt$Ruw$Rx!Q$R!Q!Y1u!Y#O$R#P;'S$R;'S;=`$j<%lO$RU1|W!TS|QOt$Ruw$Rx!Q$R!Q!Y1u!Y#O$R#P;'S$R;'S;=`$j<%lO$RU2k[!TSOt$Ruw$Rx!Q$R!Q![3a![!c$R!c!i3a!i#O$R#P#T$R#T#Z3a#Z;'S$R;'S;=`$j<%lO$RU3h[!TS|QOt$Ruw$Rx!Q$R!Q![3a![!c$R!c!i3a!i#O$R#P#T$R#T#Z3a#Z;'S$R;'S;=`$j<%lO$RU4cW!TSOt$Ruw$Rx!P$R!P!Q4{!Q#O$R#P;'S$R;'S;=`$j<%lO$RU5Q^!TSOY5|YZ$RZt5|tu7Puw5|wx7Px!P5|!P!Q$R!Q!}5|!}#O;r#O#P9_#P;'S5|;'S;=`<s<%lO5|U6T^!TS!lQOY5|YZ$RZt5|tu7Puw5|wx7Px!P5|!P!Q9t!Q!}5|!}#O;r#O#P9_#P;'S5|;'S;=`<s<%lO5|Q7UX!lQOY7PZ!P7P!P!Q7q!Q!}7P!}#O8`#O#P9_#P;'S7P;'S;=`9n<%lO7PQ7tP!P!Q7wQ7|U!lQ#Z#[7w#]#^7w#a#b7w#g#h7w#i#j7w#m#n7wQ8cVOY8`Z#O8`#O#P8x#P#Q7P#Q;'S8`;'S;=`9X<%lO8`Q8{SOY8`Z;'S8`;'S;=`9X<%lO8`Q9[P;=`<%l8`Q9bSOY7PZ;'S7P;'S;=`9n<%lO7PQ9qP;=`<%l7PU9yW!TSOt$Ruw$Rx!P$R!P!Q:c!Q#O$R#P;'S$R;'S;=`$j<%lO$RU:jb!TS!lQOt$Ruw$Rx#O$R#P#Z$R#Z#[:c#[#]$R#]#^:c#^#a$R#a#b:c#b#g$R#g#h:c#h#i$R#i#j:c#j#m$R#m#n:c#n;'S$R;'S;=`$j<%lO$RU;w[!TSOY;rYZ$RZt;rtu8`uw;rwx8`x#O;r#O#P8x#P#Q5|#Q;'S;r;'S;=`<m<%lO;rU<pP;=`<%l;rU<vP;=`<%l5|U=QU!TS!ZQOt$Ruw$Rx#O$R#P;'S$R;'S;=`$j<%lO$RU=kW#nQ!TSOt$Ruw$Rx!_$R!_!`>T!`#O$R#P;'S$R;'S;=`$j<%lO$RU>YV!TSOt$Ruw$Rx#O$R#P#Q>o#Q;'S$R;'S;=`$j<%lO$RU>vU#mQ!TSOt$Ruw$Rx#O$R#P;'S$R;'S;=`$j<%lO$R~?_O#e~U?fU#oQ!TSOt$Ruw$Rx#O$R#P;'S$R;'S;=`$j<%lO$RU@PU!TS!bQOt$Ruw$Rx#O$R#P;'S$R;'S;=`$j<%lO$RU@h^!TSOt$Ruw$Rx}$R}!O@c!O!Q$R!Q![@c![!_$R!_!`Ad!`#O$R#P#T$R#T#o@c#o;'S$R;'S;=`$j<%lO$RUAkU!RQ!TSOt$Ruw$Rx#O$R#P;'S$R;'S;=`$j<%lO$RUBS_!TSOt$Ruw$Rx}$R}!O@c!O!Q$R!Q![@c![!_$R!_!`Ad!`#O$R#P#T$R#T#UCR#U#o@c#o;'S$R;'S;=`$j<%lO$RUCW`!TSOt$Ruw$Rx}$R}!O@c!O!Q$R!Q![@c![!_$R!_!`Ad!`#O$R#P#T$R#T#`@c#`#aDY#a#o@c#o;'S$R;'S;=`$j<%lO$RUD_`!TSOt$Ruw$Rx}$R}!O@c!O!Q$R!Q![@c![!_$R!_!`Ad!`#O$R#P#T$R#T#g@c#g#hEa#h#o@c#o;'S$R;'S;=`$j<%lO$RUEf`!TSOt$Ruw$Rx}$R}!O@c!O!Q$R!Q![@c![!_$R!_!`Ad!`#O$R#P#T$R#T#X@c#X#YFh#Y#o@c#o;'S$R;'S;=`$j<%lO$RUFo^!XQ!TSOt$Ruw$Rx}$R}!O@c!O!Q$R!Q![@c![!_$R!_!`Ad!`#O$R#P#T$R#T#o@c#o;'S$R;'S;=`$j<%lO$R^Gr^#fW!TSOt$Ruw$Rx}$R}!O@c!O!Q$R!Q![@c![!_$R!_!`Ad!`#O$R#P#T$R#T#o@c#o;'S$R;'S;=`$j<%lO$R^Hu^#hW!TSOt$Ruw$Rx}$R}!O@c!O!Q$R!Q![@c![!_$R!_!`Ad!`#O$R#P#T$R#T#o@c#o;'S$R;'S;=`$j<%lO$R^Ix`#gW!TSOt$Ruw$Rx}$R}!O@c!O!Q$R!Q![@c![!_$R!_!`Ad!`#O$R#P#T$R#T#f@c#f#gJz#g#o@c#o;'S$R;'S;=`$j<%lO$RUKP`!TSOt$Ruw$Rx}$R}!O@c!O!Q$R!Q![@c![!_$R!_!`Ad!`#O$R#P#T$R#T#i@c#i#jEa#j#o@c#o;'S$R;'S;=`$j<%lO$RULYUuQ!TSOt$Ruw$Rx#O$R#P;'S$R;'S;=`$j<%lO$R~LqO#p~",
tokenizers: [operatorTokenizer, 1, 2, 3, tokenizer, new LocalTokenGroup("[~RP!O!PU~ZO#`~~", 11)],
topRules: {"Program":[0,35]},
specialized: [{term: 28, get: (value: any, stack: any) => (specializeKeyword(value, stack) << 1), external: specializeKeyword},{term: 28, get: (value: keyof typeof spec_Identifier) => spec_Identifier[value] || -1}],
tokenPrec: 2370
})

View File

@ -1,275 +0,0 @@
import { SyntaxNode } from './node'
/**
* 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 => {
const stringNode = new SyntaxNode('String', from, to)
const content = input.slice(from, to)
// Determine string type
const firstChar = content[0]
// Double-quoted strings: no interpolation or escapes
if (firstChar === '"') {
const fragment = new SyntaxNode('DoubleQuote', from, to)
stringNode.add(fragment)
return stringNode
}
// Curly strings: interpolation but no escapes
if (firstChar === '{') {
parseCurlyString(stringNode, input, from, to, parser)
return stringNode
}
// Single-quoted strings: interpolation and escapes
if (firstChar === "'") {
parseSingleQuoteString(stringNode, input, from, to, parser)
return stringNode
}
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,
) => {
let pos = from + 1 // Skip opening '
let fragmentStart = pos
while (pos < to - 1) {
// -1 to skip closing '
const char = input[pos]
// Escape sequence
if (char === '\\' && pos + 1 < to - 1) {
// Push accumulated fragment
if (pos > fragmentStart) {
const frag = new SyntaxNode('StringFragment', fragmentStart, pos)
stringNode.add(frag)
}
// Add escape sequence node
const escNode = new SyntaxNode('EscapeSeq', pos, pos + 2)
stringNode.add(escNode)
pos += 2
fragmentStart = pos
continue
}
// Interpolation
if (char === '$') {
// Push accumulated fragment
if (pos > fragmentStart) {
const frag = new SyntaxNode('StringFragment', fragmentStart, pos)
stringNode.add(frag)
}
pos++ // Skip $
// Parse interpolation content
if (input[pos] === '(') {
// Expression interpolation: $(expr)
const interpStart = pos - 1 // Include the $
const exprResult = parseInterpolationExpr(input, pos, parser)
const interpNode = new SyntaxNode('Interpolation', interpStart, exprResult.endPos)
interpNode.add(exprResult.node)
stringNode.add(interpNode)
pos = exprResult.endPos
} else {
// Variable interpolation: $name
const interpStart = pos - 1
const identEnd = findIdentifierEnd(input, pos, to - 1)
const identNode = new SyntaxNode('FunctionCallOrIdentifier', pos, identEnd)
const innerIdent = new SyntaxNode('Identifier', pos, identEnd)
identNode.add(innerIdent)
const interpNode = new SyntaxNode('Interpolation', interpStart, identEnd)
interpNode.add(identNode)
stringNode.add(interpNode)
pos = identEnd
}
fragmentStart = pos
continue
}
pos++
}
// Push final fragment
if (pos > fragmentStart && fragmentStart < to - 1) {
const frag = new SyntaxNode('StringFragment', fragmentStart, pos)
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,
) => {
let pos = from + 1 // Skip opening {
let fragmentStart = from // Include the opening { in the fragment
let depth = 1
while (pos < to && depth > 0) {
const char = input[pos]
// Track brace nesting
if (char === '{') {
depth++
pos++
continue
}
if (char === '}') {
depth--
if (depth === 0) {
// Push final fragment including closing }
const frag = new SyntaxNode('CurlyString', fragmentStart, pos + 1)
stringNode.add(frag)
break
}
pos++
continue
}
// Interpolation
if (char === '$') {
// Push accumulated fragment
if (pos > fragmentStart) {
const frag = new SyntaxNode('CurlyString', fragmentStart, pos)
stringNode.add(frag)
}
pos++ // Skip $
// Parse interpolation content
if (input[pos] === '(') {
// Expression interpolation: $(expr)
const interpStart = pos - 1
const exprResult = parseInterpolationExpr(input, pos, parser)
const interpNode = new SyntaxNode('Interpolation', interpStart, exprResult.endPos)
interpNode.add(exprResult.node)
stringNode.add(interpNode)
pos = exprResult.endPos
} else {
// Variable interpolation: $name
const interpStart = pos - 1
const identEnd = findIdentifierEnd(input, pos, to)
const identNode = new SyntaxNode('FunctionCallOrIdentifier', pos, identEnd)
const innerIdent = new SyntaxNode('Identifier', pos, identEnd)
identNode.add(innerIdent)
const interpNode = new SyntaxNode('Interpolation', interpStart, identEnd)
interpNode.add(identNode)
stringNode.add(interpNode)
pos = identEnd
}
fragmentStart = pos
continue
}
pos++
}
}
/**
* 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 } => {
// Find matching closing paren
let depth = 1
let start = pos
let end = pos + 1 // Start after opening (
while (end < input.length && depth > 0) {
if (input[end] === '(') depth++
if (input[end] === ')') {
depth--
if (depth === 0) break
}
end++
}
const exprContent = input.slice(start + 1, end) // Content between ( and )
const closeParen = end
end++ // Move past closing )
// Use the main parser to parse the expression
const exprNode = parser.parse(exprContent)
// Get the first real node (skip Program wrapper)
const innerNode = exprNode.firstChild || exprNode
// 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)
// Wrap in ParenExpr - use positions in the full string
const parenNode = new SyntaxNode('ParenExpr', start, closeParen + 1)
parenNode.add(innerNode)
return { node: parenNode, endPos: end }
}
/**
* Recursively adjust all node positions by adding an offset
*/
const adjustNodePositions = (node: SyntaxNode, offset: number) => {
node.from += offset
node.to += offset
for (const child of node.children) {
adjustNodePositions(child, offset)
}
}
/**
* 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 => {
let end = pos
while (end < maxPos) {
const char = input[end]!
// Stop at non-identifier characters
if (!/[a-z0-9\-?]/.test(char)) {
break
}
end++
}
return end
}

View File

@ -1,5 +1,7 @@
import { expect, describe, test } from 'bun:test'
import '../shrimp.grammar' // Importing this so changes cause it to retest!
describe('null', () => {
test('parses null', () => {
expect('null').toMatchTree(`Null null`)
@ -438,26 +440,6 @@ describe('Parentheses', () => {
PositionalArg
Identifier arg3`)
})
test('function call with mulitline identifiers starting separate lines in parens', () => {
expect(`(
echo
arg1
arg2
arg3
)`).toMatchTree(`
ParenExpr
FunctionCall
Identifier echo
PositionalArg
Identifier arg1
PositionalArg
Identifier arg2
PositionalArg
Identifier arg3`)
})
})
describe('Number literals', () => {
@ -808,6 +790,44 @@ describe('Nullish coalescing operator', () => {
})
})
describe('DotGet whitespace sensitivity', () => {
test('no whitespace - DotGet works when identifier in scope', () => {
expect('basename = 5; basename.prop').toMatchTree(`
Assign
AssignableIdentifier basename
Eq =
Number 5
FunctionCallOrIdentifier
DotGet
IdentifierBeforeDot basename
Identifier prop`)
})
test('space before dot - NOT DotGet, parses as division', () => {
expect('basename = 5; basename / prop').toMatchTree(`
Assign
AssignableIdentifier basename
Eq =
Number 5
BinOp
Identifier basename
Slash /
Identifier prop`)
})
test('dot followed by slash is Word, not DotGet', () => {
expect('basename ./cool').toMatchTree(`
FunctionCall
Identifier basename
PositionalArg
Word ./cool`)
})
test('identifier not in scope with dot becomes Word', () => {
expect('readme.txt').toMatchTree(`Word readme.txt`)
})
})
describe('Comments', () => {
test('are greedy', () => {
expect(`
@ -857,6 +877,61 @@ basename = 5 # very astute
})
})
describe('Array destructuring', () => {
test('parses array pattern with two variables', () => {
expect('[ a b ] = [ 1 2 3 4]').toMatchTree(`
Assign
Array
Identifier a
Identifier b
Eq =
Array
Number 1
Number 2
Number 3
Number 4`)
})
test('parses array pattern with one variable', () => {
expect('[ x ] = [ 42 ]').toMatchTree(`
Assign
Array
Identifier x
Eq =
Array
Number 42`)
})
test('parses array pattern with emoji identifiers', () => {
expect('[ 🚀 💎 ] = [ 1 2 ]').toMatchTree(`
Assign
Array
Identifier 🚀
Identifier 💎
Eq =
Array
Number 1
Number 2`)
})
test('works with dotget', () => {
expect('[ a ] = [ [1 2 3] ]; a.1').toMatchTree(`
Assign
Array
Identifier a
Eq =
Array
Array
Number 1
Number 2
Number 3
FunctionCallOrIdentifier
DotGet
IdentifierBeforeDot a
Number 1`)
})
})
describe('Conditional ops', () => {
test('or can be chained', () => {
expect(`
@ -942,3 +1017,34 @@ Assign
`)
})
})
describe('import', () => {
test('parses single import', () => {
expect(`import str`).toMatchTree(`
Import
keyword import
Identifier str
`)
})
test('parses multiple imports', () => {
expect(`import str math list`).toMatchTree(`
Import
keyword import
Identifier str
Identifier math
Identifier list
`)
})
test('parses named args', () => {
expect(`import str only=ends-with?`).toMatchTree(`
Import
keyword import
Identifier str
NamedArg
NamedArgPrefix only=
Identifier ends-with?
`)
})
})

View File

@ -1,5 +1,7 @@
import { expect, describe, test } from 'bun:test'
import '../shrimp.grammar' // Importing this so changes cause it to retest!
describe('bitwise operators - grammar', () => {
test('parses band (bitwise AND)', () => {
expect('5 band 3').toMatchTree(`

View File

@ -1,5 +1,7 @@
import { expect, describe, test } from 'bun:test'
import '../shrimp.grammar' // Importing this so changes cause it to retest!
describe('if/else if/else', () => {
test('parses single line if', () => {
expect(`if y == 1: 'cool' end`).toMatchTree(`
@ -22,8 +24,7 @@ describe('if/else if/else', () => {
Eq =
IfExpr
keyword if
FunctionCallOrIdentifier
Identifier x
Identifier x
colon :
Block
Number 2
@ -58,8 +59,7 @@ describe('if/else if/else', () => {
end`).toMatchTree(`
IfExpr
keyword if
FunctionCallOrIdentifier
Identifier with-else
Identifier with-else
colon :
Block
FunctionCallOrIdentifier
@ -82,8 +82,7 @@ describe('if/else if/else', () => {
end`).toMatchTree(`
IfExpr
keyword if
FunctionCallOrIdentifier
Identifier with-else-if
Identifier with-else-if
colon :
Block
FunctionCallOrIdentifier
@ -91,8 +90,7 @@ describe('if/else if/else', () => {
ElseIfExpr
keyword else
keyword if
FunctionCallOrIdentifier
Identifier another-condition
Identifier another-condition
colon :
Block
FunctionCallOrIdentifier
@ -113,8 +111,7 @@ describe('if/else if/else', () => {
end`).toMatchTree(`
IfExpr
keyword if
FunctionCallOrIdentifier
Identifier with-else-if-else
Identifier with-else-if-else
colon :
Block
FunctionCallOrIdentifier
@ -122,8 +119,7 @@ describe('if/else if/else', () => {
ElseIfExpr
keyword else
keyword if
FunctionCallOrIdentifier
Identifier another-condition
Identifier another-condition
colon :
Block
FunctionCallOrIdentifier
@ -131,8 +127,7 @@ describe('if/else if/else', () => {
ElseIfExpr
keyword else
keyword if
FunctionCallOrIdentifier
Identifier yet-another-condition
Identifier yet-another-condition
colon :
Block
FunctionCallOrIdentifier
@ -178,7 +173,7 @@ describe('if/else if/else', () => {
`)
})
test("parses paren'd function calls in if tests", () => {
test('parses function calls in if tests', () => {
expect(`if (var? 'abc'): true end`).toMatchTree(`
IfExpr
keyword if
@ -195,6 +190,7 @@ describe('if/else if/else', () => {
`)
})
test('parses function calls in else-if tests', () => {
expect(`if false: true else if var? 'abc': true end`).toMatchTree(`
IfExpr
@ -218,7 +214,7 @@ describe('if/else if/else', () => {
`)
})
test("parses paren'd function calls in else-if tests", () => {
test('parses function calls in else-if tests', () => {
expect(`if false: true else if (var? 'abc'): true end`).toMatchTree(`
IfExpr
keyword if
@ -285,6 +281,7 @@ describe('while', () => {
keyword end`)
})
test('compound expression', () => {
expect(`while a > 0 and b < 100 and c < 1000: true end`).toMatchTree(`
WhileExpr
@ -342,6 +339,7 @@ describe('while', () => {
keyword end`)
})
test('multiline compound expression', () => {
expect(`
while a > 0 and b < 100 and c < 1000:

View File

@ -1,56 +0,0 @@
import { expect, describe, test } from 'bun:test'
describe('Array destructuring', () => {
test('parses array pattern with two variables', () => {
expect('[ a b ] = [ 1 2 3 4]').toMatchTree(`
Assign
Array
Identifier a
Identifier b
Eq =
Array
Number 1
Number 2
Number 3
Number 4`)
})
test('parses array pattern with one variable', () => {
expect('[ x ] = [ 42 ]').toMatchTree(`
Assign
Array
Identifier x
Eq =
Array
Number 42`)
})
test('parses array pattern with emoji identifiers', () => {
expect('[ 🚀 💎 ] = [ 1 2 ]').toMatchTree(`
Assign
Array
Identifier 🚀
Identifier 💎
Eq =
Array
Number 1
Number 2`)
})
test('works with dotget', () => {
expect('[ a ] = [ [1 2 3] ]; a.1').toMatchTree(`
Assign
Array
Identifier a
Eq =
Array
Array
Number 1
Number 2
Number 3
FunctionCallOrIdentifier
DotGet
IdentifierBeforeDot a
Number 1`)
})
})

View File

@ -1,44 +1,6 @@
import { describe, test, expect } from 'bun:test'
import '../../testSetup'
describe('DotGet whitespace sensitivity', () => {
test('no whitespace - DotGet works when identifier in scope', () => {
expect('basename = 5; basename.prop').toMatchTree(`
Assign
AssignableIdentifier basename
Eq =
Number 5
FunctionCallOrIdentifier
DotGet
IdentifierBeforeDot basename
Identifier prop`)
})
test('space before dot - NOT DotGet, parses as division', () => {
expect('basename = 5; basename / prop').toMatchTree(`
Assign
AssignableIdentifier basename
Eq =
Number 5
BinOp
Identifier basename
Slash /
Identifier prop`)
})
test('dot followed by slash is Word, not DotGet', () => {
expect('basename ./cool').toMatchTree(`
FunctionCall
Identifier basename
PositionalArg
Word ./cool`)
})
test('identifier not in scope with dot becomes Word', () => {
expect('readme.txt').toMatchTree(`Word readme.txt`)
})
})
describe('DotGet', () => {
test('readme.txt is Word when readme not in scope', () => {
expect('readme.txt').toMatchTree(`Word readme.txt`)
@ -237,7 +199,7 @@ end`).toMatchTree(`
`)
})
test.skip("dot get doesn't work with spaces", () => {
test("dot get doesn't work with spaces", () => {
expect('obj . prop').toMatchTree(`
FunctionCall
Identifier obj
@ -336,163 +298,4 @@ end`).toMatchTree(`
Number 2
`)
})
// NOTE: these are parsed as DotGet(meta, DotGet(script, name)) because that's easiest,
// but the compiler flattens them
test('chained dot get: meta.script.name', () => {
expect('meta = 42; meta.script.name').toMatchTree(`
Assign
AssignableIdentifier meta
Eq =
Number 42
FunctionCallOrIdentifier
DotGet
IdentifierBeforeDot meta
DotGet
IdentifierBeforeDot script
Identifier name
`)
})
test('chained dot get: a.b.c.d', () => {
expect('a = 1; a.b.c.d').toMatchTree(`
Assign
AssignableIdentifier a
Eq =
Number 1
FunctionCallOrIdentifier
DotGet
IdentifierBeforeDot a
DotGet
IdentifierBeforeDot b
DotGet
IdentifierBeforeDot c
Identifier d
`)
})
test('chained dot get in function call', () => {
expect('config = 1; echo config.db.host').toMatchTree(`
Assign
AssignableIdentifier config
Eq =
Number 1
FunctionCall
Identifier echo
PositionalArg
DotGet
IdentifierBeforeDot config
DotGet
IdentifierBeforeDot db
Identifier host
`)
})
test('chained dot get with numeric index at end', () => {
expect('obj = 1; obj.items.0').toMatchTree(`
Assign
AssignableIdentifier obj
Eq =
Number 1
FunctionCallOrIdentifier
DotGet
IdentifierBeforeDot obj
DotGet
IdentifierBeforeDot items
Number 0
`)
})
test('chained dot get with ParenExpr at end', () => {
expect('obj = 1; obj.items.(i)').toMatchTree(`
Assign
AssignableIdentifier obj
Eq =
Number 1
FunctionCallOrIdentifier
DotGet
IdentifierBeforeDot obj
DotGet
IdentifierBeforeDot items
ParenExpr
FunctionCallOrIdentifier
Identifier i
`)
})
test('not in scope remains Word with chained dots', () => {
expect('readme.md.bak').toMatchTree(`Word readme.md.bak`)
})
test('chained dot get in nested functions', () => {
expect(`do cfg:
do inner:
cfg.db.host
end
end`).toMatchTree(`
FunctionDef
Do do
Params
Identifier cfg
colon :
FunctionDef
Do do
Params
Identifier inner
colon :
FunctionCallOrIdentifier
DotGet
IdentifierBeforeDot cfg
DotGet
IdentifierBeforeDot db
Identifier host
keyword end
keyword end
`)
})
test('mixed simple and chained dot get', () => {
expect('obj = 1; obj.a; obj.b.c').toMatchTree(`
Assign
AssignableIdentifier obj
Eq =
Number 1
FunctionCallOrIdentifier
DotGet
IdentifierBeforeDot obj
Identifier a
FunctionCallOrIdentifier
DotGet
IdentifierBeforeDot obj
DotGet
IdentifierBeforeDot b
Identifier c
`)
})
test.skip('chained numeric dot get: row.2.1.b', () => {
expect('row = []; row.2.1').toMatchTree(`
Assign
AssignableIdentifier row
Eq =
Array []
FunctionCallOrIdentifier
DotGet
IdentifierBeforeDot row
DotGet
Number 2
DotGet
Number 1
Identifier b
`)
test('parses $.pid just fine', () => {
expect(`$.pid`).toMatchTree(`
FunctionCallOrIdentifier
DotGet
Dollar $
Identifier pid
`)
})
})
})

View File

@ -1,5 +1,7 @@
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:
@ -137,24 +139,11 @@ 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', () => {
expect('throw error-object').toMatchTree(`
Throw
keyword throw
FunctionCallOrIdentifier
Identifier error-object
Identifier error-object
`)
})

View File

@ -1,5 +1,7 @@
import { expect, describe, test } from 'bun:test'
import '../shrimp.grammar' // Importing this so changes cause it to retest!
describe('single line function blocks', () => {
test('work with no args', () => {
expect(`trap: echo bye bye end`).toMatchTree(`
@ -14,7 +16,8 @@ describe('single line function blocks', () => {
Identifier bye
PositionalArg
Identifier bye
keyword end`)
keyword end`
)
})
test('work with one arg', () => {
@ -32,7 +35,8 @@ describe('single line function blocks', () => {
Identifier bye
PositionalArg
Identifier bye
keyword end`)
keyword end`
)
})
test('work with named args', () => {
@ -52,9 +56,11 @@ describe('single line function blocks', () => {
Identifier bye
PositionalArg
Identifier bye
keyword end`)
keyword end`
)
})
test('work with dot-get', () => {
expect(`signals = [=]; signals.trap 'EXIT': echo bye bye end`).toMatchTree(`
Assign
@ -77,7 +83,8 @@ describe('single line function blocks', () => {
Identifier bye
PositionalArg
Identifier bye
keyword end`)
keyword end`
)
})
})
@ -99,7 +106,8 @@ end
Identifier bye
PositionalArg
Identifier bye
keyword end`)
keyword end`
)
})
test('work with one arg', () => {
@ -120,7 +128,8 @@ end`).toMatchTree(`
Identifier bye
PositionalArg
Identifier bye
keyword end`)
keyword end`
)
})
test('work with named args', () => {
@ -146,9 +155,11 @@ end`).toMatchTree(`
Identifier bye
PositionalArg
Identifier bye
keyword end`)
keyword end`
)
})
test('work with dot-get', () => {
expect(`
signals = [=]
@ -175,7 +186,8 @@ end`).toMatchTree(`
Identifier bye
PositionalArg
Identifier bye
keyword end`)
keyword end`
)
})
})
@ -252,7 +264,8 @@ end`).toMatchTree(`
p:
h1 class=bright style='font-family: helvetica' Heya
h2 man that is (b wild)!
end`).toMatchTree(`
end`)
.toMatchTree(`
FunctionCallWithBlock
FunctionCallOrIdentifier
Identifier p

View File

@ -1,5 +1,7 @@
import { expect, describe, test } from 'bun:test'
import '../shrimp.grammar' // Importing this so changes cause it to retest!
describe('calling functions', () => {
test('call with no args', () => {
expect('tail').toMatchTree(`
@ -41,57 +43,6 @@ 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', () => {
expect('tail tail').toMatchTree(`
FunctionCall
@ -113,7 +64,7 @@ describe('calling functions', () => {
NamedArg
NamedArgPrefix lines=
`)
`)
})
})

View File

@ -1,32 +0,0 @@
import { expect, describe, test } from 'bun:test'
describe('import', () => {
test('parses single import', () => {
expect(`import str`).toMatchTree(`
Import
keyword import
Identifier str
`)
})
test('parses multiple imports', () => {
expect(`import str math list`).toMatchTree(`
Import
keyword import
Identifier str
Identifier math
Identifier list
`)
})
test('parses named args', () => {
expect(`import str only=ends-with?`).toMatchTree(`
Import
keyword import
Identifier str
NamedArg
NamedArgPrefix only=
Identifier ends-with?
`)
})
})

View File

@ -1,5 +1,7 @@
import { expect, describe, test } from 'bun:test'
import '../shrimp.grammar' // Importing this so changes cause it to retest!
describe('number literals', () => {
test('binary numbers', () => {
expect('0b110').toMatchTree(`
@ -334,22 +336,6 @@ 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', () => {
expect('[a=one b=[two [c=three]]]').toMatchTree(`
Dict
@ -385,35 +371,14 @@ describe('dict literals', () => {
Number 3
`)
})
test('can have spaces between equals', () => {
expect(`[
a = 1
b = 2
c = 3
]`).toMatchTree(`
Dict
NamedArg
NamedArgPrefix a =
Number 1
NamedArg
NamedArgPrefix b =
Number 2
NamedArg
NamedArgPrefix c =
Number 3
`)
})
test('empty dict', () => {
expect('[=]').toMatchTree(`
Dict [=]
`)
})
test('empty dict w whitespace', () => {
expect('[ = ]').toMatchTree(`
Dict [ = ]
Array
Word =
`)
})

View File

@ -1,5 +1,7 @@
import { expect, describe, test } from 'bun:test'
import '../shrimp.grammar' // Importing this so changes cause it to retest!
describe('multiline', () => {
test('parses multiline strings', () => {
expect(`'first'\n'second'`).toMatchTree(`

View File

@ -1,5 +1,7 @@
import { expect, describe, test } from 'bun:test'
import '../shrimp.grammar' // Importing this so changes cause it to retest!
describe('pipe expressions', () => {
test('simple pipe expression', () => {
expect('echo hello | grep h').toMatchTree(`
@ -173,235 +175,4 @@ describe('pipe expressions', () => {
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', () => {
test('pipe on next line', () => {
expect(`hello
| echo`).toMatchTree(`
PipeExpr
FunctionCallOrIdentifier
Identifier hello
operator |
FunctionCallOrIdentifier
Identifier echo
`)
expect(`echo hello
| grep h`).toMatchTree(`
PipeExpr
FunctionCall
Identifier echo
PositionalArg
Identifier hello
operator |
FunctionCall
Identifier grep
PositionalArg
Identifier h
`)
})
test('pipe on next non-empty line', () => {
expect(`hello
| echo`).toMatchTree(`
PipeExpr
FunctionCallOrIdentifier
Identifier hello
operator |
FunctionCallOrIdentifier
Identifier echo
`)
})
test('multi-line pipe chain', () => {
expect(`echo hello
| grep h
| sort`).toMatchTree(`
PipeExpr
FunctionCall
Identifier echo
PositionalArg
Identifier hello
operator |
FunctionCall
Identifier grep
PositionalArg
Identifier h
operator |
FunctionCallOrIdentifier
Identifier sort
`)
})
test('pipe with indentation', () => {
expect(`echo hello
| grep h
| sort`).toMatchTree(`
PipeExpr
FunctionCall
Identifier echo
PositionalArg
Identifier hello
operator |
FunctionCall
Identifier grep
PositionalArg
Identifier h
operator |
FunctionCallOrIdentifier
Identifier sort
`)
})
test('pipe after operand on next line (trailing pipe style)', () => {
expect(`echo hello |
grep h`).toMatchTree(`
PipeExpr
FunctionCall
Identifier echo
PositionalArg
Identifier hello
operator |
FunctionCall
Identifier grep
PositionalArg
Identifier h
`)
})
test('same-line pipes still work', () => {
expect('echo hello | grep h | sort').toMatchTree(`
PipeExpr
FunctionCall
Identifier echo
PositionalArg
Identifier hello
operator |
FunctionCall
Identifier grep
PositionalArg
Identifier h
operator |
FunctionCallOrIdentifier
Identifier sort
`)
})
test('lots of pipes', () => {
expect(`
'this should help readability in long chains'
| split ' '
| map (ref str.to-upper)
| join '-'
| echo
`).toMatchTree(`
PipeExpr
String
StringFragment this should help readability in long chains
operator |
FunctionCall
Identifier split
PositionalArg
String
StringFragment (space)
operator |
FunctionCall
Identifier map
PositionalArg
ParenExpr
FunctionCall
Identifier ref
PositionalArg
DotGet
IdentifierBeforeDot str
Identifier to-upper
operator |
FunctionCall
Identifier join
PositionalArg
String
StringFragment -
operator |
FunctionCallOrIdentifier
Identifier echo
`)
})
})
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

@ -1,13 +1,14 @@
import { expect, describe, test } from 'bun:test'
import '../shrimp.grammar' // Importing this so changes cause it to retest!
describe('string interpolation', () => {
test('string with variable interpolation', () => {
expect("'hello $name'").toMatchTree(`
String
StringFragment ${'hello '}
Interpolation
FunctionCallOrIdentifier
Identifier name
Identifier name
`)
})
@ -43,8 +44,7 @@ describe('string interpolation', () => {
String
StringFragment x/
Interpolation
FunctionCallOrIdentifier
Identifier y
Identifier y
StringFragment /z
`)
})
@ -122,8 +122,7 @@ describe('string escape sequences', () => {
String
StringFragment value:
Interpolation
FunctionCallOrIdentifier
Identifier x
Identifier x
EscapeSeq \\n
`)
})
@ -161,7 +160,7 @@ describe('curly strings', () => {
})
describe('double quoted strings', () => {
test('work', () => {
test("work", () => {
expect(`"hello world"`).toMatchTree(`
String
DoubleQuote "hello world"`)

View File

@ -1,745 +0,0 @@
import { expect, describe, test } from 'bun:test'
describe('constant types', () => {
test('null', () => {
expect(`null`).toBeToken('Null')
})
test('boolean', () => {
expect(`true`).toMatchToken('Boolean', 'true')
expect(`false`).toMatchToken('Boolean', 'false')
})
})
describe('numbers', () => {
test('non-numbers', () => {
expect(`1st`).toMatchToken('Word', '1st')
expect(`1_`).toMatchToken('Word', '1_')
expect(`100.`).toMatchTokens({ type: 'Number', value: '100' }, { type: 'Operator', value: '.' })
})
test('simple numbers', () => {
expect(`1`).toMatchToken('Number', '1')
expect(`200`).toMatchToken('Number', '200')
expect(`5.20`).toMatchToken('Number', '5.20')
expect(`0.20`).toMatchToken('Number', '0.20')
expect(`-20`).toMatchToken('Number', '-20')
expect(`+20`).toMatchToken('Number', '+20')
expect(`-2134.34`).toMatchToken('Number', '-2134.34')
expect(`+20.5325`).toMatchToken('Number', '+20.5325')
expect(`1_000`).toMatchToken('Number', '1_000')
expect(`53_232_220`).toMatchToken('Number', '53_232_220')
})
test('binary numbers', () => {
expect('0b110').toMatchToken('Number', '0b110')
})
test('hex numbers', () => {
expect('0xdeadbeef').toMatchToken('Number', '0xdeadbeef')
expect('0x02d3f4').toMatchToken('Number', '0x02d3f4')
})
test('hex numbers uppercase', () => {
expect('0xFF').toMatchToken('Number', '0xFF')
})
test('octal numbers', () => {
expect('0o644').toMatchToken('Number', '0o644')
expect('0o055').toMatchToken('Number', '0o055')
})
test('negative binary', () => {
expect('-0b110').toMatchToken('Number', '-0b110')
})
test('negative hex', () => {
expect('-0xFF').toMatchToken('Number', '-0xFF')
})
test('negative octal', () => {
expect('-0o755').toMatchToken('Number', '-0o755')
})
test('positive prefix binary', () => {
expect('+0b110').toMatchToken('Number', '+0b110')
})
test('positive prefix hex', () => {
expect('+0xFF').toMatchToken('Number', '+0xFF')
})
test('positive prefix octal', () => {
expect('+0o644').toMatchToken('Number', '+0o644')
})
test('underscores in number', () => {
expect(`1_000`).toMatchToken('Number', '1_000')
expect(`1_0`).toMatchToken('Number', '1_0')
expect('0b11_0').toMatchToken('Number', '0b11_0')
expect('0xdead_beef').toMatchToken('Number', '0xdead_beef')
expect('0o64_4').toMatchToken('Number', '0o64_4')
})
})
describe('identifiers', () => {
test('regular', () => {
expect('name').toBeToken('Identifier')
expect('bobby-mcgee').toBeToken('Identifier')
expect('starts-with?').toBeToken('Identifier')
expect('📢').toMatchToken('Identifier', '📢')
expect(' 📢 ').toMatchToken('Identifier', '📢')
expect(' oink-🐷-oink').toMatchToken('Identifier', 'oink-🐷-oink')
expect('$').toMatchToken('Identifier', '$')
expect('$cool').toMatchToken('Identifier', '$cool')
})
test('one character identifiers', () => {
expect('a').toMatchToken('Identifier', 'a')
expect('z').toMatchToken('Identifier', 'z')
expect('$').toMatchToken('Identifier', '$')
expect('📢').toMatchToken('Identifier', '📢')
expect('?').toBeToken('Word') // ? alone is not valid identifier start
})
test('two character identifiers', () => {
expect('ab').toMatchToken('Identifier', 'ab')
expect('a1').toMatchToken('Identifier', 'a1')
expect('a-').toMatchToken('Identifier', 'a-')
expect('a?').toMatchToken('Identifier', 'a?') // ? valid at end
expect('ab?').toMatchToken('Identifier', 'ab?')
})
test('three+ character identifiers', () => {
expect('abc').toMatchToken('Identifier', 'abc')
expect('a-b').toMatchToken('Identifier', 'a-b')
expect('a1b').toMatchToken('Identifier', 'a1b')
expect('abc?').toMatchToken('Identifier', 'abc?') // ? valid at end
expect('a-b-c?').toMatchToken('Identifier', 'a-b-c?')
})
test('edge cases', () => {
expect('-bobby-mcgee').toBeToken('Word')
expect('starts-with??').toMatchToken('Identifier', 'starts-with??')
expect('starts?with?').toMatchToken('Identifier', 'starts?with?')
expect('a??b').toMatchToken('Identifier', 'a??b')
expect('oink-oink!').toBeToken('Word')
expect('dog#pound').toMatchToken('Word', 'dog#pound')
expect('http://website.com').toMatchToken('Word', 'http://website.com')
expect('school$cool').toMatchToken('Identifier', 'school$cool')
expect('EXIT:').toMatchTokens({ type: 'Word', value: 'EXIT' }, { type: 'Colon' })
expect(`if y == 1: 'cool' end`).toMatchTokens(
{ type: 'Keyword', value: 'if' },
{ type: 'Identifier', value: 'y' },
{ type: 'Operator', value: '==' },
{ type: 'Number', value: '1' },
{ type: 'Colon' },
{ type: 'String', value: `'cool'` },
{ type: 'Keyword', value: 'end' },
)
})
})
describe('paths', () => {
test('starting with ./', () => {
expect('./tmp').toMatchToken('Word', './tmp')
})
test('starting with /', () => {
expect('/home/chris/dev').toMatchToken('Word', '/home/chris/dev')
})
test('identifiers with dots tokenize separately', () => {
expect('readme.txt').toMatchTokens(
{ type: 'Identifier', value: 'readme' },
{ type: 'Operator', value: '.' },
{ type: 'Identifier', value: 'txt' },
)
})
test('words (non-identifiers) consume dots', () => {
expect('README.md').toMatchToken('Word', 'README.md')
})
test('all sorts of weird stuff', () => {
expect('dog#pound').toMatchToken('Word', 'dog#pound')
expect('my/kinda/place').toMatchToken('my/kinda/place')
expect('file://%/$##/@40!/index.php').toMatchToken('Word', 'file://%/$##/@40!/index.php')
})
})
describe('strings', () => {
test('single quoted', () => {
expect(`'hello world'`).toMatchToken('String', `'hello world'`)
expect(`'it\\'s a beautiful world'`).toMatchToken("'it\\'s a beautiful world'")
})
test('double quoted', () => {
expect(`"hello world"`).toMatchToken('String', `"hello world"`)
expect(`"it's a beautiful world"`).toMatchToken('String', `"it's a beautiful world"`)
})
test('empty strings', () => {
expect(`''`).toMatchToken('String', `''`)
expect(`""`).toMatchToken('String', `""`)
})
test('escape sequences', () => {
expect(`'hello\\nworld'`).toMatchToken('String', `'hello\\nworld'`)
expect(`'tab\\there'`).toMatchToken('String', `'tab\\there'`)
expect(`'quote\\''`).toMatchToken('String', `'quote\\''`)
expect(`'backslash\\\\'`).toMatchToken('String', `'backslash\\\\'`)
expect(`'dollar\\$sign'`).toMatchToken('String', `'dollar\\$sign'`)
})
test('unclosed strings - error case', () => {
// These should either fail or produce unexpected results
expect(`'hello`).toMatchToken('String', `'hello`)
expect(`"world`).toMatchToken('String', `"world`)
})
})
describe('curly strings', () => {
test('curly quoted', () => {
expect('{ one two three }').toMatchToken('String', `{ one two three }`)
})
test('work on multiple lines', () => {
expect(`{
one
two
three }`).toMatchToken(
'String',
`{
one
two
three }`,
)
})
test('can contain other curlies', () => {
expect(`{ { one }
two
{ three } }`).toMatchToken(
'String',
`{ { one }
two
{ three } }`,
)
})
test('empty curly string', () => {
expect('{}').toMatchToken('String', '{}')
})
test('unclosed curly string - error case', () => {
// Should either fail or produce unexpected results
expect('{ hello').toMatchToken('String', '{ hello')
expect('{ nested { unclosed }').toMatchToken('String', '{ nested { unclosed }')
})
})
describe('operators', () => {
test('math operators', () => {
// assignment
expect('=').toMatchToken('Operator', '=')
// logic
expect('or').toMatchToken('Operator', 'or')
expect('and').toMatchToken('Operator', 'and')
// bitwise
expect('band').toMatchToken('Operator', 'band')
expect('bor').toMatchToken('Operator', 'bor')
expect('bxor').toMatchToken('Operator', 'bxor')
expect('>>>').toMatchToken('Operator', '>>>')
expect('>>').toMatchToken('Operator', '>>')
expect('<<').toMatchToken('Operator', '<<')
// compound assignment
expect('??=').toMatchToken('Operator', '??=')
expect('+=').toMatchToken('Operator', '+=')
expect('-=').toMatchToken('Operator', '-=')
expect('*=').toMatchToken('Operator', '*=')
expect('/=').toMatchToken('Operator', '/=')
expect('%=').toMatchToken('Operator', '%=')
// nullish
expect('??').toMatchToken('Operator', '??')
// math
expect('**').toMatchToken('Operator', '**')
expect('*').toMatchToken('Operator', '*')
expect('/').toMatchToken('Operator', '/')
expect('+').toMatchToken('Operator', '+')
expect('-').toMatchToken('Operator', '-')
expect('%').toMatchToken('Operator', '%')
// comparison
expect('>=').toMatchToken('Operator', '>=')
expect('<=').toMatchToken('Operator', '<=')
expect('!=').toMatchToken('Operator', '!=')
expect('==').toMatchToken('Operator', '==')
expect('>').toMatchToken('Operator', '>')
expect('<').toMatchToken('Operator', '<')
// property access
expect('.').toMatchToken('Operator', '.')
})
})
describe('keywords', () => {
test('keywords', () => {
expect(`import`).toMatchToken('Keyword', 'import')
expect(`end`).toMatchToken('Keyword', 'end')
expect(`do`).toMatchToken('Keyword', 'do')
expect(`while`).toMatchToken('Keyword', 'while')
expect(`if`).toMatchToken('Keyword', 'if')
expect(`else`).toMatchToken('Keyword', 'else')
expect(`try`).toMatchToken('Keyword', 'try')
expect(`catch`).toMatchToken('Keyword', 'catch')
expect(`finally`).toMatchToken('Keyword', 'finally')
expect(`throw`).toMatchToken('Keyword', 'throw')
expect(`not`).toMatchToken('Keyword', 'not')
})
})
describe('regex', () => {
test('use double slash', () => {
expect(`//[0-9]+//`).toMatchToken('Regex', '//[0-9]+//')
})
})
describe('punctuation', () => {
test('underscore', () => {
expect(`_`).toBeToken('Underscore')
expect(`__`).toMatchToken('Word', '__')
})
test('semicolon', () => {
expect(`;`).toBeToken('Semicolon')
})
test('newline', () => {
expect('\n').toBeToken('Newline')
})
test('colon', () => {
expect(':').toBeToken('Colon')
})
})
describe('comments', () => {
test('comments', () => {
expect(`# hey friends`).toMatchToken('Comment', '# hey friends')
expect(`#hey-friends`).toMatchToken('Comment', '#hey-friends')
})
})
describe('brackets', () => {
test('parens', () => {
expect(`(`).toBeToken('OpenParen')
expect(`)`).toBeToken('CloseParen')
})
test('staples', () => {
expect(`[`).toBeToken('OpenBracket')
expect(`]`).toBeToken('CloseBracket')
})
})
describe('multiple tokens', () => {
test('constants work fine', () => {
expect(`null true false`).toMatchTokens(
{ type: 'Null' },
{ type: 'Boolean', value: 'true' },
{ type: 'Boolean', value: 'false' },
)
})
test('numbers', () => {
expect(`100 -400.42 null`).toMatchTokens(
{ type: 'Number', value: '100' },
{ type: 'Number', value: '-400.42' },
{ type: 'Null' },
)
})
test('whitespace', () => {
expect(`
'hello world'
'goodbye world'
`).toMatchTokens(
{ type: 'Newline' },
{ type: 'String', value: "'hello world'" },
{ type: 'Newline' },
{ type: 'Newline' },
{ type: 'String', value: "'goodbye world'" },
{ type: 'Newline' },
)
})
test('newline in parens is ignored', () => {
expect(`(
'hello world'
'goodbye world'
)`).toMatchTokens(
{ type: 'OpenParen' },
{ type: 'String', value: "'hello world'" },
{ type: 'String', value: "'goodbye world'" },
{ type: 'CloseParen' },
)
})
test('newline in brackets is ignored', () => {
expect(`[
a b
c d
e
f
]`).toMatchTokens(
{ type: 'OpenBracket' },
{ type: 'Identifier', value: 'a' },
{ type: 'Identifier', value: 'b' },
{ type: 'Identifier', value: 'c' },
{ type: 'Identifier', value: 'd' },
{ type: 'Identifier', value: 'e' },
{ type: 'Identifier', value: 'f' },
{ type: 'CloseBracket' },
)
})
test('function call', () => {
expect('echo hello world').toMatchTokens(
{ type: 'Identifier', value: 'echo' },
{ type: 'Identifier', value: 'hello' },
{ type: 'Identifier', value: 'world' },
)
})
test('function call w/ parens', () => {
expect('echo(bold hello world)').toMatchTokens(
{ type: 'Identifier', value: 'echo' },
{ type: 'OpenParen' },
{ type: 'Identifier', value: 'bold' },
{ type: 'Identifier', value: 'hello' },
{ type: 'Identifier', value: 'world' },
{ type: 'CloseParen' },
)
expect('echo (bold hello world)').toMatchTokens(
{ type: 'Identifier', value: 'echo' },
{ type: 'OpenParen' },
{ type: 'Identifier', value: 'bold' },
{ type: 'Identifier', value: 'hello' },
{ type: 'Identifier', value: 'world' },
{ type: 'CloseParen' },
)
})
test('assignment', () => {
expect('x = 5').toMatchTokens(
{ type: 'Identifier', value: 'x' },
{ type: 'Operator', value: '=' },
{ type: 'Number', value: '5' },
)
})
test('math expression', () => {
expect('1 + 2 * 3').toMatchTokens(
{ type: 'Number', value: '1' },
{ type: 'Operator', value: '+' },
{ type: 'Number', value: '2' },
{ type: 'Operator', value: '*' },
{ type: 'Number', value: '3' },
)
})
test('inline comment', () => {
expect('x = 5 # set x').toMatchTokens(
{ type: 'Identifier', value: 'x' },
{ type: 'Operator', value: '=' },
{ type: 'Number', value: '5' },
{ type: 'Comment', value: '# set x' },
)
})
test('line comment', () => {
expect('x = 5 \n# hello\n set x').toMatchTokens(
{ type: 'Identifier', value: 'x' },
{ type: 'Operator', value: '=' },
{ type: 'Number', value: '5' },
{ type: 'Newline' },
{ type: 'Comment', value: '# hello' },
{ type: 'Newline' },
{ type: 'Identifier', value: 'set' },
{ type: 'Identifier', value: 'x' },
)
})
test('colons separate tokens', () => {
expect('x do: y').toMatchTokens(
{ type: 'Identifier', value: 'x' },
{ type: 'Keyword', value: 'do' },
{ type: 'Colon' },
{ type: 'Identifier', value: 'y' },
)
expect('x: y').toMatchTokens(
{ type: 'Identifier', value: 'x' },
{ type: 'Colon' },
{ type: 'Identifier', value: 'y' },
)
expect('5: y').toMatchTokens(
{ type: 'Number', value: '5' },
{ type: 'Colon' },
{ type: 'Identifier', value: 'y' },
)
expect(`if (var? 'abc'): y`).toMatchTokens(
{ type: 'Keyword', value: 'if' },
{ type: 'OpenParen' },
{ type: 'Identifier', value: 'var?' },
{ type: 'String', value: `'abc'` },
{ type: 'CloseParen' },
{ type: 'Colon' },
{ type: 'Identifier', value: 'y' },
)
expect(`
do x:
y
end`).toMatchTokens(
{ type: 'Newline' },
{ type: 'Keyword', value: 'do' },
{ type: 'Identifier', value: 'x' },
{ type: 'Colon' },
{ type: 'Newline' },
{ type: 'Identifier', value: 'y' },
{ type: 'Newline' },
{ type: 'Keyword', value: 'end' },
)
})
test('semicolons separate statements', () => {
expect('x; y').toMatchTokens(
{ type: 'Identifier', value: 'x' },
{ type: 'Semicolon' },
{ type: 'Identifier', value: 'y' },
)
})
test('semicolons in parens', () => {
expect('(x; y)').toMatchTokens(
{ type: 'OpenParen' },
{ type: 'Identifier', value: 'x' },
{ type: 'Semicolon' },
{ type: 'Identifier', value: 'y' },
{ type: 'CloseParen' },
)
})
test('dot operator beginning word with slash', () => {
expect(`(basename ./cool)`).toMatchTokens(
{ type: 'OpenParen' },
{ type: 'Identifier', value: 'basename' },
{ type: 'Word', value: './cool' },
{ type: 'CloseParen' },
)
})
test('dot word after identifier with space', () => {
expect(`expand-path .git`).toMatchTokens(
{ type: 'Identifier', value: 'expand-path' },
{ type: 'Word', value: '.git' },
)
})
test('dot operator after identifier without space', () => {
expect(`config.path`).toMatchTokens(
{ type: 'Identifier', value: 'config' },
{ type: 'Operator', value: '.' },
{ type: 'Identifier', value: 'path' },
)
})
})
describe('nesting edge cases', () => {
test('deeply nested parens', () => {
expect('((nested))').toMatchTokens(
{ type: 'OpenParen' },
{ type: 'OpenParen' },
{ type: 'Identifier', value: 'nested' },
{ type: 'CloseParen' },
{ type: 'CloseParen' },
)
})
test('mixed nesting', () => {
expect('([combo])').toMatchTokens(
{ type: 'OpenParen' },
{ type: 'OpenBracket' },
{ type: 'Identifier', value: 'combo' },
{ type: 'CloseBracket' },
{ type: 'CloseParen' },
)
})
})
describe('invalid numbers that should be words', () => {
test('invalid binary', () => {
expect('0b2').toMatchToken('Word', '0b2')
expect('0b123').toMatchToken('Word', '0b123')
})
test('invalid octal', () => {
expect('0o8').toMatchToken('Word', '0o8')
expect('0o999').toMatchToken('Word', '0o999')
})
test('invalid hex', () => {
expect('0xGGG').toMatchToken('Word', '0xGGG')
expect('0xZZZ').toMatchToken('Word', '0xZZZ')
})
test('multiple decimal points', () => {
expect('1.2.3').toMatchToken('Word', '1.2.3')
})
})
describe('unicode and emoji', () => {
test('greek letters', () => {
expect('αβγ').toMatchToken('Identifier', 'αβγ')
expect('delta-δ').toMatchToken('Identifier', 'delta-δ')
})
test('math symbols', () => {
expect('∑').toMatchToken('Identifier', '∑')
expect('∏').toMatchToken('Identifier', '∏')
})
test('CJK characters', () => {
expect('你好').toMatchToken('Identifier', '你好')
expect('こんにちは').toMatchToken('Identifier', 'こんにちは')
})
})
describe('empty and whitespace input', () => {
test('empty string', () => {
expect('').toMatchTokens()
})
test('only whitespace', () => {
expect(' ').toMatchTokens()
})
test('only tabs', () => {
expect('\t\t\t').toMatchTokens()
})
test('only newlines', () => {
expect('\n\n\n').toMatchTokens({ type: 'Newline' }, { type: 'Newline' }, { type: 'Newline' })
})
})
describe('named args', () => {
test("don't need spaces", () => {
expect(`named=arg`).toMatchTokens(
{ type: 'NamedArgPrefix', value: 'named=' },
{ type: 'Identifier', value: 'arg' },
)
})
test('can have spaces', () => {
expect(`named= arg`).toMatchTokens(
{ type: 'NamedArgPrefix', value: 'named=' },
{ type: 'Identifier', value: 'arg' },
)
})
test('can include numbers', () => {
expect(`named123= arg`).toMatchTokens(
{ type: 'NamedArgPrefix', value: 'named123=' },
{ type: 'Identifier', value: 'arg' },
)
})
})
describe('dot operator', () => {
test('standalone dot', () => {
expect('.').toMatchToken('Operator', '.')
})
test('dot between identifiers tokenizes as separate tokens', () => {
expect('config.path').toMatchTokens(
{ type: 'Identifier', value: 'config' },
{ type: 'Operator', value: '.' },
{ type: 'Identifier', value: 'path' },
)
})
test('dot with number', () => {
expect('array.0').toMatchTokens(
{ type: 'Identifier', value: 'array' },
{ type: 'Operator', value: '.' },
{ type: 'Number', value: '0' },
)
})
test('chained dots', () => {
expect('a.b.c').toMatchTokens(
{ type: 'Identifier', value: 'a' },
{ type: 'Operator', value: '.' },
{ type: 'Identifier', value: 'b' },
{ type: 'Operator', value: '.' },
{ type: 'Identifier', value: 'c' },
)
})
test('identifier-like paths tokenize separately', () => {
expect('readme.txt').toMatchTokens(
{ type: 'Identifier', value: 'readme' },
{ type: 'Operator', value: '.' },
{ type: 'Identifier', value: 'txt' },
)
})
test('word-like paths remain as single token', () => {
expect('./file.txt').toMatchToken('Word', './file.txt')
expect('README.TXT').toMatchToken('Word', 'README.TXT')
})
test('dot with paren expression', () => {
expect('obj.(1 + 2)').toMatchTokens(
{ type: 'Identifier', value: 'obj' },
{ type: 'Operator', value: '.' },
{ type: 'OpenParen' },
{ type: 'Number', value: '1' },
{ type: 'Operator', value: '+' },
{ type: 'Number', value: '2' },
{ type: 'CloseParen' },
)
})
test('chained dot with paren expression', () => {
expect('obj.items.(i)').toMatchTokens(
{ type: 'Identifier', value: 'obj' },
{ type: 'Operator', value: '.' },
{ type: 'Identifier', value: 'items' },
{ type: 'Operator', value: '.' },
{ type: 'OpenParen' },
{ type: 'Identifier', value: 'i' },
{ type: 'CloseParen' },
)
})
})

348
src/parser/tokenizer.ts Normal file
View File

@ -0,0 +1,348 @@
import { ExternalTokenizer, InputStream, Stack } from '@lezer/lr'
import { Identifier, AssignableIdentifier, Word, IdentifierBeforeDot, Do, CurlyString } from './shrimp.terms'
// doobie doobie do (we need the `do` keyword to know when we're defining params)
export function specializeKeyword(ident: string) {
return ident === 'do' ? Do : -1
}
// tell the dotGet searcher about builtin globals
export const globals: string[] = []
export const setGlobals = (newGlobals: string[] | Record<string, any>) => {
globals.length = 0
globals.push(...(Array.isArray(newGlobals) ? newGlobals : Object.keys(newGlobals)))
}
// The only chars that can't be words are whitespace, apostrophes, closing parens, and EOF.
export const tokenizer = new ExternalTokenizer(
(input: InputStream, stack: Stack) => {
const ch = getFullCodePoint(input, 0)
// Handle curly strings
if (ch === 123 /* { */) return consumeCurlyString(input, stack)
if (!isWordChar(ch)) return
// Don't consume things that start with digits - let Number token handle it
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
const isValidStart = isIdentStart(ch)
const canBeWord = stack.canShift(Word)
// Consume all word characters, tracking if it remains a valid identifier
const { pos, isValidIdentifier, stoppedAtDot } = consumeWordToken(
input,
isValidStart,
canBeWord
)
// Check if we should emit IdentifierBeforeDot for property access
if (stoppedAtDot) {
const dotGetToken = checkForDotGet(input, stack, pos)
if (dotGetToken) {
input.advance(pos)
input.acceptToken(dotGetToken)
} else {
// Not in scope - continue consuming the dot as part of the word
const afterDot = consumeRestOfWord(input, pos + 1, canBeWord)
input.advance(afterDot)
input.acceptToken(Word)
}
return
}
// Advance past the token we consumed
input.advance(pos)
// Choose which token to emit
if (isValidIdentifier) {
const token = chooseIdentifierToken(input, stack)
input.acceptToken(token)
} else {
input.acceptToken(Word)
}
},
{ contextual: true }
)
// Build identifier text from input stream, handling surrogate pairs for emoji
const buildIdentifierText = (input: InputStream, length: number): string => {
let text = ''
for (let i = 0; i < length; i++) {
const charCode = input.peek(i)
if (charCode === -1) break
// Handle surrogate pairs for emoji (UTF-16 encoding)
if (charCode >= 0xd800 && charCode <= 0xdbff && i + 1 < length) {
const low = input.peek(i + 1)
if (low >= 0xdc00 && low <= 0xdfff) {
text += String.fromCharCode(charCode, low)
i++ // Skip the low surrogate
continue
}
}
text += String.fromCharCode(charCode)
}
return text
}
// 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
const consumeWordToken = (
input: InputStream,
isValidStart: boolean,
canBeWord: boolean
): { pos: number; isValidIdentifier: boolean; stoppedAtDot: boolean } => {
let pos = getCharSize(getFullCodePoint(input, 0))
let isValidIdentifier = isValidStart
let stoppedAtDot = false
while (true) {
const ch = getFullCodePoint(input, pos)
// Stop at dot if we have a valid identifier (might be property access)
if (ch === 46 /* . */ && isValidIdentifier) {
stoppedAtDot = true
break
}
// Stop if we hit a non-word character
if (!isWordChar(ch)) break
// Context-aware termination: semicolon/colon can end a word if followed by whitespace
// This allows `hello; 2` to parse correctly while `hello;world` stays as one word
if (canBeWord && (ch === 59 /* ; */ || ch === 58) /* : */) {
const nextCh = getFullCodePoint(input, pos + 1)
if (!isWordChar(nextCh)) break
}
// Track identifier validity: must be lowercase, digit, dash, or emoji/unicode
if (!isIdentChar(ch)) {
if (!canBeWord) break
isValidIdentifier = false
}
pos += getCharSize(ch)
}
return { pos, isValidIdentifier, stoppedAtDot }
}
// Consume the rest of a word after we've decided not to treat a dot as DotGet
// Used when we have "file.txt" - we already consumed "file", now consume ".txt"
const consumeRestOfWord = (input: InputStream, startPos: number, canBeWord: boolean): number => {
let pos = startPos
while (true) {
const ch = getFullCodePoint(input, pos)
// Stop if we hit a non-word character
if (!isWordChar(ch)) break
// Context-aware termination for semicolon/colon
if (canBeWord && (ch === 59 /* ; */ || ch === 58) /* : */) {
const nextCh = getFullCodePoint(input, pos + 1)
if (!isWordChar(nextCh)) break
}
pos += getCharSize(ch)
}
return pos
}
// Consumes { curly strings } and tracks braces so you can { have { braces { inside { braces } } }
const consumeCurlyString = (input: InputStream, stack: Stack) => {
if (!stack.canShift(CurlyString)) return
let depth = 0
let pos = 0
while (true) {
const ch = input.peek(pos)
if (ch < 0) return // EOF - invalid
if (ch === 123) depth++ // {
else if (ch === 125) { // }
depth--
if (depth === 0) {
pos++ // consume final }
break
}
}
pos++
}
input.acceptToken(CurlyString, pos)
}
// Check if this identifier is in scope (for property access detection)
// Returns IdentifierBeforeDot token if in scope, null otherwise
const checkForDotGet = (input: InputStream, stack: Stack, pos: number): number | null => {
const identifierText = buildIdentifierText(input, pos)
const context = stack.context as { scope: { has(name: string): boolean } } | undefined
// If identifier is in scope, this is property access (e.g., obj.prop)
// If not in scope, it should be consumed as a Word (e.g., file.txt)
return context?.scope.has(identifierText) || globals.includes(identifierText)
? IdentifierBeforeDot
: null
}
// Decide between AssignableIdentifier and Identifier using grammar state + peek-ahead
const chooseIdentifierToken = (input: InputStream, stack: Stack): number => {
const canAssignable = stack.canShift(AssignableIdentifier)
const canRegular = stack.canShift(Identifier)
// Only one option is valid - use it
if (canAssignable && !canRegular) return AssignableIdentifier
if (canRegular && !canAssignable) return Identifier
// Both possible (ambiguous context) - peek ahead for '=' to disambiguate
// This happens at statement start where both `x = 5` (assign) and `echo x` (call) are valid
let peekPos = 0
while (true) {
const ch = getFullCodePoint(input, peekPos)
if (isWhiteSpace(ch)) {
peekPos += getCharSize(ch)
} else {
break
}
}
const nextCh = getFullCodePoint(input, peekPos)
const nextCh2 = getFullCodePoint(input, peekPos + 1)
const nextCh3 = getFullCodePoint(input, peekPos + 2)
// Check for ??= (three-character compound operator)
if (nextCh === 63 /* ? */ && nextCh2 === 63 /* ? */ && nextCh3 === 61 /* = */) {
const charAfterOp = getFullCodePoint(input, peekPos + 3)
if (isWhiteSpace(charAfterOp) || charAfterOp === -1 /* EOF */) {
return AssignableIdentifier
}
}
// Check for compound assignment operators: +=, -=, *=, /=, %=
if (
[43 /* + */, 45 /* - */, 42 /* * */, 47 /* / */, 37 /* % */].includes(nextCh) &&
nextCh2 === 61 /* = */
) {
// Found compound operator, check if it's followed by whitespace
const charAfterOp = getFullCodePoint(input, peekPos + 2)
if (isWhiteSpace(charAfterOp) || charAfterOp === -1 /* EOF */) {
return AssignableIdentifier
}
}
if (nextCh === 61 /* = */) {
// Found '=', but check if it's followed by whitespace
// If '=' is followed by non-whitespace (like '=cool*'), it won't be tokenized as Eq
// In that case, this should be Identifier (for function call), not AssignableIdentifier
const charAfterEquals = getFullCodePoint(input, peekPos + 1)
if (isWhiteSpace(charAfterEquals) || charAfterEquals === -1 /* EOF */) {
return AssignableIdentifier
}
}
return Identifier
}
// Character classification helpers
export const isIdentStart = (ch: number): boolean => {
return isLowercaseLetter(ch) || isEmojiOrUnicode(ch)
}
export const isIdentChar = (ch: number): boolean => {
return isLowercaseLetter(ch) || isDigit(ch) || ch === 45 /* - */ || ch === 63 /* ? */ || isEmojiOrUnicode(ch)
}
const isWhiteSpace = (ch: number): boolean => {
return ch === 32 /* space */ || ch === 9 /* tab */ || ch === 13 /* \r */
}
const isWordChar = (ch: number): boolean => {
return (
!isWhiteSpace(ch) &&
ch !== 10 /* \n */ &&
ch !== 41 /* ) */ &&
ch !== 93 /* ] */ &&
ch !== -1 /* EOF */
)
}
const isLowercaseLetter = (ch: number): boolean => {
return ch >= 97 && ch <= 122 // a-z
}
const isDigit = (ch: number): boolean => {
return ch >= 48 && ch <= 57 // 0-9
}
const getFullCodePoint = (input: InputStream, pos: number): number => {
const ch = input.peek(pos)
// Check if this is a high surrogate (0xD800-0xDBFF)
if (ch >= 0xd800 && ch <= 0xdbff) {
const low = input.peek(pos + 1)
// Check if next is low surrogate (0xDC00-0xDFFF)
if (low >= 0xdc00 && low <= 0xdfff) {
// Combine surrogate pair into full code point
return 0x10000 + ((ch & 0x3ff) << 10) + (low & 0x3ff)
}
}
return ch
}
const isEmojiOrUnicode = (ch: number): boolean => {
return (
// Basic Emoticons
(ch >= 0x1f600 && ch <= 0x1f64f) ||
// Miscellaneous Symbols and Pictographs
(ch >= 0x1f300 && ch <= 0x1f5ff) ||
// Transport and Map Symbols
(ch >= 0x1f680 && ch <= 0x1f6ff) ||
// Regional Indicator Symbols (flags)
(ch >= 0x1f1e6 && ch <= 0x1f1ff) ||
// Miscellaneous Symbols (hearts, stars, weather)
(ch >= 0x2600 && ch <= 0x26ff) ||
// Dingbats (scissors, pencils, etc)
(ch >= 0x2700 && ch <= 0x27bf) ||
// Supplemental Symbols and Pictographs (newer emojis)
(ch >= 0x1f900 && ch <= 0x1f9ff) ||
// Symbols and Pictographs Extended-A (newest emojis)
(ch >= 0x1fa70 && ch <= 0x1faff) ||
// Various Asian Characters with emoji presentation
(ch >= 0x1f018 && ch <= 0x1f270) ||
// Variation Selectors (for emoji presentation)
(ch >= 0xfe00 && ch <= 0xfe0f) ||
// Additional miscellaneous items
(ch >= 0x238c && ch <= 0x2454) ||
// Combining Diacritical Marks for Symbols
(ch >= 0x20d0 && ch <= 0x20ff) ||
// Latin-1 Supplement (includes ², ³, ¹ and other special chars)
(ch >= 0x00a0 && ch <= 0x00ff) ||
// Greek and Coptic (U+0370-U+03FF)
(ch >= 0x0370 && ch <= 0x03ff) ||
// Mathematical Alphanumeric Symbols (U+1D400-U+1D7FF)
(ch >= 0x1d400 && ch <= 0x1d7ff) ||
// Mathematical Operators (U+2200-U+22FF)
(ch >= 0x2200 && ch <= 0x22ff) ||
// Superscripts and Subscripts (U+2070-U+209F)
(ch >= 0x2070 && ch <= 0x209f) ||
// Arrows (U+2190-U+21FF)
(ch >= 0x2190 && ch <= 0x21ff) ||
// Hiragana (U+3040-U+309F)
(ch >= 0x3040 && ch <= 0x309f) ||
// Katakana (U+30A0-U+30FF)
(ch >= 0x30a0 && ch <= 0x30ff) ||
// CJK Unified Ideographs (U+4E00-U+9FFF)
(ch >= 0x4e00 && ch <= 0x9fff)
)
}
const getCharSize = (ch: number) => (ch > 0xffff ? 2 : 1) // emoji takes 2 UTF-16 code units

View File

@ -1,596 +0,0 @@
import { isDebug } from '#utils/utils'
export type Token = {
type: TokenType
value?: string
from: number
to: number
}
export enum TokenType {
Comment,
Keyword,
Operator,
Newline,
Semicolon,
Colon,
Underscore,
OpenParen,
CloseParen,
OpenBracket,
CloseBracket,
Identifier,
Word,
NamedArgPrefix,
Null,
Boolean,
Number,
String,
Regex,
}
const valueTokens = new Set([
TokenType.Comment,
TokenType.Keyword,
TokenType.Operator,
TokenType.Identifier,
TokenType.Word,
TokenType.NamedArgPrefix,
TokenType.Boolean,
TokenType.Number,
TokenType.String,
TokenType.Regex,
TokenType.Underscore,
])
const operators = new Set([
// assignment
'=',
// logic
'or',
'and',
// bitwise
'band',
'bor',
'bxor',
'>>>',
'>>',
'<<',
// compound assignment
'??=',
'+=',
'-=',
'*=',
'/=',
'%=',
// nullish
'??',
// math
'**',
'*',
'/',
'+',
'-',
'%',
// comparison
'>=',
'<=',
'!=',
'==',
'>',
'<',
// property access
'.',
// pipe
'|',
])
const keywords = new Set([
'import',
'end',
'do',
'if',
'while',
'if',
'else',
'try',
'catch',
'finally',
'throw',
'not',
])
// helper
function c(strings: TemplateStringsArray, ...values: any[]) {
return strings.reduce((result, str, i) => result + str + (values[i] ?? ''), '').charCodeAt(0)
}
function s(c: number): string {
return String.fromCharCode(c)
}
export class Scanner {
input = ''
pos = 0
start = 0
char = 0
prev = 0
inParen = 0
inBracket = 0
tokens: Token[] = []
prevIsWhitespace = true
reset() {
this.input = ''
this.pos = 0
this.start = 0
this.char = 0
this.prev = 0
this.tokens.length = 0
this.prevIsWhitespace = true
}
peek(count = 0): number {
return getFullCodePoint(this.input, this.pos + count)
}
next(): number {
this.prevIsWhitespace = isWhitespace(this.char)
this.prev = this.char
this.char = this.peek()
this.pos += getCharSize(this.char)
return this.char
}
push(type: TokenType, from?: number, to?: number) {
from ??= this.start
to ??= this.pos - getCharSize(this.char)
if (to < from) to = from
this.tokens.push(
Object.assign(
{},
{
type,
from,
to,
},
valueTokens.has(type) ? { value: this.input.slice(from, to) } : {}
)
)
if (isDebug()) {
const tok = this.tokens.at(-1)
console.log(`≫ PUSH(${from},${to})`, TokenType[tok?.type || 0], '—', tok?.value)
}
this.start = this.pos
}
pushChar(type: TokenType) {
this.push(type, this.pos - 1, this.pos)
}
// turn shrimp code into shrimp tokens that get fed into the parser
tokenize(input: string): Token[] {
this.reset()
this.input = input
this.next()
while (this.char > 0) {
const char = this.char
if (char === c`#`) {
this.readComment()
continue
}
if (isBracket(char)) {
this.readBracket()
continue
}
if (isStringDelim(char)) {
this.readString(char)
continue
}
if (char === c`{`) {
this.readCurlyString()
continue
}
if (isIdentStart(char)) {
this.readWordOrIdent(true) // true = started with identifier char
continue
}
if (isDigit(char) || ((char === c`-` || char === c`+`) && isDigit(this.peek()))) {
this.readNumber()
continue
}
if (char === c`:`) {
this.pushChar(TokenType.Colon)
this.next()
continue
}
// whitespace-sensitive dot as operator (property access) only after identifier/number
if (char === c`.`) {
if (this.canBeDotGet(this.tokens.at(-1))) {
this.pushChar(TokenType.Operator)
this.next()
continue
}
}
if (char === c`/` && this.peek() === c`/`) {
this.readRegex()
continue
}
if (isWordChar(char)) {
this.readWordOrIdent(false) // false = didn't start with identifier char
continue
}
if (char === c`\n`) {
if (this.inParen === 0 && this.inBracket === 0) this.pushChar(TokenType.Newline)
this.next()
continue
}
if (char === c`;`) {
this.pushChar(TokenType.Semicolon)
this.next()
continue
}
this.next()
}
return this.tokens
}
readComment() {
this.start = this.pos - 1
while (this.char !== c`\n` && this.char > 0) this.next()
this.push(TokenType.Comment)
}
readBracket() {
switch (this.char) {
case c`(`:
this.inParen++
this.pushChar(TokenType.OpenParen)
break
case c`)`:
this.inParen--
this.pushChar(TokenType.CloseParen)
break
case c`[`:
this.inBracket++
this.pushChar(TokenType.OpenBracket)
break
case c`]`:
this.inBracket--
this.pushChar(TokenType.CloseBracket)
break
}
this.next()
}
readString(delim: number) {
this.start = this.pos - 1
this.next() // skip opening delim
while (this.char > 0 && (this.char !== delim || (this.char === delim && this.prev === c`\\`)))
this.next()
this.next() // skip closing delim
this.push(TokenType.String)
}
readCurlyString() {
this.start = this.pos - 1
let depth = 1
this.next()
while (depth > 0 && this.char > 0) {
if (this.char === c`{`) depth++
if (this.char === c`}`) depth--
this.next()
}
this.push(TokenType.String)
}
readWordOrIdent(startedWithIdentChar: boolean) {
this.start = this.pos - getCharSize(this.char)
while (isWordChar(this.char)) {
// stop at colon if followed by whitespace (e.g., 'do x: echo x end')
if (this.char === c`:`) {
const nextCh = this.peek()
if (isWhitespace(nextCh) || nextCh === 0) break
}
// stop at equal sign (named arg) - but only if what we've read so far is an identifier
if (this.char === c`=`) {
const soFar = this.input.slice(this.start, this.pos - getCharSize(this.char))
if (isIdentifer(soFar)) {
this.next()
break
}
}
// stop at dot only if it would create a valid property access
// AND only if we started with an identifier character (not for Words like README.txt)
if (startedWithIdentChar && this.char === c`.`) {
const nextCh = this.peek()
if (isIdentStart(nextCh) || isDigit(nextCh) || nextCh === c`(`) {
const soFar = this.input.slice(this.start, this.pos - getCharSize(this.char))
if (isIdentifer(soFar)) break
}
}
this.next()
}
const word = this.input.slice(this.start, this.pos - getCharSize(this.char))
// classify the token based on what we read
if (word === '_') this.push(TokenType.Underscore)
else if (word === 'null') this.push(TokenType.Null)
else if (word === 'true' || word === 'false') this.push(TokenType.Boolean)
else if (isKeyword(word)) this.push(TokenType.Keyword)
else if (isOperator(word)) this.push(TokenType.Operator)
else if (isIdentifer(word)) this.push(TokenType.Identifier)
else if (word.endsWith('=')) this.push(TokenType.NamedArgPrefix)
else this.push(TokenType.Word)
}
readNumber() {
this.start = this.pos - 1
while (isWordChar(this.char)) {
// stop at dot unless it's part of the number
if (this.char === c`.`) {
const nextCh = this.peek()
if (!isDigit(nextCh)) break
}
// stop at colon
if (this.char === c`:`) {
const nextCh = this.peek()
if (isWhitespace(nextCh) || nextCh === 0) break
}
this.next()
}
const ident = this.input.slice(this.start, this.pos - 1)
this.push(isNumber(ident) ? TokenType.Number : TokenType.Word)
}
readRegex() {
this.start = this.pos - 1
this.next() // skip 2nd /
while (this.char > 0) {
if (this.char === c`/` && this.peek() === c`/`) {
this.next() // skip /
this.next() // skip /
// 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
}
this.next()
}
}
canBeDotGet(lastToken?: Token): boolean {
return (
!this.prevIsWhitespace &&
!!lastToken &&
(lastToken.type === TokenType.Identifier ||
lastToken.type === TokenType.Number ||
lastToken.type === TokenType.CloseParen ||
lastToken.type === TokenType.CloseBracket)
)
}
}
const isNumber = (word: string): boolean => {
// regular number
if (/^[+-]?\d+(_?\d+)*(\.(\d+(_?\d+)*))?$/.test(word)) return true
// binary
if (/^[+-]?0b[01]+(_?[01]+)*(\.[01](_?[01]*))?$/.test(word)) return true
// octal
if (/^[+-]?0o[0-7]+(_?[0-7]+)*(\.[0-7](_?[0-7]*))?$/.test(word)) return true
// hex
if (/^[+-]?0x[0-9a-f]+([0-9a-f]_?[0-9a-f]+)*(\.([0-9a-f]_?[0-9a-f]*))?$/i.test(word)) return true
return false
}
const isIdentifer = (s: string): boolean => {
if (s.length === 0) return false
let pos = 0
const chars = []
while (pos < s.length) {
const out = getFullCodePoint(s, pos)
pos += getCharSize(out)
chars.push(out)
}
if (chars.length === 1) return isIdentStart(chars[0]!)
else if (chars.length === 2) return isIdentStart(chars[0]!) && isIdentEnd(chars[1]!)
else
return (
isIdentStart(chars[0]!) &&
chars.slice(1, chars.length - 1).every(isIdentChar) &&
isIdentEnd(chars.at(-1)!)
)
}
const isStringDelim = (ch: number): boolean => {
return ch === c`'` || ch === c`"`
}
export const isIdentStart = (char: number | string): boolean => {
let ch = typeof char === 'string' ? char.charCodeAt(0) : char
return isLowercaseLetter(ch) || isEmojiOrUnicode(ch) || ch === 36 /* $ */
}
export const isIdentChar = (char: number | string): boolean => {
let ch = typeof char === 'string' ? char.charCodeAt(0) : char
return isIdentStart(ch) || isDigit(ch) || ch === 45 /* - */ || ch === 63 /* ? */
}
const isIdentEnd = (char: number | string): boolean => {
return isIdentChar(char)
}
const isLowercaseLetter = (ch: number): boolean => {
return ch >= 97 && ch <= 122 // a-z
}
const isDigit = (ch: number): boolean => {
return ch >= 48 && ch <= 57 // 0-9
}
const isWhitespace = (ch: number): boolean => {
return (
ch === 32 /* space */ ||
ch === 9 /* tab */ ||
ch === 13 /* \r */ ||
ch === 10 /* \n */ ||
ch === -1 ||
ch === 0
) /* EOF */
}
const isWordChar = (ch: number): boolean => {
return (
!isWhitespace(ch) &&
ch !== 10 /* \n */ &&
ch !== 59 /* ; */ &&
ch !== 40 /* ( */ &&
ch !== 41 /* ) */ &&
ch !== 93 /* ] */ &&
ch !== -1 /* EOF */
)
}
const isOperator = (word: string): boolean => {
return operators.has(word)
}
const isKeyword = (word: string): boolean => {
return keywords.has(word)
}
const isBracket = (char: number): boolean => {
return char === c`(` || char === c`)` || char === c`[` || char === c`]`
}
const getCharSize = (ch: number) => (ch > 0xffff ? 2 : 1) // emoji takes 2 UTF-16 code units
const getFullCodePoint = (input: string, pos: number): number => {
const ch = input[pos]?.charCodeAt(0) || 0
// Check if this is a high surrogate (0xD800-0xDBFF)
if (ch >= 0xd800 && ch <= 0xdbff) {
const low = input[pos + 1]?.charCodeAt(0) || 0
// Check if next is low surrogate (0xDC00-0xDFFF)
if (low >= 0xdc00 && low <= 0xdfff) {
// Combine surrogate pair into full code point
return 0x10000 + ((ch & 0x3ff) << 10) + (low & 0x3ff)
}
}
return ch
}
const isEmojiOrUnicode = (ch: number): boolean => {
return (
// Basic Emoticons
(ch >= 0x1f600 && ch <= 0x1f64f) ||
// Miscellaneous Symbols and Pictographs
(ch >= 0x1f300 && ch <= 0x1f5ff) ||
// Transport and Map Symbols
(ch >= 0x1f680 && ch <= 0x1f6ff) ||
// Regional Indicator Symbols (flags)
(ch >= 0x1f1e6 && ch <= 0x1f1ff) ||
// Miscellaneous Symbols (hearts, stars, weather)
(ch >= 0x2600 && ch <= 0x26ff) ||
// Dingbats (scissors, pencils, etc)
(ch >= 0x2700 && ch <= 0x27bf) ||
// Supplemental Symbols and Pictographs (newer emojis)
(ch >= 0x1f900 && ch <= 0x1f9ff) ||
// Symbols and Pictographs Extended-A (newest emojis)
(ch >= 0x1fa70 && ch <= 0x1faff) ||
// Various Asian Characters with emoji presentation
(ch >= 0x1f018 && ch <= 0x1f270) ||
// Variation Selectors (for emoji presentation)
(ch >= 0xfe00 && ch <= 0xfe0f) ||
// Additional miscellaneous items
(ch >= 0x238c && ch <= 0x2454) ||
// Combining Diacritical Marks for Symbols
(ch >= 0x20d0 && ch <= 0x20ff) ||
// Latin-1 Supplement (includes ², ³, ¹ and other special chars)
(ch >= 0x00a0 && ch <= 0x00ff) ||
// Greek and Coptic (U+0370-U+03FF)
(ch >= 0x0370 && ch <= 0x03ff) ||
// Mathematical Alphanumeric Symbols (U+1D400-U+1D7FF)
(ch >= 0x1d400 && ch <= 0x1d7ff) ||
// Mathematical Operators (U+2200-U+22FF)
(ch >= 0x2200 && ch <= 0x22ff) ||
// Superscripts and Subscripts (U+2070-U+209F)
(ch >= 0x2070 && ch <= 0x209f) ||
// Arrows (U+2190-U+21FF)
(ch >= 0x2190 && ch <= 0x21ff) ||
// Hiragana (U+3040-U+309F)
(ch >= 0x3040 && ch <= 0x309f) ||
// Katakana (U+30A0-U+30FF)
(ch >= 0x30a0 && ch <= 0x30ff) ||
// CJK Unified Ideographs (U+4E00-U+9FFF)
(ch >= 0x4e00 && ch <= 0x9fff)
)
}

View File

@ -1,12 +0,0 @@
export const date = {
now: () => Date.now(),
year: (time: number) => new Date(time).getFullYear(),
month: (time: number) => new Date(time).getMonth(),
date: (time: number) => new Date(time).getDate(),
hour: (time: number) => new Date(time).getHours(),
minute: (time: number) => new Date(time).getMinutes(),
second: (time: number) => new Date(time).getSeconds(),
ms: (time: number) => new Date(time).getMilliseconds(),
new: (year: number, month: number, day: number, hour = 0, minute = 0, second = 0, ms = 0) =>
new Date(year, month, day, hour, minute, second, ms).getTime(),
}

View File

@ -3,11 +3,9 @@ import { type Value, toString } from 'reefvm'
export const dict = {
keys: (dict: Record<string, any>) => Object.keys(dict),
values: (dict: Record<string, any>) => Object.values(dict),
entries: (dict: Record<string, any>) =>
Object.entries(dict).map(([k, v]) => ({ key: k, value: v })),
entries: (dict: Record<string, any>) => Object.entries(dict).map(([k, v]) => ({ key: k, value: v })),
'has?': (dict: Record<string, any>, key: string) => key in dict,
get: (dict: Record<string, any>, key: string, defaultValue: any = null) =>
dict[key] ?? defaultValue,
get: (dict: Record<string, any>, key: string, defaultValue: any = null) => dict[key] ?? defaultValue,
set: (dict: Value, key: Value, value: Value) => {
const map = dict.value as Map<string, Value>
map.set(toString(key), value)
@ -32,6 +30,6 @@ export const dict = {
'from-entries': (entries: [string, any][]) => Object.fromEntries(entries),
}
// raw functions deal directly in Value types, meaning we can modify collection
// careful - they MUST return a Value!
;(dict.set as any).raw = true
// raw functions deal directly in Value types, meaning we can modify collection
// careful - they MUST return a Value!
; (dict.set as any).raw = true

View File

@ -1,33 +1,23 @@
import { join, resolve, basename, dirname, extname } from 'path'
import {
readdirSync,
mkdirSync,
rmdirSync,
readFileSync,
writeFileSync,
appendFileSync,
rmSync,
copyFileSync,
statSync,
lstatSync,
chmodSync,
symlinkSync,
readlinkSync,
watch,
} from 'fs'
readdirSync, mkdirSync, rmdirSync,
readFileSync, writeFileSync, appendFileSync,
rmSync, copyFileSync,
statSync, lstatSync, chmodSync, symlinkSync, readlinkSync,
watch
} from "fs"
export const fs = {
// Directory operations
ls: (path: string) => readdirSync(path),
mkdir: (path: string) => mkdirSync(path, { recursive: true }),
rmdir: (path: string) =>
rmdirSync(path === '/' || path === '' ? '/tmp/*' : path, { recursive: true }),
rmdir: (path: string) => rmdirSync(path === '/' || path === '' ? '/tmp/*' : path, { recursive: true }),
pwd: () => process.cwd(),
cd: (path: string) => process.chdir(path),
// Reading
read: (path: string) => readFileSync(path, 'utf-8'),
cat: (path: string) => {}, // added below
cat: (path: string) => { }, // added below
'read-bytes': (path: string) => [...readFileSync(path)],
// Writing
@ -36,13 +26,13 @@ export const fs = {
// File operations
delete: (path: string) => rmSync(path),
rm: (path: string) => {}, // added below
rm: (path: string) => { }, // added below
copy: (from: string, to: string) => copyFileSync(from, to),
move: (from: string, to: string) => {
fs.copy(from, to)
fs.rm(from)
},
mv: (from: string, to: string) => {}, // added below
mv: (from: string, to: string) => { }, // added below
// Path operations
basename: (path: string) => basename(path),
@ -68,50 +58,39 @@ export const fs = {
} catch {
return {}
}
},
'exists?': (path: string) => {
try {
statSync(path)
return true
} catch {
}
catch {
return false
}
},
'file?': (path: string) => {
try {
return statSync(path).isFile()
} catch {
return false
}
try { return statSync(path).isFile() }
catch { return false }
},
'dir?': (path: string) => {
try {
return statSync(path).isDirectory()
} catch {
return false
}
try { return statSync(path).isDirectory() }
catch { return false }
},
'symlink?': (path: string) => {
try {
return lstatSync(path).isSymbolicLink()
} catch {
return false
}
try { return lstatSync(path).isSymbolicLink() }
catch { return false }
},
'exec?': (path: string) => {
try {
const stats = statSync(path)
return !!(stats.mode & 0o111)
} catch {
return false
}
catch { return false }
},
size: (path: string) => {
try {
return statSync(path).size
} catch {
return 0
}
try { return statSync(path).size }
catch { return 0 }
},
// Permissions
@ -135,12 +114,15 @@ export const fs = {
return readdirSync(dir)
.filter((f) => f.endsWith(ext))
.map((f) => join(dir, f))
},
watch: (path: string, callback: Function) =>
watch(path, (event, filename) => callback(event, filename)),
}
;(fs as any).cat = fs.read
;(fs as any).mv = fs.move
;(fs as any).cp = fs.copy
;(fs as any).rm = fs.delete
; (fs as any).cat = fs.read
; (fs as any).mv = fs.move
; (fs as any).cp = fs.copy
; (fs as any).rm = fs.delete

View File

@ -1,16 +1,10 @@
// The prelude creates all the builtin Shrimp functions.
import { join, resolve } from 'path'
import {
type Value,
type VM,
toValue,
extractParamInfo,
isWrapped,
getOriginalFunction,
type Value, type VM, toValue,
extractParamInfo, isWrapped, getOriginalFunction,
} from 'reefvm'
import { date } from './date'
import { dict } from './dict'
import { fs } from './fs'
import { json } from './json'
@ -18,11 +12,8 @@ import { load } from './load'
import { list } from './list'
import { math } from './math'
import { str } from './str'
import { types } from './types'
import { runningInBrowser } from '#utils/utils'
export const globals: Record<string, any> = {
date,
export const globals = {
dict,
fs,
json,
@ -31,39 +22,12 @@ export const globals: Record<string, any> = {
math,
str,
// shrimp runtime info
$: runningInBrowser
? {
args: [],
argv: [],
env: {},
pid: 0,
cwd: '',
script: {
name: '',
path: '.',
},
}
: {
args: Bun.argv.slice(3),
argv: Bun.argv.slice(1),
env: process.env,
pid: process.pid,
cwd: process.env.PWD,
script: {
name: Bun.argv[2] || '(shrimp)',
path: resolve(join('.', Bun.argv[2] ?? '')),
},
},
// hello
echo: (...args: any[]) => {
console.log(
...args.map((a) => {
const v = toValue(a)
return ['array', 'dict'].includes(v.type) ? formatValue(v, true) : v.value
})
)
console.log(...args.map(a => {
const v = toValue(a)
return ['array', 'dict'].includes(v.type) ? formatValue(v, true) : v.value
}))
return toValue(null)
},
@ -82,7 +46,7 @@ export const globals: Record<string, any> = {
},
ref: (fn: Function) => fn,
import: function (this: VM, atNamed: Record<any, string | string[]> = {}, ...idents: string[]) {
const onlyArray = Array.isArray(atNamed.only) ? atNamed.only : [atNamed.only].filter((a) => a)
const onlyArray = Array.isArray(atNamed.only) ? atNamed.only : [atNamed.only].filter(a => a)
const only = new Set(onlyArray)
const wantsOnly = only.size > 0
@ -101,12 +65,25 @@ export const globals: Record<string, any> = {
},
// env
argv: Bun.argv.slice(1), // everything
args: Bun.argv.slice(3), // only args specifically passed to this script/program
exit: (num: number) => process.exit(num ?? 0),
// type predicates
'string?': (v: any) => toValue(v).type === 'string',
'number?': (v: any) => toValue(v).type === 'number',
'boolean?': (v: any) => toValue(v).type === 'boolean',
'array?': (v: any) => toValue(v).type === 'array',
'dict?': (v: any) => toValue(v).type === 'dict',
'function?': (v: any) => {
const t = toValue(v).type
return t === 'function' || t === 'native'
},
'null?': (v: any) => toValue(v).type === 'null',
'some?': (v: any) => toValue(v).type !== 'null',
// boolean/logic
not: (v: any) => !v,
bnot: (n: number) => ~(n | 0),
// utilities
@ -118,13 +95,9 @@ export const globals: Record<string, any> = {
length: (v: any) => {
const value = toValue(v)
switch (value.type) {
case 'string':
case 'array':
return value.value.length
case 'dict':
return value.value.size
default:
throw new Error(`length: expected string, array, or dict, got ${value.type}`)
case 'string': case 'array': return value.value.length
case 'dict': return value.value.size
default: throw new Error(`length: expected string, array, or dict, got ${value.type}`)
}
},
at: (collection: any, index: number | string) => {
@ -132,9 +105,7 @@ export const globals: Record<string, any> = {
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}`
)
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') {
@ -161,8 +132,7 @@ export const globals: Record<string, any> = {
'empty?': (v: any) => {
const value = toValue(v)
switch (value.type) {
case 'string':
case 'array':
case 'string': case 'array':
return value.value.length === 0
case 'dict':
return value.value.size === 0
@ -176,6 +146,7 @@ export const globals: Record<string, any> = {
for (const value of list) await cb(value)
return list
},
}
export const colors = {
@ -188,7 +159,7 @@ export const colors = {
red: '\x1b[31m',
blue: '\x1b[34m',
magenta: '\x1b[35m',
pink: '\x1b[38;2;255;105;180m',
pink: '\x1b[38;2;255;105;180m'
}
export function formatValue(value: Value, inner = false): string {
@ -202,15 +173,15 @@ export function formatValue(value: Value, inner = false): string {
case 'null':
return `${colors.dim}null${colors.reset}`
case 'array': {
const items = value.value.map((x) => formatValue(x, true)).join(' ')
const items = value.value.map(x => formatValue(x, true)).join(' ')
return `${colors.blue}[${colors.reset}${items}${colors.blue}]${colors.reset}`
}
case 'dict': {
const entries = Array.from(value.value.entries())
.reverse()
.map(([k, v]) => `${k.trim()}${colors.blue}=${colors.reset}${formatValue(v, true)}`)
.map(([k, v]) => `${k}${colors.blue}=${colors.reset}${formatValue(v, true)}`)
.join(' ')
if (entries.length === 0) return `${colors.blue}[=]${colors.reset}`
if (entries.length === 0)
return `${colors.blue}[=]${colors.reset}`
return `${colors.blue}[${colors.reset}${entries}${colors.blue}]${colors.reset}`
}
case 'function': {
@ -228,6 +199,3 @@ export function formatValue(value: Value, inner = false): string {
return String(value)
}
}
// add types functions to top-level namespace
for (const [key, value] of Object.entries(types)) globals[key] = value

View File

@ -2,5 +2,6 @@ export const json = {
encode: (s: any) => JSON.stringify(s),
decode: (s: string) => JSON.parse(s),
}
;(json as any).parse = json.decode
;(json as any).stringify = json.encode
; (json as any).parse = json.decode
; (json as any).stringify = json.encode

View File

@ -36,8 +36,6 @@ export const list = {
// predicates
'empty?': (list: any[]) => list.length === 0,
'contains?': (list: any[], item: any) => list.includes(item),
'includes?': (list: any[], item: any) => list.includes(item),
'has?': (list: any[], item: any) => list.includes(item),
'any?': async (list: any[], cb: Function) => {
for (const value of list) {
if (await cb(value)) return true
@ -46,7 +44,7 @@ export const list = {
},
'all?': async (list: any[], cb: Function) => {
for (const value of list) {
if (!(await cb(value))) return false
if (!await cb(value)) return false
}
return true
},
@ -131,7 +129,7 @@ export const list = {
}
return [truthy, falsy]
},
compact: (list: any[]) => list.filter((x) => x != null),
compact: (list: any[]) => list.filter(x => x != null),
'group-by': async (list: any[], cb: Function) => {
const groups: Record<string, any[]> = {}
for (const value of list) {
@ -143,11 +141,12 @@ export const list = {
},
}
// raw functions deal directly in Value types, meaning we can modify collection
// careful - they MUST return a Value!
;(list.splice as any).raw = true
;(list.push as any).raw = true
;(list.pop as any).raw = true
;(list.shift as any).raw = true
;(list.unshift as any).raw = true
;(list.insert as any).raw = true
// raw functions deal directly in Value types, meaning we can modify collection
// careful - they MUST return a Value!
; (list.splice as any).raw = true
; (list.push as any).raw = true
; (list.pop as any).raw = true
; (list.shift as any).raw = true
; (list.unshift as any).raw = true
; (list.insert as any).raw = true

View File

@ -20,7 +20,8 @@ export const load = async function (this: VM, path: string): Promise<Record<stri
await this.continue()
const module: Record<string, Value> = {}
for (const [name, value] of this.scope.locals.entries()) module[name] = value
for (const [name, value] of this.scope.locals.entries())
module[name] = value
this.scope = scope
this.pc = pc

View File

@ -16,10 +16,7 @@ export const math = {
if (n < 0) throw new Error(`sqrt: cannot take square root of negative number ${n}`)
return Math.sqrt(n)
},
random: (min = 0, max = 1) => {
if (min === 0 && max === 1) return Math.random()
return Math.floor(Math.random() * (max - min + 1)) + min
},
random: () => Math.random(),
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)

View File

@ -17,33 +17,17 @@ export const str = {
'last-index-of': (str: string, search: string) => String(str ?? '').lastIndexOf(search),
// transformations
replace: (str: string, search: string, replacement: string) =>
String(str ?? '').replace(search, replacement),
'replace-all': (str: string, search: string, replacement: string) =>
String(str ?? '').replaceAll(search, replacement),
slice: (str: string, start: number, end?: number | null) =>
String(str ?? '').slice(start, end ?? undefined),
substring: (str: string, start: number, end?: number | null) =>
String(str ?? '').substring(start, end ?? undefined),
replace: (str: string, search: string, replacement: string) => String(str ?? '').replace(search, replacement),
'replace-all': (str: string, search: string, replacement: string) => String(str ?? '').replaceAll(search, replacement),
slice: (str: string, start: number, end?: number | null) => String(str ?? '').slice(start, end ?? undefined),
substring: (str: string, start: number, end?: number | null) => String(str ?? '').substring(start, end ?? undefined),
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 String(str ?? '').repeat(count)
},
'pad-start': (str: string, length: number, pad: string = ' ') =>
String(str ?? '').padStart(length, pad),
'pad-end': (str: string, length: number, pad: string = ' ') =>
String(str ?? '').padEnd(length, pad),
capitalize: (str: string) => {
const s = String(str ?? '')
return s.charAt(0).toUpperCase() + s.slice(1).toLowerCase()
},
titlecase: (s: string) => {
return String(s ?? '')
.split(' ')
.map(str.capitalize)
.join(' ')
},
'pad-start': (str: string, length: number, pad: string = ' ') => String(str ?? '').padStart(length, pad),
'pad-end': (str: string, length: number, pad: string = ' ') => String(str ?? '').padEnd(length, pad),
lines: (str: string) => String(str ?? '').split('\n'),
chars: (str: string) => String(str ?? '').split(''),

View File

@ -1,170 +0,0 @@
import { expect, describe, test } from 'bun:test'
describe('date', () => {
test('date.now returns current timestamp', () => {
expect(`date.now | number?`).toEvaluateTo(true)
expect(`(date.now) > 1577836800000`).toEvaluateTo(true)
})
test('date.new creates timestamp from components', () => {
expect(`
t = date.new 2024 0 1 12 0 0 500
[
(date.year t)
(date.month t)
(date.date t)
(date.hour t)
(date.minute t)
(date.second t)
(date.ms t)
]
`).toEvaluateTo([2024, 0, 1, 12, 0, 0, 500])
})
test('date.new with minimal arguments', () => {
expect(`
t = date.new 2024 5 15
[
(date.year t)
(date.month t)
(date.date t)
(date.hour t)
(date.minute t)
(date.second t)
(date.ms t)
]
`).toEvaluateTo([2024, 5, 15, 0, 0, 0, 0])
})
test('date.year extracts year', () => {
expect(`
t = date.new 2024 0 1
date.year t
`).toEvaluateTo(2024)
expect(`
t = date.new 1999 11 31
date.year t
`).toEvaluateTo(1999)
})
test('date.month extracts month (0-indexed)', () => {
// January = 0, December = 11
expect(`
jan = date.new 2024 0 1
dec = date.new 2024 11 31
[(date.month jan) (date.month dec)]
`).toEvaluateTo([0, 11])
})
test('date.date extracts day of month', () => {
expect(`
t = date.new 2024 5 15
date.date t
`).toEvaluateTo(15)
expect(`
date.new 2024 0 1 | date.date
`).toEvaluateTo(1)
})
test('date.hour extracts hour', () => {
expect(`
t = date.new 2024 0 1 14 30 45
date.hour t
`).toEvaluateTo(14)
expect(`
t = date.new 2024 0 1 0 0 0
date.hour t
`).toEvaluateTo(0)
})
test('date.minute extracts minute', () => {
expect(`
t = date.new 2024 0 1 14 30 45
date.minute t
`).toEvaluateTo(30)
})
test('date.second extracts second', () => {
expect(`
t = date.new 2024 0 1 14 30 45
date.second t
`).toEvaluateTo(45)
})
test('date.ms extracts milliseconds', () => {
expect(`
t = date.new 2024 0 1 14 30 45 250
date.ms t
`).toEvaluateTo(250)
})
test('round-trip: create and extract components', () => {
expect(`
t = date.new 2024 6 4 15 30 45 123
year = date.year t
month = date.month t
day = date.date t
hour = date.hour t
min = date.minute t
sec = date.second t
ms = date.ms t
[year month day hour min sec ms]
`).toEvaluateTo([2024, 6, 4, 15, 30, 45, 123])
})
test('edge cases - midnight', () => {
expect(`
t = date.new 2024 0 1 0 0 0 0
[
(date.hour t)
(date.minute t)
(date.second t)
(date.ms t)
]
`).toEvaluateTo([0, 0, 0, 0])
})
test('edge cases - end of day', () => {
expect(`
t = date.new 2024 0 1 23 59 59 999
[
(date.hour t)
(date.minute t)
(date.second t)
(date.ms t)
]
`).toEvaluateTo([23, 59, 59, 999])
})
test('edge cases - leap year', () => {
expect(`
t = date.new 2024 1 29
[
(date.year t)
(date.month t)
(date.date t)
]
`).toEvaluateTo([2024, 1, 29])
})
test('combining date functions with arithmetic', () => {
expect(`
t = date.new 2024 5 15 10 30 0
next-hour = date.new 2024 5 15 11 30 0
(date.hour next-hour) - (date.hour t)
`).toEvaluateTo(1)
})
test('using date.now in calculations', () => {
// Check that date.now is in the past compared to a future timestamp
expect(`
now = (date.now)
future = date.new 2030 0 1
future > now
`).toEvaluateTo(true)
})
})

View File

@ -314,16 +314,14 @@ describe('fs - other', () => {
writeFileSync(file, 'initial')
let called = false
const watcher = fs.watch(file, () => {
called = true
})
const watcher = fs.watch(file, () => { called = true })
// Trigger change
await new Promise((resolve) => setTimeout(resolve, 100))
await new Promise(resolve => setTimeout(resolve, 100))
writeFileSync(file, 'updated')
// Wait for watcher
await new Promise((resolve) => setTimeout(resolve, 500))
await new Promise(resolve => setTimeout(resolve, 500))
expect(called).toBe(true)
watcher.close?.()

View File

@ -1,93 +1,98 @@
import { expect, describe, test } from 'bun:test'
import { globals } from '#prelude'
describe('var and var?', () => {
test('var? checks if a variable exists', async () => {
await expect(`var? 'nada'`).toEvaluateTo(false)
await expect(`var? 'info'`).toEvaluateTo(false)
await expect(`abc = abc; var? 'abc'`).toEvaluateTo(true)
await expect(`var? 'var?'`).toEvaluateTo(true)
await expect(`var? 'nada'`).toEvaluateTo(false, globals)
await expect(`var? 'info'`).toEvaluateTo(false, globals)
await expect(`abc = abc; var? 'abc'`).toEvaluateTo(true, globals)
await expect(`var? 'var?'`).toEvaluateTo(true, globals)
await expect(`var? 'dict'`).toEvaluateTo(true)
await expect(`var? dict`).toEvaluateTo(true)
await expect(`var? 'dict'`).toEvaluateTo(true, globals)
await expect(`var? dict`).toEvaluateTo(true, globals)
})
test('var returns a value or null', async () => {
await expect(`var 'nada'`).toEvaluateTo(null)
await expect(`var nada`).toEvaluateTo(null)
await expect(`var 'info'`).toEvaluateTo(null)
await expect(`abc = my-string; var 'abc'`).toEvaluateTo('my-string')
await expect(`abc = my-string; var abc`).toEvaluateTo(null)
await expect(`var 'nada'`).toEvaluateTo(null, globals)
await expect(`var nada`).toEvaluateTo(null, globals)
await expect(`var 'info'`).toEvaluateTo(null, globals)
await expect(`abc = my-string; var 'abc'`).toEvaluateTo('my-string', globals)
await expect(`abc = my-string; var abc`).toEvaluateTo(null, globals)
})
})
describe('type predicates', () => {
test('string? checks for string type', async () => {
await expect(`string? 'hello'`).toEvaluateTo(true)
await expect(`string? 42`).toEvaluateTo(false)
await expect(`string? 'hello'`).toEvaluateTo(true, globals)
await expect(`string? 42`).toEvaluateTo(false, globals)
})
test('number? checks for number type', async () => {
await expect(`number? 42`).toEvaluateTo(true)
await expect(`number? 'hello'`).toEvaluateTo(false)
await expect(`number? 42`).toEvaluateTo(true, globals)
await expect(`number? 'hello'`).toEvaluateTo(false, globals)
})
test('boolean? checks for boolean type', async () => {
await expect(`boolean? true`).toEvaluateTo(true)
await expect(`boolean? 42`).toEvaluateTo(false)
await expect(`boolean? true`).toEvaluateTo(true, globals)
await expect(`boolean? 42`).toEvaluateTo(false, globals)
})
test('array? checks for array type', async () => {
await expect(`array? [1 2 3]`).toEvaluateTo(true)
await expect(`array? 42`).toEvaluateTo(false)
await expect(`array? [1 2 3]`).toEvaluateTo(true, globals)
await expect(`array? 42`).toEvaluateTo(false, globals)
})
test('dict? checks for dict type', async () => {
await expect(`dict? [a=1]`).toEvaluateTo(true)
await expect(`dict? []`).toEvaluateTo(false)
await expect(`dict? [a=1]`).toEvaluateTo(true, globals)
await expect(`dict? []`).toEvaluateTo(false, globals)
})
test('null? checks for null type', async () => {
await expect(`null? null`).toEvaluateTo(true)
await expect(`null? 42`).toEvaluateTo(false)
await expect(`null? null`).toEvaluateTo(true, globals)
await expect(`null? 42`).toEvaluateTo(false, globals)
})
test('some? checks for non-null', async () => {
await expect(`some? 42`).toEvaluateTo(true)
await expect(`some? null`).toEvaluateTo(false)
await expect(`some? 42`).toEvaluateTo(true, globals)
await expect(`some? null`).toEvaluateTo(false, globals)
})
})
describe('introspection', () => {
test('type returns proper types', async () => {
await expect(`type 'hello'`).toEvaluateTo('string')
await expect(`type 42`).toEvaluateTo('number')
await expect(`type true`).toEvaluateTo('boolean')
await expect(`type false`).toEvaluateTo('boolean')
await expect(`type null`).toEvaluateTo('null')
await expect(`type [1 2 3]`).toEvaluateTo('array')
await expect(`type [a=1 b=2]`).toEvaluateTo('dict')
await expect(`type 'hello'`).toEvaluateTo('string', globals)
await expect(`type 42`).toEvaluateTo('number', globals)
await expect(`type true`).toEvaluateTo('boolean', globals)
await expect(`type false`).toEvaluateTo('boolean', globals)
await expect(`type null`).toEvaluateTo('null', globals)
await expect(`type [1 2 3]`).toEvaluateTo('array', globals)
await expect(`type [a=1 b=2]`).toEvaluateTo('dict', globals)
})
test('inspect formats values', async () => {
await expect(`inspect 'hello'`).toEvaluateTo("\u001b[32m'hello\u001b[32m'\u001b[0m")
await expect(`inspect 'hello'`).toEvaluateTo("\u001b[32m'hello\u001b[32m'\u001b[0m", globals)
})
test('describe describes values', async () => {
await expect(`describe 'hello'`).toEvaluateTo("#<string: \u001b[32m'hello\u001b[32m'\u001b[0m>")
await expect(`describe 'hello'`).toEvaluateTo("#<string: \u001b[32m'hello\u001b[32m'\u001b[0m>", globals)
})
})
describe('environment', () => {
test('args is an array', async () => {
await expect(`array? $.args`).toEvaluateTo(true)
await expect(`array? args`).toEvaluateTo(true, globals)
})
test('args can be accessed', async () => {
await expect(`type $.args`).toEvaluateTo('array')
await expect(`type args`).toEvaluateTo('array', globals)
})
test('argv includes more than just the args', async () => {
await expect(`list.first $.argv | str.ends-with? 'shrimp.test.ts'`).toEvaluateTo(true)
test('args is just stuff passed to the script', async () => {
await expect(`list.empty? args`).toEvaluateTo(true)
})
test('argv is all the classic argv info (minus bun)', async () => {
await expect(`list.first argv | str.ends-with? 'shrimp.test.ts'`).toEvaluateTo(true)
})
})
@ -102,38 +107,3 @@ describe('ref', () => {
expect(`rnd = ref math.random; rnd | type`).toEvaluateTo('number')
expect(`rnd = ref math.random; ref rnd | type`).toEvaluateTo('native')
})
describe('$ global dictionary', () => {
test('$.args is an array', async () => {
await expect(`$.args | array?`).toEvaluateTo(true)
})
test('$.args can be accessed', async () => {
await expect(`$.args | type`).toEvaluateTo('array')
})
test('$.script.name is a string', async () => {
await expect(`$.script.name | string?`).toEvaluateTo(true)
})
test('$.script.path is a string', async () => {
await expect(`$.script.path | string?`).toEvaluateTo(true)
})
test('$.env is a dict', async () => {
await expect(`$.env | dict?`).toEvaluateTo(true)
})
test('$.pid is a number', async () => {
await expect(`$.pid | number?`).toEvaluateTo(true)
await expect(`$.pid > 0`).toEvaluateTo(true)
})
test('$.cwd is a string', async () => {
await expect(`$.cwd | string?`).toEvaluateTo(true)
})
test('$.cwd returns current working directory', async () => {
await expect(`$.cwd`).toEvaluateTo(process.cwd())
})
})

View File

@ -5,22 +5,14 @@ describe('json', () => {
expect(`json.decode '[1,2,3]'`).toEvaluateTo([1, 2, 3])
expect(`json.decode '"heya"'`).toEvaluateTo('heya')
expect(`json.decode '[true, false, null]'`).toEvaluateTo([true, false, null])
expect(`json.decode '{"a": true, "b": false, "c": "yeah"}'`).toEvaluateTo({
a: true,
b: false,
c: 'yeah',
})
expect(`json.decode '{"a": true, "b": false, "c": "yeah"}'`).toEvaluateTo({ a: true, b: false, c: "yeah" })
})
test('json.encode', () => {
expect(`json.encode [1 2 3]`).toEvaluateTo('[1,2,3]')
expect(`json.encode 'heya'`).toEvaluateTo('"heya"')
expect(`json.encode [true false null]`).toEvaluateTo('[true,false,null]')
expect(`json.encode [a=true b=false c='yeah'] | json.decode`).toEvaluateTo({
a: true,
b: false,
c: 'yeah',
})
expect(`json.encode [a=true b=false c='yeah'] | json.decode`).toEvaluateTo({ a: true, b: false, c: "yeah" })
})
test('edge cases - empty structures', () => {
@ -59,31 +51,27 @@ describe('json', () => {
})
test('nested structures - arrays', () => {
expect(`json.decode '[[1,2],[3,4],[5,6]]'`).toEvaluateTo([
[1, 2],
[3, 4],
[5, 6],
])
expect(`json.decode '[[1,2],[3,4],[5,6]]'`).toEvaluateTo([[1, 2], [3, 4], [5, 6]])
expect(`json.decode '[1,[2,[3,[4]]]]'`).toEvaluateTo([1, [2, [3, [4]]]])
})
test('nested structures - objects', () => {
expect(`json.decode '{"user":{"name":"Alice","age":30}}'`).toEvaluateTo({
user: { name: 'Alice', age: 30 },
user: { name: 'Alice', age: 30 }
})
expect(`json.decode '{"a":{"b":{"c":"deep"}}}'`).toEvaluateTo({
a: { b: { c: 'deep' } },
a: { b: { c: 'deep' } }
})
})
test('nested structures - mixed arrays and objects', () => {
expect(`json.decode '[{"id":1,"tags":["a","b"]},{"id":2,"tags":["c"]}]'`).toEvaluateTo([
{ id: 1, tags: ['a', 'b'] },
{ id: 2, tags: ['c'] },
{ id: 2, tags: ['c'] }
])
expect(`json.decode '{"items":[1,2,3],"meta":{"count":3}}'`).toEvaluateTo({
items: [1, 2, 3],
meta: { count: 3 },
meta: { count: 3 }
})
})

View File

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

View File

@ -1,4 +1,5 @@
import { expect, describe, test } from 'bun:test'
import { globals } from '#prelude'
describe('string operations', () => {
test('to-upper converts to uppercase', async () => {
@ -16,18 +17,6 @@ describe('string operations', () => {
await expect(`str.trim '\\n\\thello\\t\\n'`).toEvaluateTo('hello')
})
test('capitalize makes first char uppercase', async () => {
await expect(`str.capitalize 'hello'`).toEvaluateTo('Hello')
await expect(`str.capitalize 'HELLO'`).toEvaluateTo('Hello')
await expect(`str.capitalize 'hello world'`).toEvaluateTo('Hello world')
})
test('titlecase capitalizes each word', async () => {
await expect(`str.titlecase 'hello world'`).toEvaluateTo('Hello World')
await expect(`str.titlecase 'HELLO WORLD'`).toEvaluateTo('Hello World')
await expect(`str.titlecase 'the quick brown fox'`).toEvaluateTo('The Quick Brown Fox')
})
test('split divides string by separator', async () => {
await expect(`str.split 'a,b,c' ','`).toEvaluateTo(['a', 'b', 'c'])
await expect(`str.split 'hello' ''`).toEvaluateTo(['h', 'e', 'l', 'l', 'o'])
@ -117,17 +106,6 @@ describe('boolean logic', () => {
await expect(`not 42`).toEvaluateTo(false)
await expect(`not null`).toEvaluateTo(true)
})
test('not works with function calls', async () => {
await expect(`equals = do x y: x == y end; not equals 5 5`).toEvaluateTo(false)
await expect(`equals = do x y: x == y end; not equals 5 10`).toEvaluateTo(true)
})
test('not works with binary operations and comparisons', async () => {
await expect(`not 5 > 10`).toEvaluateTo(true)
await expect(`not 10 > 5`).toEvaluateTo(false)
await expect(`not true and false`).toEvaluateTo(true)
})
})
describe('utilities', () => {
@ -277,10 +255,7 @@ describe('collections', () => {
})
test('list.zip combines two arrays', async () => {
await expect(`list.zip [1 2] [3 4]`).toEvaluateTo([
[1, 3],
[2, 4],
])
await expect(`list.zip [1 2] [3 4]`).toEvaluateTo([[1, 3], [2, 4]])
})
test('list.first returns first element', async () => {
@ -450,10 +425,7 @@ describe('collections', () => {
await expect(`
gt-two = do x: x > 2 end
list.partition [1 2 3 4 5] gt-two
`).toEvaluateTo([
[3, 4, 5],
[1, 2],
])
`).toEvaluateTo([[3, 4, 5], [1, 2]])
})
test('list.compact removes null values', async () => {

View File

@ -1,143 +0,0 @@
import { expect, describe, test } from 'bun:test'
describe('type predicates', () => {
test('boolean? checks if value is boolean', async () => {
await expect(`boolean? true`).toEvaluateTo(true)
await expect(`boolean? false`).toEvaluateTo(true)
await expect(`boolean? 42`).toEvaluateTo(false)
await expect(`boolean? 'hello'`).toEvaluateTo(false)
await expect(`boolean? null`).toEvaluateTo(false)
await expect(`boolean? [1 2 3]`).toEvaluateTo(false)
})
test('number? checks if value is number', async () => {
await expect(`number? 42`).toEvaluateTo(true)
await expect(`number? 3.14`).toEvaluateTo(true)
await expect(`number? 0`).toEvaluateTo(true)
await expect(`number? -5`).toEvaluateTo(true)
await expect(`number? 'hello'`).toEvaluateTo(false)
await expect(`number? true`).toEvaluateTo(false)
await expect(`number? null`).toEvaluateTo(false)
})
test('string? checks if value is string', async () => {
await expect(`string? 'hello'`).toEvaluateTo(true)
await expect(`string? ''`).toEvaluateTo(true)
await expect(`string? world`).toEvaluateTo(true)
await expect(`string? 42`).toEvaluateTo(false)
await expect(`string? true`).toEvaluateTo(false)
await expect(`string? null`).toEvaluateTo(false)
await expect(`string? [1 2 3]`).toEvaluateTo(false)
})
test('array? checks if value is array', async () => {
await expect(`array? [1 2 3]`).toEvaluateTo(true)
await expect(`array? []`).toEvaluateTo(true)
await expect(`array? ['a' 'b']`).toEvaluateTo(true)
await expect(`array? [a=1 b=2]`).toEvaluateTo(false)
await expect(`array? 42`).toEvaluateTo(false)
await expect(`array? 'hello'`).toEvaluateTo(false)
await expect(`array? null`).toEvaluateTo(false)
})
test('list? is alias for array?', async () => {
await expect(`list? [1 2 3]`).toEvaluateTo(true)
await expect(`list? []`).toEvaluateTo(true)
await expect(`list? [a=1 b=2]`).toEvaluateTo(false)
})
test('dict? checks if value is dict', async () => {
await expect(`dict? [a=1 b=2]`).toEvaluateTo(true)
await expect(`dict? [=]`).toEvaluateTo(true)
await expect(`dict? [1 2 3]`).toEvaluateTo(false)
await expect(`dict? []`).toEvaluateTo(false)
await expect(`dict? 42`).toEvaluateTo(false)
await expect(`dict? 'hello'`).toEvaluateTo(false)
})
test('function? checks if value is function', async () => {
await expect(`
my-fn = do x: x * 2 end
function? my-fn
`).toEvaluateTo(true)
await expect(`function? inc`).toEvaluateTo(true)
await expect(`function? list.map`).toEvaluateTo(true)
await expect(`function? 42`).toEvaluateTo(false)
await expect(`function? 'hello'`).toEvaluateTo(false)
await expect(`function? [1 2 3]`).toEvaluateTo(false)
})
test('null? checks if value is null', async () => {
await expect(`null? null`).toEvaluateTo(true)
await expect(`null? 0`).toEvaluateTo(false)
await expect(`null? false`).toEvaluateTo(false)
await expect(`null? ''`).toEvaluateTo(false)
await expect(`null? []`).toEvaluateTo(false)
})
})
describe('type coercion', () => {
test('boolean coerces to boolean', async () => {
await expect(`boolean true`).toEvaluateTo(true)
await expect(`boolean false`).toEvaluateTo(false)
await expect(`boolean 1`).toEvaluateTo(true)
await expect(`boolean 0`).toEvaluateTo(false)
await expect(`boolean 'hello'`).toEvaluateTo(true)
await expect(`boolean ''`).toEvaluateTo(false)
await expect(`boolean null`).toEvaluateTo(false)
await expect(`boolean [1 2 3]`).toEvaluateTo(true)
})
test('number coerces to number', async () => {
await expect(`number 42`).toEvaluateTo(42)
await expect(`number '42'`).toEvaluateTo(42)
await expect(`number '3.14'`).toEvaluateTo(3.14)
await expect(`number true`).toEvaluateTo(1)
await expect(`number false`).toEvaluateTo(0)
})
test('string coerces to string', async () => {
await expect(`string 'hello'`).toEvaluateTo('hello')
await expect(`string 42`).toEvaluateTo('42')
await expect(`string true`).toEvaluateTo('true')
await expect(`string false`).toEvaluateTo('false')
await expect(`string null`).toEvaluateTo('null')
})
})
describe('type predicates in conditionals', () => {
test('using type predicates in if statements', async () => {
await expect(`
x = 42
if (number? x):
'is-num'
else:
'not-num'
end
`).toEvaluateTo('is-num')
})
test('filtering by type', async () => {
await expect(`
items = [1 'hello' 2 'world' 3]
list.filter items number?
`).toEvaluateTo([1, 2, 3])
})
test('filtering strings', async () => {
await expect(`
items = [1 'hello' 2 'world' 3]
list.filter items string?
`).toEvaluateTo(['hello', 'world'])
})
test('checking for functions', async () => {
await expect(`
double = do x: x * 2 end
not-fn = 42
is-fn = function? double
is-not-fn = function? not-fn
is-fn and (not is-not-fn)
`).toEvaluateTo(true)
})
})

View File

@ -1,21 +0,0 @@
import { toValue } from 'reefvm'
export const types = {
'boolean?': (v: any) => toValue(v).type === 'boolean',
boolean: (v: any) => Boolean(v),
'number?': (v: any) => toValue(v).type === 'number',
number: (v: any) => Number(v),
'string?': (v: any) => toValue(v).type === 'string',
string: (v: any) => String(v),
'array?': (v: any) => toValue(v).type === 'array',
'list?': (v: any) => toValue(v).type === 'array',
'dict?': (v: any) => toValue(v).type === 'dict',
'function?': (v: any) => ['function', 'native'].includes(toValue(v).type),
'null?': (v: any) => toValue(v).type === 'null',
}

10
src/server/app.tsx Normal file
View File

@ -0,0 +1,10 @@
import { Editor } from '#/editor/editor'
import { render } from 'hono/jsx/dom'
import './index.css'
const App = () => {
return <Editor />
}
const root = document.getElementById('root')!
render(<App />, root)

84
src/server/index.css Normal file
View File

@ -0,0 +1,84 @@
:root {
/* Background colors */
--bg-editor: #011627;
--bg-output: #40318D;
--bg-status-bar: #1E2A4A;
--bg-status-border: #0E1A3A;
--bg-selection: #1D3B53;
--bg-variable-def: #1E2A4A;
/* Text colors */
--text-editor: #D6DEEB;
--text-output: #7C70DA;
--text-status: #B3A9FF55;
--caret: #80A4C2;
/* Syntax highlighting colors */
--color-keyword: #C792EA;
--color-function: #82AAFF;
--color-string: #C3E88D;
--color-number: #F78C6C;
--color-bool: #FF5370;
--color-operator: #89DDFF;
--color-paren: #676E95;
--color-function-call: #FF9CAC;
--color-variable-def: #FFCB6B;
--color-error: #FF6E6E;
--color-regex: #E1ACFF;
/* ANSI terminal colors */
--ansi-black: #011627;
--ansi-red: #FF5370;
--ansi-green: #C3E88D;
--ansi-yellow: #FFCB6B;
--ansi-blue: #82AAFF;
--ansi-magenta: #C792EA;
--ansi-cyan: #89DDFF;
--ansi-white: #D6DEEB;
/* ANSI bright colors (slightly more vibrant) */
--ansi-bright-black: #676E95;
--ansi-bright-red: #FF6E90;
--ansi-bright-green: #D4F6A8;
--ansi-bright-yellow: #FFE082;
--ansi-bright-blue: #A8C7FA;
--ansi-bright-magenta: #E1ACFF;
--ansi-bright-cyan: #A8F5FF;
--ansi-bright-white: #FFFFFF;
}
@font-face {
font-family: 'C64ProMono';
src: url('../../assets/C64_Pro_Mono-STYLE.woff2') format('woff2');
font-weight: normal;
font-style: normal;
}
@font-face {
font-family: 'Pixeloid Mono';
src: url('../../assets/PixeloidMono.ttf') format('truetype');
font-weight: normal;
font-style: normal;
}
* {
margin: 0;
padding: 0;
box-sizing: border-box;
}
body {
background: var(--bg-output);
color: var(--text-output);
font-family: 'Pixeloid Mono', 'Courier New', monospace;
font-size: 18px;
height: 100vh;
overflow: hidden;
}
#root {
height: 100vh;
background: var(--bg-output);
display: flex;
flex-direction: column;
}

12
src/server/index.html Normal file
View File

@ -0,0 +1,12 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Shrimp</title>
</head>
<body>
<div id="root"></div>
<script type="module" src="./app.tsx"></script>
</body>
</html>

29
src/server/server.tsx Normal file
View File

@ -0,0 +1,29 @@
import index from './index.html'
const server = Bun.serve({
port: process.env.PORT ? Number(process.env.PORT) : 3001,
routes: {
'/*': index,
'/api/hello': {
async GET(req) {
return Response.json({
message: 'Hello, world!',
method: 'GET',
})
},
async PUT(req) {
return Response.json({
message: 'Hello, world!',
method: 'PUT',
})
},
},
},
development: process.env.NODE_ENV !== 'production' && {
hmr: true,
console: true,
},
})
console.log(`🚀 Server running at ${server.url}`)

View File

@ -1,14 +1,33 @@
import { expect } from 'bun:test'
import { diffLines } from 'diff'
import color from 'kleur'
import { Scanner, TokenType, type Token } from '#parser/tokenizer2'
import { parse, setGlobals } from '#parser/parser2'
import { Tree } from '#parser/node'
import { parser } from '#parser/shrimp'
import { setGlobals } from '#parser/tokenizer'
import { globals as prelude } from '#prelude'
import { $ } from 'bun'
import { assert, errorMessage } from '#utils/utils'
import { Compiler } from '#compiler/compiler'
import { run, VM } from 'reefvm'
import { treeToString2, VMResultToValue } from '#utils/tree'
import { treeToString, VMResultToValue } from '#utils/tree'
const regenerateParser = async () => {
let generate = true
try {
const grammarStat = await Bun.file('./src/parser/shrimp.grammar').stat()
const tokenizerStat = await Bun.file('./src/parser/tokenizer.ts').stat()
const parserStat = await Bun.file('./src/parser/shrimp.ts').stat()
if (grammarStat.mtime <= parserStat.mtime && tokenizerStat.mtime <= parserStat.mtime) {
generate = false
}
} catch (e) {
console.error('Error checking or regenerating parser:', e)
} finally {
if (generate) {
await $`bun generate-parser`
}
}
}
await regenerateParser()
// Type declaration for TypeScript
declare module 'bun:test' {
@ -18,9 +37,6 @@ declare module 'bun:test' {
toFailParse(): T
toEvaluateTo(expected: unknown, globals?: Record<string, any>): Promise<T>
toFailEvaluation(): Promise<T>
toBeToken(expected: string): T
toMatchToken(typeOrValue: string, value?: string): T
toMatchTokens(...tokens: { type: string; value?: string }[]): T
}
}
@ -30,8 +46,8 @@ expect.extend({
const allGlobals = { ...prelude, ...(globals || {}) }
setGlobals(Object.keys(allGlobals))
const tree = parse(received)
const actual = treeToString2(tree, received)
const tree = parser.parse(received)
const actual = treeToString(tree, received)
const normalizedExpected = trimWhitespace(expected)
try {
@ -50,8 +66,7 @@ expect.extend({
assert(typeof received === 'string', 'toFailParse can only be used with string values')
try {
const node = parse(received)
const tree = new Tree(node)
const tree = parser.parse(received)
let hasErrors = false
tree.iterate({
enter(n) {
@ -68,7 +83,7 @@ expect.extend({
pass: true,
}
} else {
const actual = treeToString2(node, received)
const actual = treeToString(tree, received)
return {
message: () => `Expected input to fail parsing, but it parsed successfully:\n${actual}`,
pass: false,
@ -129,109 +144,8 @@ expect.extend({
}
}
},
toBeToken(received: unknown, expected: string) {
assert(typeof received === 'string', 'toBeToken can only be used with string values')
try {
const tokens = tokenize(received)
const value = tokens[0] as Token
const target = TokenType[expected as keyof typeof TokenType]
if (!value) {
return {
message: () => `Expected token type to be ${expected}, but got ${value}`,
pass: false,
}
}
return {
message: () => `Expected token type to be ${expected}, but got ${TokenType[value.type]}`,
pass: value.type === target,
}
} catch (error) {
return {
message: () => `Tokenization failed: ${errorMessage(error)}`,
pass: false,
}
}
},
toMatchToken(received: unknown, typeOrValue: string, value?: string) {
assert(typeof received === 'string', 'toMatchToken can only be used with string values')
const expectedValue = value ? value : typeOrValue
const expectedType = value ? typeOrValue : undefined
try {
const tokens = tokenize(received)
const token = tokens[0] as Token
if (!token) {
return {
message: () =>
`Expected token to be ${expectedValue.replaceAll('\n', '\\n')}, got ${token}`,
pass: false,
}
}
if (expectedType && TokenType[expectedType as keyof typeof TokenType] !== token.type) {
return {
message: () => `Expected token to be ${expectedType}, but got ${TokenType[token.type]}`,
pass: false,
}
}
return {
message: () =>
`Expected token to be ${expectedValue.replaceAll('\n', '\\n')}, but got ${token.value}`,
pass: token.value === expectedValue,
}
} catch (error) {
return {
message: () => `Tokenization failed: ${errorMessage(error)} `,
pass: false,
}
}
},
toMatchTokens(received: unknown, ...tokens: { type: string; value?: string }[]) {
assert(typeof received === 'string', 'toMatchTokens can only be used with string values')
try {
const result = tokenize(received).map((t) => toHumanToken(t))
if (result.length === 0 && tokens.length > 0) {
return {
message: () => `Expected tokens ${JSON.stringify(tokens)}, got nothing`,
pass: false,
}
}
const expected = JSON.stringify(tokens, null, 2)
const actual = JSON.stringify(result, null, 2)
return {
message: () => `Tokens don't match: \n\n${diff(actual, expected)}`,
pass: expected == actual,
}
} catch (error) {
return {
message: () => `Tokenization failed: ${errorMessage(error)} `,
pass: false,
}
}
},
})
const tokenize = (code: string): Token[] => {
const scanner = new Scanner()
return scanner.tokenize(code)
}
const toHumanToken = (tok: Token): { type: string; value?: string } => {
return {
type: TokenType[tok.type],
value: tok.value,
}
}
const trimWhitespace = (str: string): string => {
const lines = str.split('\n').filter((line) => line.trim().length > 0)
const firstLine = lines[0]
@ -243,33 +157,10 @@ const trimWhitespace = (str: string): string => {
if (!line.startsWith(leadingWhitespace)) {
let foundWhitespace = line.match(/^(\s*)/)?.[1] || ''
throw new Error(
`Line has inconsistent leading whitespace: "${line}"(found "${foundWhitespace}", expected "${leadingWhitespace}")`,
`Line has inconsistent leading whitespace: "${line}" (found "${foundWhitespace}", expected "${leadingWhitespace}")`
)
}
return line.slice(leadingWhitespace.length)
})
.join('\n')
}
const diff = (a: string, b: string): string => {
const expected = a.trim()
const actual = b.trim()
const lines = []
if (expected !== actual) {
const changes = diffLines(actual, expected)
for (const part of changes) {
const sign = part.added ? '+' : part.removed ? '-' : ' '
let line = sign + part.value
if (part.added) {
line = color.green(line)
} else if (part.removed) {
line = color.red(line)
}
lines.push(line.endsWith('\n') || line.endsWith('\n\u001b[39m') ? line : line + '\n')
}
}
return lines.join('\n')
}

68
src/utils/signal.ts Normal file
View File

@ -0,0 +1,68 @@
/**
* How to use a Signal:
*
* Create a signal with primitives:
* const nameSignal = new Signal<string>()
* const countSignal = new Signal<number>()
*
* Create a signal with objects:
* const chatSignal = new Signal<{ username: string, message: string }>()
*
* Create a signal with no data (void):
* const clickSignal = new Signal<void>()
* const clickSignal2 = new Signal() // Defaults to void
*
* Connect to the signal:
* const disconnect = chatSignal.connect((data) => {
* const {username, message} = data;
* console.log(`${username} said "${message}"`);
* })
*
* Emit a signal:
* nameSignal.emit("Alice")
* countSignal.emit(42)
* chatSignal.emit({ username: "Chad", message: "Hey everyone, how's it going?" });
* clickSignal.emit() // No argument for void signals
*
* Forward a signal:
* const relaySignal = new Signal<{ username: string, message: string }>()
* const disconnectRelay = chatSignal.connect(relaySignal)
* // Now, when chatSignal emits, relaySignal will also emit the same data
*
* Disconnect a single listener:
* disconnect(); // The disconnect function is returned when you connect to a signal
*
* Disconnect all listeners:
* chatSignal.disconnect()
*/
export class Signal<T = void> {
private listeners: Array<(data: T) => void> = []
connect(listenerOrSignal: Signal<T> | ((data: T) => void)) {
let listener: (data: T) => void
// If it is a signal, forward the data to the signal
if (listenerOrSignal instanceof Signal) {
listener = (data: T) => listenerOrSignal.emit(data)
} else {
listener = listenerOrSignal
}
this.listeners.push(listener)
return () => {
this.listeners = this.listeners.filter((l) => l !== listener)
}
}
emit(data: T) {
for (const listener of this.listeners) {
listener(data)
}
}
disconnect() {
this.listeners = []
}
}

View File

@ -1,50 +1,5 @@
import { Tree, TreeCursor } from '@lezer/common'
import { type Value, fromValue } from 'reefvm'
import { SyntaxNode } from '#parser/node'
const nodeToString = (node: SyntaxNode, input: string, depth = 0): string => {
const indent = ' '.repeat(depth)
const text = input.slice(node.from, node.to)
const nodeName = node.name
if (node.firstChild) {
return `${indent}${nodeName}`
} else {
// Only strip quotes from whole String nodes (legacy DoubleQuote), not StringFragment/EscapeSeq/CurlyString
let cleanText = nodeName === 'String' ? text.slice(1, -1) : text
if (cleanText === ' ') cleanText = '(space)'
return cleanText ? `${indent}${nodeName} ${cleanText}` : `${indent}${nodeName}`
}
}
export const treeToString2 = (tree: SyntaxNode, input: string, depth = 0): string => {
let lines = []
let node: SyntaxNode | null = tree
if (node.name === 'Program') node = node.firstChild
while (node) {
// If this node is an error, print ⚠ instead of its content
if (node.isError && !node.firstChild) {
lines.push(' '.repeat(depth) + '⚠')
} else {
lines.push(nodeToString(node, input, depth))
if (node.firstChild) {
lines.push(treeToString2(node.firstChild, input, depth + 1))
}
// If this node has an error, add ⚠ after its children
if (node.isError && node.firstChild) {
lines.push(' '.repeat(depth === 0 ? 0 : depth + 1) + '⚠')
}
}
node = node.nextSibling
}
return lines.join('\n')
}
export const treeToString = (tree: Tree, input: string): string => {
const lines: string[] = []

View File

@ -136,12 +136,3 @@ export const asciiEscapeToHtml = (str: string): HtmlEscapedString => {
return result as HtmlEscapedString
}
export const isDebug = (): boolean => {
if (typeof process !== 'undefined' && process.env) {
return !!process.env.DEBUG
}
return false
}
export const runningInBrowser = typeof window !== 'undefined'

0
today.md Normal file
View File

4
vscode-extension/.gitignore vendored Normal file
View File

@ -0,0 +1,4 @@
node_modules
client/dist
server/dist
*.vsix

View File

@ -5,7 +5,10 @@
"name": "Run Extension",
"type": "extensionHost",
"request": "launch",
"args": ["--extensionDevelopmentPath=${workspaceFolder}", "--profile=Shrimp Dev"],
"args": [
"--extensionDevelopmentPath=${workspaceFolder}",
"--profile=Shrimp Dev"
],
"outFiles": [
"${workspaceFolder}/client/dist/**/*.js",
"${workspaceFolder}/server/dist/**/*.js"

View File

@ -22,7 +22,7 @@ export function activate(context: vscode.ExtensionContext) {
'shrimpLanguageServer',
'Shrimp Language Server',
serverOptions,
clientOptions,
clientOptions
)
client.start()
@ -46,7 +46,7 @@ export function activate(context: vscode.ExtensionContext) {
language: 'text',
})
await vscode.window.showTextDocument(doc, { preview: false })
}),
})
)
// Command: Show Bytecode
@ -67,7 +67,7 @@ export function activate(context: vscode.ExtensionContext) {
language: 'text',
})
await vscode.window.showTextDocument(doc, { preview: false })
}),
})
)
// Command: Run File
@ -93,7 +93,7 @@ export function activate(context: vscode.ExtensionContext) {
const terminal = vscode.window.createTerminal('Shrimp')
terminal.show()
terminal.sendText(`${binaryPath} "${filePath}"`)
}),
})
)
}

Some files were not shown because too many files have changed in this diff Show More