Compare commits
No commits in common. "main" and "parser2" have entirely different histories.
1
.gitignore
vendored
1
.gitignore
vendored
|
|
@ -34,7 +34,6 @@ report.[0-9]_.[0-9]_.[0-9]_.[0-9]_.json
|
||||||
.DS_Store
|
.DS_Store
|
||||||
|
|
||||||
/tmp
|
/tmp
|
||||||
vscode-extension/tmp
|
|
||||||
/docs
|
/docs
|
||||||
|
|
||||||
*.vsix
|
*.vsix
|
||||||
|
|
@ -42,7 +42,7 @@ function analyzeParser(filePath: string): Map<string, CallInfo> {
|
||||||
methods.set(currentMethod, {
|
methods.set(currentMethod, {
|
||||||
method: currentMethod,
|
method: currentMethod,
|
||||||
line: i + 1,
|
line: i + 1,
|
||||||
calls: new Set(),
|
calls: new Set()
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -85,7 +85,7 @@ function buildTree(
|
||||||
indent = '',
|
indent = '',
|
||||||
isLast = true,
|
isLast = true,
|
||||||
depth = 0,
|
depth = 0,
|
||||||
maxDepth = 3,
|
maxDepth = 3
|
||||||
): string[] {
|
): string[] {
|
||||||
const lines: string[] = []
|
const lines: string[] = []
|
||||||
const info = callGraph.get(method)
|
const info = callGraph.get(method)
|
||||||
|
|
@ -93,7 +93,7 @@ function buildTree(
|
||||||
if (!info) return lines
|
if (!info) return lines
|
||||||
|
|
||||||
// Add current method
|
// Add current method
|
||||||
const prefix = depth === 0 ? '' : isLast ? '└─> ' : '├─> '
|
const prefix = depth === 0 ? '' : (isLast ? '└─> ' : '├─> ')
|
||||||
const suffix = info.isRecursive ? ' (recursive)' : ''
|
const suffix = info.isRecursive ? ' (recursive)' : ''
|
||||||
const lineNum = `[line ${info.line}]`
|
const lineNum = `[line ${info.line}]`
|
||||||
lines.push(`${indent}${prefix}${method}() ${lineNum}${suffix}`)
|
lines.push(`${indent}${prefix}${method}() ${lineNum}${suffix}`)
|
||||||
|
|
@ -116,9 +116,9 @@ function buildTree(
|
||||||
|
|
||||||
// Get sorted unique calls (filter out recursive self-calls for display)
|
// Get sorted unique calls (filter out recursive self-calls for display)
|
||||||
const calls = Array.from(info.calls)
|
const calls = Array.from(info.calls)
|
||||||
.filter((c) => callGraph.has(c)) // Only show parser methods
|
.filter(c => callGraph.has(c)) // Only show parser methods
|
||||||
.filter((c) => c !== method) // Don't show immediate self-recursion
|
.filter(c => c !== method) // Don't show immediate self-recursion
|
||||||
.filter((c) => !helperPatterns.test(c)) // Filter out helpers
|
.filter(c => !helperPatterns.test(c)) // Filter out helpers
|
||||||
.sort()
|
.sort()
|
||||||
|
|
||||||
// Add children
|
// Add children
|
||||||
|
|
@ -131,7 +131,7 @@ function buildTree(
|
||||||
newIndent,
|
newIndent,
|
||||||
idx === calls.length - 1,
|
idx === calls.length - 1,
|
||||||
depth + 1,
|
depth + 1,
|
||||||
maxDepth,
|
maxDepth
|
||||||
)
|
)
|
||||||
lines.push(...childLines)
|
lines.push(...childLines)
|
||||||
})
|
})
|
||||||
|
|
@ -163,11 +163,11 @@ console.log(` Entry point: parse()`)
|
||||||
// Find methods that are never called (potential dead code or entry points)
|
// Find methods that are never called (potential dead code or entry points)
|
||||||
const allCalled = new Set<string>()
|
const allCalled = new Set<string>()
|
||||||
for (const info of callGraph.values()) {
|
for (const info of callGraph.values()) {
|
||||||
info.calls.forEach((c) => allCalled.add(c))
|
info.calls.forEach(c => allCalled.add(c))
|
||||||
}
|
}
|
||||||
|
|
||||||
const uncalled = Array.from(callGraph.keys())
|
const uncalled = Array.from(callGraph.keys())
|
||||||
.filter((m) => !allCalled.has(m) && m !== 'parse')
|
.filter(m => !allCalled.has(m) && m !== 'parse')
|
||||||
.sort()
|
.sort()
|
||||||
|
|
||||||
if (uncalled.length > 0) {
|
if (uncalled.length > 0) {
|
||||||
|
|
|
||||||
|
|
@ -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
|
|
||||||
|
|
@ -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
|
|
||||||
|
|
@ -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
|
|
||||||
11
package.json
11
package.json
|
|
@ -1,16 +1,13 @@
|
||||||
{
|
{
|
||||||
"name": "shrimp",
|
"name": "shrimp",
|
||||||
"version": "0.1.0",
|
"version": "0.1.0",
|
||||||
"exports": {
|
"exports": "./src/index.ts",
|
||||||
".": "./src/index.ts",
|
|
||||||
"./editor": "./src/editor/index.ts",
|
|
||||||
"./editor.css": "./src/editor/editor.css"
|
|
||||||
},
|
|
||||||
"private": true,
|
"private": true,
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"editor": "bun --hot src/editor/example/server.tsx",
|
"dev": "bun generate-parser && bun --hot src/server/server.tsx",
|
||||||
"repl": "bun bin/repl",
|
"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",
|
"update-reef": "rm -rf ~/.bun/install/cache/ && rm bun.lock && bun update reefvm",
|
||||||
"cli:install": "ln -s \"$(pwd)/bin/shrimp\" ~/.bun/bin/shrimp",
|
"cli:install": "ln -s \"$(pwd)/bin/shrimp\" ~/.bun/bin/shrimp",
|
||||||
"cli:remove": "rm ~/.bun/bin/shrimp",
|
"cli:remove": "rm ~/.bun/bin/shrimp",
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,9 @@
|
||||||
import { CompilerError } from '#compiler/compilerError.ts'
|
import { CompilerError } from '#compiler/compilerError.ts'
|
||||||
import { parse, setGlobals } from '#parser/parser2'
|
import { parse } from '#parser/parser2'
|
||||||
import { SyntaxNode, Tree } from '#parser/node'
|
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 { tokenizeCurlyString } from '#parser/curlyTokenizer'
|
||||||
import { assert, errorMessage } from '#utils/utils'
|
import { assert, errorMessage } from '#utils/utils'
|
||||||
import { toBytecode, type Bytecode, type ProgramItem, bytecodeToString } from 'reefvm'
|
import { toBytecode, type Bytecode, type ProgramItem, bytecodeToString } from 'reefvm'
|
||||||
|
|
@ -57,12 +60,8 @@ export class Compiler {
|
||||||
loopLabelCount = 0
|
loopLabelCount = 0
|
||||||
bytecode: Bytecode
|
bytecode: Bytecode
|
||||||
pipeCounter = 0
|
pipeCounter = 0
|
||||||
pipeVarStack: string[] = [] // Stack of pipe variable names for nested pipes
|
|
||||||
|
|
||||||
constructor(
|
constructor(public input: string, globals?: string[] | Record<string, any>) {
|
||||||
public input: string,
|
|
||||||
globals?: string[] | Record<string, any>,
|
|
||||||
) {
|
|
||||||
try {
|
try {
|
||||||
if (globals) setGlobals(Array.isArray(globals) ? globals : Object.keys(globals))
|
if (globals) setGlobals(Array.isArray(globals) ? globals : Object.keys(globals))
|
||||||
const ast = parse(input)
|
const ast = parse(input)
|
||||||
|
|
@ -92,7 +91,7 @@ export class Compiler {
|
||||||
}
|
}
|
||||||
|
|
||||||
#compileCst(cst: Tree, input: string) {
|
#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}`)
|
assert(isProgram, `Expected Program node, got ${cst.topNode.type.name}`)
|
||||||
|
|
||||||
let child = cst.topNode.firstChild
|
let child = cst.topNode.firstChild
|
||||||
|
|
@ -108,20 +107,14 @@ export class Compiler {
|
||||||
const value = input.slice(node.from, node.to)
|
const value = input.slice(node.from, node.to)
|
||||||
if (DEBUG) console.log(`🫦 ${node.name}: ${value}`)
|
if (DEBUG) console.log(`🫦 ${node.name}: ${value}`)
|
||||||
|
|
||||||
switch (node.type.name) {
|
switch (node.type.id) {
|
||||||
case 'Number':
|
case terms.Number:
|
||||||
// Handle sign prefix for hex, binary, and octal literals
|
// Handle sign prefix for hex, binary, and octal literals
|
||||||
// Number() doesn't parse '-0xFF', '+0xFF', '-0o77', etc. correctly
|
// Number() doesn't parse '-0xFF', '+0xFF', '-0o77', etc. correctly
|
||||||
let numberValue: number
|
let numberValue: number
|
||||||
if (
|
if (value.startsWith('-') && (value.includes('0x') || value.includes('0b') || value.includes('0o'))) {
|
||||||
value.startsWith('-') &&
|
|
||||||
(value.includes('0x') || value.includes('0b') || value.includes('0o'))
|
|
||||||
) {
|
|
||||||
numberValue = -Number(value.slice(1))
|
numberValue = -Number(value.slice(1))
|
||||||
} else if (
|
} else if (value.startsWith('+') && (value.includes('0x') || value.includes('0b') || value.includes('0o'))) {
|
||||||
value.startsWith('+') &&
|
|
||||||
(value.includes('0x') || value.includes('0b') || value.includes('0o'))
|
|
||||||
) {
|
|
||||||
numberValue = Number(value.slice(1))
|
numberValue = Number(value.slice(1))
|
||||||
} else {
|
} else {
|
||||||
numberValue = Number(value)
|
numberValue = Number(value)
|
||||||
|
|
@ -132,8 +125,9 @@ export class Compiler {
|
||||||
|
|
||||||
return [[`PUSH`, numberValue]]
|
return [[`PUSH`, numberValue]]
|
||||||
|
|
||||||
case 'String': {
|
case terms.String: {
|
||||||
if (node.firstChild?.type.is('CurlyString')) return this.#compileCurlyString(value, input)
|
if (node.firstChild?.type.id === terms.CurlyString)
|
||||||
|
return this.#compileCurlyString(value, input)
|
||||||
|
|
||||||
const { parts, hasInterpolation } = getStringParts(node, input)
|
const { parts, hasInterpolation } = getStringParts(node, input)
|
||||||
|
|
||||||
|
|
@ -149,19 +143,19 @@ export class Compiler {
|
||||||
parts.forEach((part) => {
|
parts.forEach((part) => {
|
||||||
const partValue = input.slice(part.from, part.to)
|
const partValue = input.slice(part.from, part.to)
|
||||||
|
|
||||||
switch (part.type.name) {
|
switch (part.type.id) {
|
||||||
case 'StringFragment':
|
case terms.StringFragment:
|
||||||
// Plain text fragment - just push as-is
|
// Plain text fragment - just push as-is
|
||||||
instructions.push(['PUSH', partValue])
|
instructions.push(['PUSH', partValue])
|
||||||
break
|
break
|
||||||
|
|
||||||
case 'EscapeSeq':
|
case terms.EscapeSeq:
|
||||||
// Process escape sequence and push the result
|
// Process escape sequence and push the result
|
||||||
const processed = processEscapeSeq(partValue)
|
const processed = processEscapeSeq(partValue)
|
||||||
instructions.push(['PUSH', processed])
|
instructions.push(['PUSH', processed])
|
||||||
break
|
break
|
||||||
|
|
||||||
case 'Interpolation':
|
case terms.Interpolation:
|
||||||
// Interpolation contains either Identifier or ParenExpr (the $ is anonymous)
|
// Interpolation contains either Identifier or ParenExpr (the $ is anonymous)
|
||||||
const child = part.firstChild
|
const child = part.firstChild
|
||||||
if (!child) {
|
if (!child) {
|
||||||
|
|
@ -175,7 +169,7 @@ export class Compiler {
|
||||||
throw new CompilerError(
|
throw new CompilerError(
|
||||||
`Unexpected string part: ${part.type.name}`,
|
`Unexpected string part: ${part.type.name}`,
|
||||||
part.from,
|
part.from,
|
||||||
part.to,
|
part.to
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
@ -185,15 +179,15 @@ export class Compiler {
|
||||||
return instructions
|
return instructions
|
||||||
}
|
}
|
||||||
|
|
||||||
case 'Boolean': {
|
case terms.Boolean: {
|
||||||
return [[`PUSH`, value === 'true']]
|
return [[`PUSH`, value === 'true']]
|
||||||
}
|
}
|
||||||
|
|
||||||
case 'Null': {
|
case terms.Null: {
|
||||||
return [[`PUSH`, null]]
|
return [[`PUSH`, null]]
|
||||||
}
|
}
|
||||||
|
|
||||||
case 'Regex': {
|
case terms.Regex: {
|
||||||
// remove the surrounding slashes and any flags
|
// remove the surrounding slashes and any flags
|
||||||
const [_, pattern, flags] = value.match(/^\/\/(.*)\/\/([gimsuy]*)$/) || []
|
const [_, pattern, flags] = value.match(/^\/\/(.*)\/\/([gimsuy]*)$/) || []
|
||||||
if (!pattern) {
|
if (!pattern) {
|
||||||
|
|
@ -210,15 +204,15 @@ export class Compiler {
|
||||||
return [['PUSH', regex]]
|
return [['PUSH', regex]]
|
||||||
}
|
}
|
||||||
|
|
||||||
case 'Identifier': {
|
case terms.Identifier: {
|
||||||
return [[`TRY_LOAD`, value]]
|
return [[`TRY_LOAD`, value]]
|
||||||
}
|
}
|
||||||
|
|
||||||
case 'Word': {
|
case terms.Word: {
|
||||||
return [['PUSH', value]]
|
return [['PUSH', value]]
|
||||||
}
|
}
|
||||||
|
|
||||||
case 'DotGet': {
|
case terms.DotGet: {
|
||||||
// DotGet is parsed into a nested tree because it's hard to parse it into a flat one.
|
// 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.
|
// However, we want a flat tree - so we're going to pretend like we are getting one from the parser.
|
||||||
//
|
//
|
||||||
|
|
@ -230,7 +224,7 @@ export class Compiler {
|
||||||
instructions.push(['TRY_LOAD', objectName])
|
instructions.push(['TRY_LOAD', objectName])
|
||||||
|
|
||||||
const flattenProperty = (prop: SyntaxNode): void => {
|
const flattenProperty = (prop: SyntaxNode): void => {
|
||||||
if (prop.type.is('DotGet')) {
|
if (prop.type.id === terms.DotGet) {
|
||||||
const nestedParts = getDotGetParts(prop, input)
|
const nestedParts = getDotGetParts(prop, input)
|
||||||
|
|
||||||
const nestedObjectValue = input.slice(nestedParts.object.from, nestedParts.object.to)
|
const nestedObjectValue = input.slice(nestedParts.object.from, nestedParts.object.to)
|
||||||
|
|
@ -239,7 +233,7 @@ export class Compiler {
|
||||||
|
|
||||||
flattenProperty(nestedParts.property)
|
flattenProperty(nestedParts.property)
|
||||||
} else {
|
} else {
|
||||||
if (prop.type.is('ParenExpr')) {
|
if (prop.type.id === terms.ParenExpr) {
|
||||||
instructions.push(...this.#compileNode(prop, input))
|
instructions.push(...this.#compileNode(prop, input))
|
||||||
} else {
|
} else {
|
||||||
const propertyValue = input.slice(prop.from, prop.to)
|
const propertyValue = input.slice(prop.from, prop.to)
|
||||||
|
|
@ -253,7 +247,7 @@ export class Compiler {
|
||||||
return instructions
|
return instructions
|
||||||
}
|
}
|
||||||
|
|
||||||
case 'BinOp': {
|
case terms.BinOp: {
|
||||||
const { left, op, right } = getBinaryParts(node)
|
const { left, op, right } = getBinaryParts(node)
|
||||||
const instructions: ProgramItem[] = []
|
const instructions: ProgramItem[] = []
|
||||||
instructions.push(...this.#compileNode(left, input))
|
instructions.push(...this.#compileNode(left, input))
|
||||||
|
|
@ -301,7 +295,7 @@ export class Compiler {
|
||||||
return instructions
|
return instructions
|
||||||
}
|
}
|
||||||
|
|
||||||
case 'Assign': {
|
case terms.Assign: {
|
||||||
const assignParts = getAssignmentParts(node)
|
const assignParts = getAssignmentParts(node)
|
||||||
const instructions: ProgramItem[] = []
|
const instructions: ProgramItem[] = []
|
||||||
|
|
||||||
|
|
@ -332,7 +326,7 @@ export class Compiler {
|
||||||
return instructions
|
return instructions
|
||||||
}
|
}
|
||||||
|
|
||||||
case 'CompoundAssign': {
|
case terms.CompoundAssign: {
|
||||||
const { identifier, operator, right } = getCompoundAssignmentParts(node)
|
const { identifier, operator, right } = getCompoundAssignmentParts(node)
|
||||||
const identifierName = input.slice(identifier.from, identifier.to)
|
const identifierName = input.slice(identifier.from, identifier.to)
|
||||||
const instructions: ProgramItem[] = []
|
const instructions: ProgramItem[] = []
|
||||||
|
|
@ -383,7 +377,7 @@ export class Compiler {
|
||||||
throw new CompilerError(
|
throw new CompilerError(
|
||||||
`Unknown compound operator: ${opValue}`,
|
`Unknown compound operator: ${opValue}`,
|
||||||
operator.from,
|
operator.from,
|
||||||
operator.to,
|
operator.to
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -394,14 +388,14 @@ export class Compiler {
|
||||||
return instructions
|
return instructions
|
||||||
}
|
}
|
||||||
|
|
||||||
case 'ParenExpr': {
|
case terms.ParenExpr: {
|
||||||
const child = node.firstChild
|
const child = node.firstChild
|
||||||
if (!child) return [] // I guess it is empty parentheses?
|
if (!child) return [] // I guess it is empty parentheses?
|
||||||
|
|
||||||
return this.#compileNode(child, input)
|
return this.#compileNode(child, input)
|
||||||
}
|
}
|
||||||
|
|
||||||
case 'FunctionDef': {
|
case terms.FunctionDef: {
|
||||||
const { paramNames, bodyNodes, catchVariable, catchBody, finallyBody } =
|
const { paramNames, bodyNodes, catchVariable, catchBody, finallyBody } =
|
||||||
getFunctionDefParts(node, input)
|
getFunctionDefParts(node, input)
|
||||||
const instructions: ProgramItem[] = []
|
const instructions: ProgramItem[] = []
|
||||||
|
|
@ -431,8 +425,8 @@ export class Compiler {
|
||||||
catchVariable,
|
catchVariable,
|
||||||
catchBody,
|
catchBody,
|
||||||
finallyBody,
|
finallyBody,
|
||||||
input,
|
input
|
||||||
),
|
)
|
||||||
)
|
)
|
||||||
} else {
|
} else {
|
||||||
instructions.push(...compileFunctionBody())
|
instructions.push(...compileFunctionBody())
|
||||||
|
|
@ -447,8 +441,8 @@ export class Compiler {
|
||||||
return instructions
|
return instructions
|
||||||
}
|
}
|
||||||
|
|
||||||
case 'FunctionCallOrIdentifier': {
|
case terms.FunctionCallOrIdentifier: {
|
||||||
if (node.firstChild?.type.is('DotGet')) {
|
if (node.firstChild?.type.id === terms.DotGet) {
|
||||||
const instructions: ProgramItem[] = []
|
const instructions: ProgramItem[] = []
|
||||||
const callLabel: Label = `.call_dotget_${++this.labelCount}`
|
const callLabel: Label = `.call_dotget_${++this.labelCount}`
|
||||||
const afterLabel: Label = `.after_dotget_${++this.labelCount}`
|
const afterLabel: Label = `.after_dotget_${++this.labelCount}`
|
||||||
|
|
@ -490,8 +484,8 @@ export class Compiler {
|
||||||
PUSH 1 ; Named count
|
PUSH 1 ; Named count
|
||||||
CALL
|
CALL
|
||||||
*/
|
*/
|
||||||
|
case terms.FunctionCallWithNewlines:
|
||||||
case 'FunctionCall': {
|
case terms.FunctionCall: {
|
||||||
const { identifierNode, namedArgs, positionalArgs } = getFunctionCallParts(node, input)
|
const { identifierNode, namedArgs, positionalArgs } = getFunctionCallParts(node, input)
|
||||||
const instructions: ProgramItem[] = []
|
const instructions: ProgramItem[] = []
|
||||||
instructions.push(...this.#compileNode(identifierNode, input))
|
instructions.push(...this.#compileNode(identifierNode, input))
|
||||||
|
|
@ -513,7 +507,7 @@ export class Compiler {
|
||||||
return instructions
|
return instructions
|
||||||
}
|
}
|
||||||
|
|
||||||
case 'Block': {
|
case terms.Block: {
|
||||||
const children = getAllChildren(node)
|
const children = getAllChildren(node)
|
||||||
const instructions: ProgramItem[] = []
|
const instructions: ProgramItem[] = []
|
||||||
|
|
||||||
|
|
@ -528,7 +522,7 @@ export class Compiler {
|
||||||
return instructions
|
return instructions
|
||||||
}
|
}
|
||||||
|
|
||||||
case 'FunctionCallWithBlock': {
|
case terms.FunctionCallWithBlock: {
|
||||||
const [fn, _colon, ...block] = getAllChildren(node)
|
const [fn, _colon, ...block] = getAllChildren(node)
|
||||||
let instructions: ProgramItem[] = []
|
let instructions: ProgramItem[] = []
|
||||||
|
|
||||||
|
|
@ -541,18 +535,18 @@ export class Compiler {
|
||||||
...block
|
...block
|
||||||
.filter((x) => x.type.name !== 'keyword')
|
.filter((x) => x.type.name !== 'keyword')
|
||||||
.map((x) => this.#compileNode(x!, input))
|
.map((x) => this.#compileNode(x!, input))
|
||||||
.flat(),
|
.flat()
|
||||||
)
|
)
|
||||||
instructions.push(['RETURN'])
|
instructions.push(['RETURN'])
|
||||||
instructions.push([`${afterLabel}:`])
|
instructions.push([`${afterLabel}:`])
|
||||||
|
|
||||||
if (fn?.type.is('FunctionCallOrIdentifier')) {
|
if (fn?.type.id === terms.FunctionCallOrIdentifier) {
|
||||||
instructions.push(['LOAD', input.slice(fn!.from, fn!.to)])
|
instructions.push(['LOAD', input.slice(fn!.from, fn!.to)])
|
||||||
instructions.push(['MAKE_FUNCTION', [], fnLabel])
|
instructions.push(['MAKE_FUNCTION', [], fnLabel])
|
||||||
instructions.push(['PUSH', 1])
|
instructions.push(['PUSH', 1])
|
||||||
instructions.push(['PUSH', 0])
|
instructions.push(['PUSH', 0])
|
||||||
instructions.push(['CALL'])
|
instructions.push(['CALL'])
|
||||||
} else if (fn?.type.is('FunctionCall')) {
|
} else if (fn?.type.id === terms.FunctionCall) {
|
||||||
let body = this.#compileNode(fn!, input)
|
let body = this.#compileNode(fn!, input)
|
||||||
const namedArgCount = (body[body.length - 2]![1] as number) * 2
|
const namedArgCount = (body[body.length - 2]![1] as number) * 2
|
||||||
const startSlice = body.length - namedArgCount - 3
|
const startSlice = body.length - namedArgCount - 3
|
||||||
|
|
@ -568,14 +562,14 @@ export class Compiler {
|
||||||
instructions.push(...body)
|
instructions.push(...body)
|
||||||
} else {
|
} else {
|
||||||
throw new Error(
|
throw new Error(
|
||||||
`FunctionCallWithBlock: Expected FunctionCallOrIdentifier or FunctionCall`,
|
`FunctionCallWithBlock: Expected FunctionCallOrIdentifier or FunctionCall`
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
return instructions
|
return instructions
|
||||||
}
|
}
|
||||||
|
|
||||||
case 'TryExpr': {
|
case terms.TryExpr: {
|
||||||
const { tryBlock, catchVariable, catchBody, finallyBody } = getTryExprParts(node, input)
|
const { tryBlock, catchVariable, catchBody, finallyBody } = getTryExprParts(node, input)
|
||||||
|
|
||||||
return this.#compileTryCatchFinally(
|
return this.#compileTryCatchFinally(
|
||||||
|
|
@ -583,34 +577,32 @@ export class Compiler {
|
||||||
catchVariable,
|
catchVariable,
|
||||||
catchBody,
|
catchBody,
|
||||||
finallyBody,
|
finallyBody,
|
||||||
input,
|
input
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
case 'Throw':
|
case terms.Throw: {
|
||||||
case 'Not': {
|
|
||||||
const keyword = node.type.is('Throw') ? 'Throw' : 'Not'
|
|
||||||
const children = getAllChildren(node)
|
const children = getAllChildren(node)
|
||||||
const [_throwKeyword, expression] = children
|
const [_throwKeyword, expression] = children
|
||||||
if (!expression) {
|
if (!expression) {
|
||||||
throw new CompilerError(
|
throw new CompilerError(
|
||||||
`${keyword} expected expression, got ${children.length} children`,
|
`Throw expected expression, got ${children.length} children`,
|
||||||
node.from,
|
node.from,
|
||||||
node.to,
|
node.to
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
const instructions: ProgramItem[] = []
|
const instructions: ProgramItem[] = []
|
||||||
instructions.push(...this.#compileNode(expression, input))
|
instructions.push(...this.#compileNode(expression, input))
|
||||||
instructions.push([keyword.toUpperCase()]) // THROW or NOT
|
instructions.push(['THROW'])
|
||||||
|
|
||||||
return instructions
|
return instructions
|
||||||
}
|
}
|
||||||
|
|
||||||
case 'IfExpr': {
|
case terms.IfExpr: {
|
||||||
const { conditionNode, thenBlock, elseIfBlocks, elseThenBlock } = getIfExprParts(
|
const { conditionNode, thenBlock, elseIfBlocks, elseThenBlock } = getIfExprParts(
|
||||||
node,
|
node,
|
||||||
input,
|
input
|
||||||
)
|
)
|
||||||
const instructions: ProgramItem[] = []
|
const instructions: ProgramItem[] = []
|
||||||
instructions.push(...this.#compileNode(conditionNode, input))
|
instructions.push(...this.#compileNode(conditionNode, input))
|
||||||
|
|
@ -650,7 +642,7 @@ export class Compiler {
|
||||||
}
|
}
|
||||||
|
|
||||||
// - `EQ`, `NEQ`, `LT`, `GT`, `LTE`, `GTE` - Pop 2, push boolean
|
// - `EQ`, `NEQ`, `LT`, `GT`, `LTE`, `GTE` - Pop 2, push boolean
|
||||||
case 'ConditionalOp': {
|
case terms.ConditionalOp: {
|
||||||
const instructions: ProgramItem[] = []
|
const instructions: ProgramItem[] = []
|
||||||
const { left, op, right } = getBinaryParts(node)
|
const { left, op, right } = getBinaryParts(node)
|
||||||
const leftInstructions: ProgramItem[] = this.#compileNode(left, input)
|
const leftInstructions: ProgramItem[] = this.#compileNode(left, input)
|
||||||
|
|
@ -725,7 +717,7 @@ export class Compiler {
|
||||||
return instructions
|
return instructions
|
||||||
}
|
}
|
||||||
|
|
||||||
case 'PipeExpr': {
|
case terms.PipeExpr: {
|
||||||
const { pipedFunctionCall, pipeReceivers } = getPipeExprParts(node)
|
const { pipedFunctionCall, pipeReceivers } = getPipeExprParts(node)
|
||||||
if (!pipedFunctionCall || pipeReceivers.length === 0) {
|
if (!pipedFunctionCall || pipeReceivers.length === 0) {
|
||||||
throw new CompilerError('PipeExpr must have at least two operands', node.from, node.to)
|
throw new CompilerError('PipeExpr must have at least two operands', node.from, node.to)
|
||||||
|
|
@ -734,77 +726,67 @@ export class Compiler {
|
||||||
const instructions: ProgramItem[] = []
|
const instructions: ProgramItem[] = []
|
||||||
instructions.push(...this.#compileNode(pipedFunctionCall, input))
|
instructions.push(...this.#compileNode(pipedFunctionCall, input))
|
||||||
|
|
||||||
// Use a unique variable name for this pipe level to handle nested pipes correctly
|
|
||||||
this.pipeCounter++
|
this.pipeCounter++
|
||||||
const pipeVarName = `_pipe_${this.pipeCounter}`
|
const pipeValName = `_pipe_value_${this.pipeCounter}`
|
||||||
this.pipeVarStack.push(pipeVarName)
|
|
||||||
|
|
||||||
pipeReceivers.forEach((pipeReceiver) => {
|
pipeReceivers.forEach((pipeReceiver) => {
|
||||||
// Store the piped value in the current pipe's variable
|
instructions.push(['STORE', pipeValName])
|
||||||
// Also store as `_` for direct access in simple cases
|
|
||||||
instructions.push(['DUP'])
|
|
||||||
instructions.push(['STORE', pipeVarName])
|
|
||||||
instructions.push(['STORE', '_'])
|
|
||||||
|
|
||||||
const isFunctionCall =
|
|
||||||
pipeReceiver.type.is('FunctionCall') || pipeReceiver.type.is('FunctionCallOrIdentifier')
|
|
||||||
|
|
||||||
if (isFunctionCall) {
|
|
||||||
// Function call receiver: check for explicit _ usage to determine arg handling
|
|
||||||
const { identifierNode, namedArgs, positionalArgs } = getFunctionCallParts(
|
const { identifierNode, namedArgs, positionalArgs } = getFunctionCallParts(
|
||||||
pipeReceiver,
|
pipeReceiver,
|
||||||
input,
|
input
|
||||||
)
|
)
|
||||||
|
|
||||||
instructions.push(...this.#compileNode(identifierNode, input))
|
instructions.push(...this.#compileNode(identifierNode, input))
|
||||||
|
|
||||||
const isUnderscoreInPositionalArgs = positionalArgs.some((arg) =>
|
const isUnderscoreInPositionalArgs = positionalArgs.some(
|
||||||
arg.type.is('Underscore'),
|
(arg) => arg.type.id === terms.Underscore
|
||||||
)
|
)
|
||||||
const isUnderscoreInNamedArgs = namedArgs.some((arg) => {
|
const isUnderscoreInNamedArgs = namedArgs.some((arg) => {
|
||||||
const { valueNode } = getNamedArgParts(arg, input)
|
const { valueNode } = getNamedArgParts(arg, input)
|
||||||
return valueNode.type.is('Underscore')
|
return valueNode.type.id === terms.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 no underscore is explicitly used, add the piped value as the first positional arg
|
||||||
if (shouldPushPositionalArg) {
|
if (shouldPushPositionalArg) {
|
||||||
instructions.push(['LOAD', pipeVarName])
|
instructions.push(['LOAD', pipeValName])
|
||||||
}
|
}
|
||||||
|
|
||||||
positionalArgs.forEach((arg) => {
|
positionalArgs.forEach((arg) => {
|
||||||
|
if (arg.type.id === terms.Underscore) {
|
||||||
|
instructions.push(['LOAD', pipeValName])
|
||||||
|
} else {
|
||||||
instructions.push(...this.#compileNode(arg, input))
|
instructions.push(...this.#compileNode(arg, input))
|
||||||
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
namedArgs.forEach((arg) => {
|
namedArgs.forEach((arg) => {
|
||||||
const { name, valueNode } = getNamedArgParts(arg, input)
|
const { name, valueNode } = getNamedArgParts(arg, input)
|
||||||
instructions.push(['PUSH', name])
|
instructions.push(['PUSH', name])
|
||||||
|
if (valueNode.type.id === terms.Underscore) {
|
||||||
|
instructions.push(['LOAD', pipeValName])
|
||||||
|
} else {
|
||||||
instructions.push(...this.#compileNode(valueNode, input))
|
instructions.push(...this.#compileNode(valueNode, input))
|
||||||
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
instructions.push(['PUSH', positionalArgs.length + (shouldPushPositionalArg ? 1 : 0)])
|
instructions.push(['PUSH', positionalArgs.length + (shouldPushPositionalArg ? 1 : 0)])
|
||||||
instructions.push(['PUSH', namedArgs.length])
|
instructions.push(['PUSH', namedArgs.length])
|
||||||
instructions.push(['CALL'])
|
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))
|
|
||||||
}
|
|
||||||
})
|
})
|
||||||
|
|
||||||
this.pipeVarStack.pop()
|
|
||||||
return instructions
|
return instructions
|
||||||
}
|
}
|
||||||
|
|
||||||
case 'Array': {
|
case terms.Array: {
|
||||||
const children = getAllChildren(node)
|
const children = getAllChildren(node)
|
||||||
|
|
||||||
// We can easily parse [=] as an empty dict, but `[ = ]` is tougher.
|
// 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
|
// = can be a valid word, and is also valid inside words, so for now we cheat
|
||||||
// and check for arrays that look like `[ = ]` to interpret them as
|
// and check for arrays that look like `[ = ]` to interpret them as
|
||||||
// empty dicts
|
// empty dicts
|
||||||
if (children.length === 1 && children[0]!.type.is('Word')) {
|
if (children.length === 1 && children[0]!.type.id === terms.Word) {
|
||||||
const child = children[0]!
|
const child = children[0]!
|
||||||
if (input.slice(child.from, child.to) === '=') {
|
if (input.slice(child.from, child.to) === '=') {
|
||||||
return [['MAKE_DICT', 0]]
|
return [['MAKE_DICT', 0]]
|
||||||
|
|
@ -816,7 +798,7 @@ export class Compiler {
|
||||||
return instructions
|
return instructions
|
||||||
}
|
}
|
||||||
|
|
||||||
case 'Dict': {
|
case terms.Dict: {
|
||||||
const children = getAllChildren(node)
|
const children = getAllChildren(node)
|
||||||
const instructions: ProgramItem[] = []
|
const instructions: ProgramItem[] = []
|
||||||
|
|
||||||
|
|
@ -835,7 +817,7 @@ export class Compiler {
|
||||||
return instructions
|
return instructions
|
||||||
}
|
}
|
||||||
|
|
||||||
case 'WhileExpr': {
|
case terms.WhileExpr: {
|
||||||
const [_while, test, _colon, block] = getAllChildren(node)
|
const [_while, test, _colon, block] = getAllChildren(node)
|
||||||
const instructions: ProgramItem[] = []
|
const instructions: ProgramItem[] = []
|
||||||
|
|
||||||
|
|
@ -853,15 +835,17 @@ export class Compiler {
|
||||||
return instructions
|
return instructions
|
||||||
}
|
}
|
||||||
|
|
||||||
case 'Import': {
|
case terms.Import: {
|
||||||
const instructions: ProgramItem[] = []
|
const instructions: ProgramItem[] = []
|
||||||
const [_import, ...nodes] = getAllChildren(node)
|
const [_import, ...nodes] = getAllChildren(node)
|
||||||
const args = nodes.filter((node) => node.type.is('Identifier'))
|
const args = nodes.filter(node => node.type.id === terms.Identifier)
|
||||||
const namedArgs = nodes.filter((node) => node.type.is('NamedArg'))
|
const namedArgs = nodes.filter(node => node.type.id === terms.NamedArg)
|
||||||
|
|
||||||
instructions.push(['LOAD', 'import'])
|
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) => {
|
namedArgs.forEach((arg) => {
|
||||||
const { name, valueNode } = getNamedArgParts(arg, input)
|
const { name, valueNode } = getNamedArgParts(arg, input)
|
||||||
|
|
@ -876,22 +860,15 @@ export class Compiler {
|
||||||
return instructions
|
return instructions
|
||||||
}
|
}
|
||||||
|
|
||||||
case 'Comment': {
|
case terms.Comment: {
|
||||||
return [] // ignore comments
|
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:
|
default:
|
||||||
throw new CompilerError(
|
throw new CompilerError(
|
||||||
`Compiler doesn't know how to handle a "${node.type.name}" node.`,
|
`Compiler doesn't know how to handle a "${node.type.name}" (${node.type.id}) node.`,
|
||||||
node.from,
|
node.from,
|
||||||
node.to,
|
node.to
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -901,7 +878,7 @@ export class Compiler {
|
||||||
catchVariable: string | undefined,
|
catchVariable: string | undefined,
|
||||||
catchBody: SyntaxNode | undefined,
|
catchBody: SyntaxNode | undefined,
|
||||||
finallyBody: SyntaxNode | undefined,
|
finallyBody: SyntaxNode | undefined,
|
||||||
input: string,
|
input: string
|
||||||
): ProgramItem[] {
|
): ProgramItem[] {
|
||||||
const instructions: ProgramItem[] = []
|
const instructions: ProgramItem[] = []
|
||||||
this.tryLabelCount++
|
this.tryLabelCount++
|
||||||
|
|
|
||||||
|
|
@ -1,9 +1,5 @@
|
||||||
export class CompilerError extends Error {
|
export class CompilerError extends Error {
|
||||||
constructor(
|
constructor(message: string, private from: number, private to: number) {
|
||||||
message: string,
|
|
||||||
private from: number,
|
|
||||||
private to: number,
|
|
||||||
) {
|
|
||||||
super(message)
|
super(message)
|
||||||
|
|
||||||
if (from < 0 || to < 0 || to < from) {
|
if (from < 0 || to < 0 || to < from) {
|
||||||
|
|
|
||||||
|
|
@ -112,12 +112,8 @@ describe('compiler', () => {
|
||||||
test('function call with no args', () => {
|
test('function call with no args', () => {
|
||||||
expect(`bloop = do: 'bleep' end; bloop`).toEvaluateTo('bleep')
|
expect(`bloop = do: 'bleep' end; bloop`).toEvaluateTo('bleep')
|
||||||
expect(`bloop = [ go=do: 'bleep' end ]; bloop.go`).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(
|
expect(`bloop = [ go=do: 'bleep' end ]; abc = do x: x end; abc (bloop.go)`).toEvaluateTo('bleep')
|
||||||
'bleep',
|
expect(`num = ((math.random) * 10 + 1) | math.floor; num >= 1 and num <= 10 `).toEvaluateTo(true)
|
||||||
)
|
|
||||||
expect(`num = ((math.random) * 10 + 1) | math.floor; num >= 1 and num <= 10 `).toEvaluateTo(
|
|
||||||
true,
|
|
||||||
)
|
|
||||||
})
|
})
|
||||||
|
|
||||||
test('function call with if statement and multiple expressions', () => {
|
test('function call with if statement and multiple expressions', () => {
|
||||||
|
|
@ -380,7 +376,7 @@ describe('default params', () => {
|
||||||
age: 60,
|
age: 60,
|
||||||
})
|
})
|
||||||
expect(
|
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 })
|
).toEvaluateTo({ name: 'Jon', age: 21 })
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
@ -412,9 +408,7 @@ describe('Nullish coalescing operator (??)', () => {
|
||||||
})
|
})
|
||||||
|
|
||||||
test('short-circuits evaluation', () => {
|
test('short-circuits evaluation', () => {
|
||||||
const throwError = () => {
|
const throwError = () => { throw new Error('Should not evaluate') }
|
||||||
throw new Error('Should not evaluate')
|
|
||||||
}
|
|
||||||
expect('5 ?? throw-error').toEvaluateTo(5, { 'throw-error': throwError })
|
expect('5 ?? throw-error').toEvaluateTo(5, { 'throw-error': throwError })
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|
@ -430,7 +424,7 @@ describe('Nullish coalescing operator (??)', () => {
|
||||||
// Use explicit call syntax to invoke the function
|
// Use explicit call syntax to invoke the function
|
||||||
expect('(get-value) ?? (get-default)').toEvaluateTo(42, {
|
expect('(get-value) ?? (get-default)').toEvaluateTo(42, {
|
||||||
'get-value': getValue,
|
'get-value': getValue,
|
||||||
'get-default': getDefault,
|
'get-default': getDefault
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
@ -462,9 +456,7 @@ describe('Nullish coalescing assignment (??=)', () => {
|
||||||
})
|
})
|
||||||
|
|
||||||
test('short-circuits evaluation when not null', () => {
|
test('short-circuits evaluation when not null', () => {
|
||||||
const throwError = () => {
|
const throwError = () => { throw new Error('Should not evaluate') }
|
||||||
throw new Error('Should not evaluate')
|
|
||||||
}
|
|
||||||
expect('x = 5; x ??= throw-error; x').toEvaluateTo(5, { 'throw-error': throwError })
|
expect('x = 5; x ??= throw-error; x').toEvaluateTo(5, { 'throw-error': throwError })
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -10,16 +10,12 @@ describe('single line function blocks', () => {
|
||||||
})
|
})
|
||||||
|
|
||||||
test('work with named args', () => {
|
test('work with named args', () => {
|
||||||
expect(
|
expect(`attach = do signal fn: [ signal (fn) ] end; attach signal='exit': true end`).toEvaluateTo(['exit', true])
|
||||||
`attach = do signal fn: [ signal (fn) ] end; attach signal='exit': true end`,
|
|
||||||
).toEvaluateTo(['exit', true])
|
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|
||||||
test('work with dot-get', () => {
|
test('work with dot-get', () => {
|
||||||
expect(`signals = [trap=do x y: [x (y)] end]; signals.trap 'EXIT': true end`).toEvaluateTo([
|
expect(`signals = [trap=do x y: [x (y)] end]; signals.trap 'EXIT': true end`).toEvaluateTo(['EXIT', true])
|
||||||
'EXIT',
|
|
||||||
true,
|
|
||||||
])
|
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|
@ -48,6 +44,7 @@ attach signal='exit':
|
||||||
end`).toEvaluateTo(['exit', true])
|
end`).toEvaluateTo(['exit', true])
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|
||||||
test('work with dot-get', () => {
|
test('work with dot-get', () => {
|
||||||
expect(`
|
expect(`
|
||||||
signals = [trap=do x y: [x (y)] end]
|
signals = [trap=do x y: [x (y)] end]
|
||||||
|
|
|
||||||
|
|
@ -219,7 +219,7 @@ describe('dict literals', () => {
|
||||||
|
|
||||||
describe('curly strings', () => {
|
describe('curly strings', () => {
|
||||||
test('work on one line', () => {
|
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', () => {
|
test('work on multiple lines', () => {
|
||||||
|
|
@ -227,7 +227,7 @@ describe('curly strings', () => {
|
||||||
one
|
one
|
||||||
two
|
two
|
||||||
three
|
three
|
||||||
}`).toEvaluateTo('\n one\n two\n three\n ')
|
}`).toEvaluateTo("\n one\n two\n three\n ")
|
||||||
})
|
})
|
||||||
|
|
||||||
test('can contain other curlies', () => {
|
test('can contain other curlies', () => {
|
||||||
|
|
@ -235,7 +235,7 @@ describe('curly strings', () => {
|
||||||
{ one }
|
{ one }
|
||||||
two
|
two
|
||||||
{ three }
|
{ three }
|
||||||
}`).toEvaluateTo('\n { one }\n two\n { three }\n ')
|
}`).toEvaluateTo("\n { one }\n two\n { three }\n ")
|
||||||
})
|
})
|
||||||
|
|
||||||
test('interpolates variables', () => {
|
test('interpolates variables', () => {
|
||||||
|
|
@ -258,12 +258,12 @@ describe('curly strings', () => {
|
||||||
test('interpolation edge cases', () => {
|
test('interpolation edge cases', () => {
|
||||||
expect(`{[a=1 b=2 c={wild}]}`).toEvaluateTo(`[a=1 b=2 c={wild}]`)
|
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(`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', () => {
|
describe('double quoted strings', () => {
|
||||||
test('work', () => {
|
test("work", () => {
|
||||||
expect(`"hello world"`).toEvaluateTo('hello world')
|
expect(`"hello world"`).toEvaluateTo('hello world')
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|
@ -272,11 +272,11 @@ describe('double quoted strings', () => {
|
||||||
expect(`"hello $(1 + 2)"`).toEvaluateTo('hello $(1 + 2)')
|
expect(`"hello $(1 + 2)"`).toEvaluateTo('hello $(1 + 2)')
|
||||||
})
|
})
|
||||||
|
|
||||||
test('equal regular strings', () => {
|
test("equal regular strings", () => {
|
||||||
expect(`"hello world" == 'hello world'`).toEvaluateTo(true)
|
expect(`"hello world" == 'hello world'`).toEvaluateTo(true)
|
||||||
})
|
})
|
||||||
|
|
||||||
test('can contain newlines', () => {
|
test("can contain newlines", () => {
|
||||||
expect(`
|
expect(`
|
||||||
"hello
|
"hello
|
||||||
world"`).toEvaluateTo('hello\n world')
|
world"`).toEvaluateTo('hello\n world')
|
||||||
|
|
|
||||||
|
|
@ -40,7 +40,7 @@ describe('Native Function Exceptions', () => {
|
||||||
const vm = new VM(compiler.bytecode)
|
const vm = new VM(compiler.bytecode)
|
||||||
|
|
||||||
vm.set('async-fail', async () => {
|
vm.set('async-fail', async () => {
|
||||||
await new Promise((resolve) => setTimeout(resolve, 1))
|
await new Promise(resolve => setTimeout(resolve, 1))
|
||||||
throw new Error('async error')
|
throw new Error('async error')
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|
@ -237,7 +237,7 @@ describe('Native Function Exceptions', () => {
|
||||||
const result = await vm.run()
|
const result = await vm.run()
|
||||||
expect(result).toEqual({
|
expect(result).toEqual({
|
||||||
type: 'string',
|
type: 'string',
|
||||||
value: 'This is a very specific error message with details',
|
value: 'This is a very specific error message with details'
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -117,42 +117,4 @@ describe('pipe expressions', () => {
|
||||||
test('dict literals can be piped', () => {
|
test('dict literals can be piped', () => {
|
||||||
expect(`[a=1 b=2 c=3] | dict.values | list.sort | str.join '-'`).toEvaluateTo('1-2-3')
|
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)
|
|
||||||
})
|
|
||||||
})
|
})
|
||||||
|
|
|
||||||
|
|
@ -5,7 +5,7 @@ const buffer: string[] = []
|
||||||
const ribbitGlobals = {
|
const ribbitGlobals = {
|
||||||
ribbit: async (cb: Function) => {
|
ribbit: async (cb: Function) => {
|
||||||
await cb()
|
await cb()
|
||||||
return buffer.join('\n')
|
return buffer.join("\n")
|
||||||
},
|
},
|
||||||
tag: async (tagFn: Function, atDefaults = {}) => {
|
tag: async (tagFn: Function, atDefaults = {}) => {
|
||||||
return (atNamed = {}, ...args: any[]) => tagFn(Object.assign({}, atDefaults, atNamed), ...args)
|
return (atNamed = {}, ...args: any[]) => tagFn(Object.assign({}, atDefaults, atNamed), ...args)
|
||||||
|
|
@ -20,12 +20,10 @@ const ribbitGlobals = {
|
||||||
ul: (atNamed: {}, ...args: any[]) => tag('ul', atNamed, ...args),
|
ul: (atNamed: {}, ...args: any[]) => tag('ul', atNamed, ...args),
|
||||||
li: (atNamed: {}, ...args: any[]) => tag('li', atNamed, ...args),
|
li: (atNamed: {}, ...args: any[]) => tag('li', atNamed, ...args),
|
||||||
nospace: () => NOSPACE_TOKEN,
|
nospace: () => NOSPACE_TOKEN,
|
||||||
echo: (...args: any[]) => console.log(...args),
|
echo: (...args: any[]) => console.log(...args)
|
||||||
}
|
}
|
||||||
|
|
||||||
function raw(fn: Function) {
|
function raw(fn: Function) { (fn as any).raw = true }
|
||||||
;(fn as any).raw = true
|
|
||||||
}
|
|
||||||
|
|
||||||
const tagBlock = async (tagName: string, props = {}, fn: Function) => {
|
const tagBlock = async (tagName: string, props = {}, fn: Function) => {
|
||||||
const attrs = Object.entries(props).map(([key, value]) => `${key}="${value}"`)
|
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 space = attrs.length ? ' ' : ''
|
||||||
const children = args
|
const children = args
|
||||||
.reverse()
|
.reverse()
|
||||||
.map((a) => (a === TAG_TOKEN ? buffer.pop() : a))
|
.map(a => a === TAG_TOKEN ? buffer.pop() : a)
|
||||||
.reverse()
|
.reverse().join(' ')
|
||||||
.join(' ')
|
|
||||||
.replaceAll(` ${NOSPACE_TOKEN} `, '')
|
.replaceAll(` ${NOSPACE_TOKEN} `, '')
|
||||||
|
|
||||||
if (SELF_CLOSING.includes(tagName)) buffer.push(`<${tagName}${space}${attrs.join(' ')} />`)
|
if (SELF_CLOSING.includes(tagName))
|
||||||
else buffer.push(`<${tagName}${space}${attrs.join(' ')}>${children}</${tagName}>`)
|
buffer.push(`<${tagName}${space}${attrs.join(' ')} />`)
|
||||||
|
else
|
||||||
|
buffer.push(`<${tagName}${space}${attrs.join(' ')}>${children}</${tagName}>`)
|
||||||
}
|
}
|
||||||
|
|
||||||
const tag = async (tagName: string, atNamed = {}, ...args: any[]) => {
|
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 NOSPACE_TOKEN = '!!ribbit-nospace!!'
|
||||||
const TAG_TOKEN = '!!ribbit-tag!!'
|
const TAG_TOKEN = '!!ribbit-tag!!'
|
||||||
const SELF_CLOSING = [
|
const SELF_CLOSING = ["area", "base", "br", "col", "embed", "hr", "img", "input", "link", "meta", "param", "source", "track", "wbr"]
|
||||||
'area',
|
|
||||||
'base',
|
|
||||||
'br',
|
|
||||||
'col',
|
|
||||||
'embed',
|
|
||||||
'hr',
|
|
||||||
'img',
|
|
||||||
'input',
|
|
||||||
'link',
|
|
||||||
'meta',
|
|
||||||
'param',
|
|
||||||
'source',
|
|
||||||
'track',
|
|
||||||
'wbr',
|
|
||||||
]
|
|
||||||
|
|
||||||
describe('ribbit', () => {
|
describe('ribbit', () => {
|
||||||
beforeEach(() => (buffer.length = 0))
|
beforeEach(() => buffer.length = 0)
|
||||||
|
|
||||||
test('head tag', () => {
|
test('head tag', () => {
|
||||||
expect(`
|
expect(`
|
||||||
|
|
@ -90,14 +74,11 @@ ribbit:
|
||||||
meta name=viewport content='width=device-width, initial-scale=1, viewport-fit=cover'
|
meta name=viewport content='width=device-width, initial-scale=1, viewport-fit=cover'
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
`).toEvaluateTo(
|
`).toEvaluateTo(`<head>
|
||||||
`<head>
|
|
||||||
<title>What up</title>
|
<title>What up</title>
|
||||||
<meta charset="UTF-8" />
|
<meta charset="UTF-8" />
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover" />
|
<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover" />
|
||||||
</head>`,
|
</head>`, ribbitGlobals)
|
||||||
ribbitGlobals,
|
|
||||||
)
|
|
||||||
})
|
})
|
||||||
|
|
||||||
test('custom tags', () => {
|
test('custom tags', () => {
|
||||||
|
|
@ -109,14 +90,11 @@ ribbit:
|
||||||
li two
|
li two
|
||||||
li three
|
li three
|
||||||
end
|
end
|
||||||
end`).toEvaluateTo(
|
end`).toEvaluateTo(`<ul class="list">
|
||||||
`<ul class="list">
|
|
||||||
<li border-bottom="1px solid black">one</li>
|
<li border-bottom="1px solid black">one</li>
|
||||||
<li>two</li>
|
<li>two</li>
|
||||||
<li>three</li>
|
<li>three</li>
|
||||||
</ul>`,
|
</ul>`, ribbitGlobals)
|
||||||
ribbitGlobals,
|
|
||||||
)
|
|
||||||
})
|
})
|
||||||
|
|
||||||
test('inline expressions', () => {
|
test('inline expressions', () => {
|
||||||
|
|
@ -132,8 +110,6 @@ end`).toEvaluateTo(
|
||||||
<h1 class="bright" style="font-family: helvetica">Heya</h1>
|
<h1 class="bright" style="font-family: helvetica">Heya</h1>
|
||||||
<h2>man that is <b>wild</b>!</h2>
|
<h2>man that is <b>wild</b>!</h2>
|
||||||
<p>Double the fun.</p>
|
<p>Double the fun.</p>
|
||||||
</p>`,
|
</p>`, ribbitGlobals)
|
||||||
ribbitGlobals,
|
|
||||||
)
|
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
@ -10,7 +10,8 @@ describe('while', () => {
|
||||||
a = false
|
a = false
|
||||||
b = done
|
b = done
|
||||||
end
|
end
|
||||||
b`).toEvaluateTo('done')
|
b`)
|
||||||
|
.toEvaluateTo('done')
|
||||||
})
|
})
|
||||||
|
|
||||||
test('basic expression', () => {
|
test('basic expression', () => {
|
||||||
|
|
@ -19,7 +20,8 @@ describe('while', () => {
|
||||||
while a < 10:
|
while a < 10:
|
||||||
a += 1
|
a += 1
|
||||||
end
|
end
|
||||||
a`).toEvaluateTo(10)
|
a`)
|
||||||
|
.toEvaluateTo(10)
|
||||||
})
|
})
|
||||||
|
|
||||||
test('compound expression', () => {
|
test('compound expression', () => {
|
||||||
|
|
@ -29,7 +31,8 @@ describe('while', () => {
|
||||||
while a > 0 and b < 100:
|
while a > 0 and b < 100:
|
||||||
b += 1
|
b += 1
|
||||||
end
|
end
|
||||||
b`).toEvaluateTo(100)
|
b`)
|
||||||
|
.toEvaluateTo(100)
|
||||||
})
|
})
|
||||||
|
|
||||||
test('returns value', () => {
|
test('returns value', () => {
|
||||||
|
|
@ -39,6 +42,7 @@ describe('while', () => {
|
||||||
a += 1
|
a += 1
|
||||||
done
|
done
|
||||||
end
|
end
|
||||||
ret`).toEvaluateTo('done')
|
ret`)
|
||||||
|
.toEvaluateTo('done')
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
@ -1,4 +1,5 @@
|
||||||
import { CompilerError } from '#compiler/compilerError.ts'
|
import { CompilerError } from '#compiler/compilerError.ts'
|
||||||
|
import * as terms from '#parser/shrimp.terms'
|
||||||
import type { SyntaxNode, Tree } from '#parser/node'
|
import type { SyntaxNode, Tree } from '#parser/node'
|
||||||
|
|
||||||
export const checkTreeForErrors = (tree: Tree): CompilerError[] => {
|
export const checkTreeForErrors = (tree: Tree): CompilerError[] => {
|
||||||
|
|
@ -23,7 +24,7 @@ export const getAllChildren = (node: SyntaxNode): SyntaxNode[] => {
|
||||||
child = child.nextSibling
|
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) => {
|
export const getBinaryParts = (node: SyntaxNode) => {
|
||||||
|
|
@ -45,23 +46,22 @@ export const getAssignmentParts = (node: SyntaxNode) => {
|
||||||
throw new CompilerError(
|
throw new CompilerError(
|
||||||
`Assign expected 3 children, got ${children.length}`,
|
`Assign expected 3 children, got ${children.length}`,
|
||||||
node.from,
|
node.from,
|
||||||
node.to,
|
node.to
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
// array destructuring
|
// array destructuring
|
||||||
if (left && left.type.is('Array')) {
|
if (left && left.type.id === terms.Array) {
|
||||||
const identifiers = getAllChildren(left).filter((child) => child.type.is('Identifier'))
|
const identifiers = getAllChildren(left).filter((child) => child.type.id === terms.Identifier)
|
||||||
return { arrayPattern: identifiers, right }
|
return { arrayPattern: identifiers, right }
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!left || !left.type.is('AssignableIdentifier')) {
|
if (!left || left.type.id !== terms.AssignableIdentifier) {
|
||||||
throw new CompilerError(
|
throw new CompilerError(
|
||||||
`Assign left child must be an AssignableIdentifier or Array, got ${
|
`Assign left child must be an AssignableIdentifier or Array, got ${left ? left.type.name : 'none'
|
||||||
left ? left.type.name : 'none'
|
|
||||||
}`,
|
}`,
|
||||||
node.from,
|
node.from,
|
||||||
node.to,
|
node.to
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -72,19 +72,18 @@ export const getCompoundAssignmentParts = (node: SyntaxNode) => {
|
||||||
const children = getAllChildren(node)
|
const children = getAllChildren(node)
|
||||||
const [left, operator, right] = children
|
const [left, operator, right] = children
|
||||||
|
|
||||||
if (!left || !left.type.is('AssignableIdentifier')) {
|
if (!left || left.type.id !== terms.AssignableIdentifier) {
|
||||||
throw new CompilerError(
|
throw new CompilerError(
|
||||||
`CompoundAssign left child must be an AssignableIdentifier, got ${
|
`CompoundAssign left child must be an AssignableIdentifier, got ${left ? left.type.name : 'none'
|
||||||
left ? left.type.name : 'none'
|
|
||||||
}`,
|
}`,
|
||||||
node.from,
|
node.from,
|
||||||
node.to,
|
node.to
|
||||||
)
|
)
|
||||||
} else if (!operator || !right) {
|
} else if (!operator || !right) {
|
||||||
throw new CompilerError(
|
throw new CompilerError(
|
||||||
`CompoundAssign expected 3 children, got ${children.length}`,
|
`CompoundAssign expected 3 children, got ${children.length}`,
|
||||||
node.from,
|
node.from,
|
||||||
node.to,
|
node.to
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -99,16 +98,16 @@ export const getFunctionDefParts = (node: SyntaxNode, input: string) => {
|
||||||
throw new CompilerError(
|
throw new CompilerError(
|
||||||
`FunctionDef expected at least 4 children, got ${children.length}`,
|
`FunctionDef expected at least 4 children, got ${children.length}`,
|
||||||
node.from,
|
node.from,
|
||||||
node.to,
|
node.to
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
const paramNames = getAllChildren(paramsNode).map((param) => {
|
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(
|
throw new CompilerError(
|
||||||
`FunctionDef params must be Identifier or NamedParam, got ${param.type.name}`,
|
`FunctionDef params must be Identifier or NamedParam, got ${param.type.name}`,
|
||||||
param.from,
|
param.from,
|
||||||
param.to,
|
param.to
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
return input.slice(param.from, param.to)
|
return input.slice(param.from, param.to)
|
||||||
|
|
@ -123,7 +122,7 @@ export const getFunctionDefParts = (node: SyntaxNode, input: string) => {
|
||||||
let finallyBody: SyntaxNode | undefined
|
let finallyBody: SyntaxNode | undefined
|
||||||
|
|
||||||
for (const child of rest) {
|
for (const child of rest) {
|
||||||
if (child.type.is('CatchExpr')) {
|
if (child.type.id === terms.CatchExpr) {
|
||||||
catchExpr = child
|
catchExpr = child
|
||||||
const catchChildren = getAllChildren(child)
|
const catchChildren = getAllChildren(child)
|
||||||
const [_catchKeyword, identifierNode, _colon, body] = catchChildren
|
const [_catchKeyword, identifierNode, _colon, body] = catchChildren
|
||||||
|
|
@ -131,12 +130,12 @@ export const getFunctionDefParts = (node: SyntaxNode, input: string) => {
|
||||||
throw new CompilerError(
|
throw new CompilerError(
|
||||||
`CatchExpr expected identifier and body, got ${catchChildren.length} children`,
|
`CatchExpr expected identifier and body, got ${catchChildren.length} children`,
|
||||||
child.from,
|
child.from,
|
||||||
child.to,
|
child.to
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
catchVariable = input.slice(identifierNode.from, identifierNode.to)
|
catchVariable = input.slice(identifierNode.from, identifierNode.to)
|
||||||
catchBody = body
|
catchBody = body
|
||||||
} else if (child.type.is('FinallyExpr')) {
|
} else if (child.type.id === terms.FinallyExpr) {
|
||||||
finallyExpr = child
|
finallyExpr = child
|
||||||
const finallyChildren = getAllChildren(child)
|
const finallyChildren = getAllChildren(child)
|
||||||
const [_finallyKeyword, _colon, body] = finallyChildren
|
const [_finallyKeyword, _colon, body] = finallyChildren
|
||||||
|
|
@ -144,7 +143,7 @@ export const getFunctionDefParts = (node: SyntaxNode, input: string) => {
|
||||||
throw new CompilerError(
|
throw new CompilerError(
|
||||||
`FinallyExpr expected body, got ${finallyChildren.length} children`,
|
`FinallyExpr expected body, got ${finallyChildren.length} children`,
|
||||||
child.from,
|
child.from,
|
||||||
child.to,
|
child.to
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
finallyBody = body
|
finallyBody = body
|
||||||
|
|
@ -165,9 +164,9 @@ export const getFunctionCallParts = (node: SyntaxNode, input: string) => {
|
||||||
throw new CompilerError(`FunctionCall expected at least 1 child, got 0`, node.from, node.to)
|
throw new CompilerError(`FunctionCall expected at least 1 child, got 0`, node.from, node.to)
|
||||||
}
|
}
|
||||||
|
|
||||||
const namedArgs = args.filter((arg) => arg.type.is('NamedArg'))
|
const namedArgs = args.filter((arg) => arg.type.id === terms.NamedArg)
|
||||||
const positionalArgs = args
|
const positionalArgs = args
|
||||||
.filter((arg) => arg.type.is('PositionalArg'))
|
.filter((arg) => arg.type.id === terms.PositionalArg)
|
||||||
.map((arg) => {
|
.map((arg) => {
|
||||||
const child = arg.firstChild
|
const child = arg.firstChild
|
||||||
if (!child) throw new CompilerError(`PositionalArg has no child`, arg.from, arg.to)
|
if (!child) throw new CompilerError(`PositionalArg has no child`, arg.from, arg.to)
|
||||||
|
|
@ -199,7 +198,7 @@ export const getIfExprParts = (node: SyntaxNode, input: string) => {
|
||||||
throw new CompilerError(
|
throw new CompilerError(
|
||||||
`IfExpr expected at least 4 children, got ${children.length}`,
|
`IfExpr expected at least 4 children, got ${children.length}`,
|
||||||
node.from,
|
node.from,
|
||||||
node.to,
|
node.to
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -208,13 +207,13 @@ export const getIfExprParts = (node: SyntaxNode, input: string) => {
|
||||||
rest.forEach((child) => {
|
rest.forEach((child) => {
|
||||||
const parts = getAllChildren(child)
|
const parts = getAllChildren(child)
|
||||||
|
|
||||||
if (child.type.is('ElseExpr')) {
|
if (child.type.id === terms.ElseExpr) {
|
||||||
if (parts.length !== 3) {
|
if (parts.length !== 3) {
|
||||||
const message = `ElseExpr expected 1 child, got ${parts.length}`
|
const message = `ElseExpr expected 1 child, got ${parts.length}`
|
||||||
throw new CompilerError(message, child.from, child.to)
|
throw new CompilerError(message, child.from, child.to)
|
||||||
}
|
}
|
||||||
elseThenBlock = parts.at(-1)
|
elseThenBlock = parts.at(-1)
|
||||||
} else if (child.type.is('ElseIfExpr')) {
|
} else if (child.type.id === terms.ElseIfExpr) {
|
||||||
const [_else, _if, conditional, _colon, thenBlock] = parts
|
const [_else, _if, conditional, _colon, thenBlock] = parts
|
||||||
if (!conditional || !thenBlock) {
|
if (!conditional || !thenBlock) {
|
||||||
const names = parts.map((p) => p.type.name).join(', ')
|
const names = parts.map((p) => p.type.name).join(', ')
|
||||||
|
|
@ -249,32 +248,35 @@ export const getStringParts = (node: SyntaxNode, input: string) => {
|
||||||
// The text is just between the quotes
|
// The text is just between the quotes
|
||||||
const parts = children.filter((child) => {
|
const parts = children.filter((child) => {
|
||||||
return (
|
return (
|
||||||
child.type.is('StringFragment') ||
|
child.type.id === terms.StringFragment ||
|
||||||
child.type.is('Interpolation') ||
|
child.type.id === terms.Interpolation ||
|
||||||
child.type.is('EscapeSeq') ||
|
child.type.id === terms.EscapeSeq ||
|
||||||
child.type.is('CurlyString')
|
child.type.id === terms.CurlyString
|
||||||
|
|
||||||
)
|
)
|
||||||
})
|
})
|
||||||
|
|
||||||
// Validate each part is the expected type
|
// Validate each part is the expected type
|
||||||
parts.forEach((part) => {
|
parts.forEach((part) => {
|
||||||
if (
|
if (
|
||||||
part.type.is('StringFragment') &&
|
part.type.id !== terms.StringFragment &&
|
||||||
part.type.is('Interpolation') &&
|
part.type.id !== terms.Interpolation &&
|
||||||
part.type.is('EscapeSeq') &&
|
part.type.id !== terms.EscapeSeq &&
|
||||||
part.type.is('CurlyString')
|
part.type.id !== terms.CurlyString
|
||||||
) {
|
) {
|
||||||
throw new CompilerError(
|
throw new CompilerError(
|
||||||
`String child must be StringFragment, Interpolation, or EscapeSeq, got ${part.type.name}`,
|
`String child must be StringFragment, Interpolation, or EscapeSeq, got ${part.type.name}`,
|
||||||
part.from,
|
part.from,
|
||||||
part.to,
|
part.to
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
// hasInterpolation means the string has interpolation ($var) or escape sequences (\n)
|
// hasInterpolation means the string has interpolation ($var) or escape sequences (\n)
|
||||||
// A simple string like 'hello' has one StringFragment but no interpolation
|
// A simple string like 'hello' has one StringFragment but no interpolation
|
||||||
const hasInterpolation = parts.some((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 }
|
return { parts, hasInterpolation }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -286,23 +288,23 @@ export const getDotGetParts = (node: SyntaxNode, input: string) => {
|
||||||
throw new CompilerError(
|
throw new CompilerError(
|
||||||
`DotGet expected 2 identifier children, got ${children.length}`,
|
`DotGet expected 2 identifier children, got ${children.length}`,
|
||||||
node.from,
|
node.from,
|
||||||
node.to,
|
node.to
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!object.type.is('IdentifierBeforeDot')) {
|
if (object.type.id !== terms.IdentifierBeforeDot && object.type.id !== terms.Dollar) {
|
||||||
throw new CompilerError(
|
throw new CompilerError(
|
||||||
`DotGet object must be an IdentifierBeforeDot, got ${object.type.name}`,
|
`DotGet object must be an IdentifierBeforeDot, got ${object.type.name}`,
|
||||||
object.from,
|
object.from,
|
||||||
object.to,
|
object.to
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!['Identifier', 'Number', 'ParenExpr', 'DotGet'].includes(property.type.name)) {
|
if (![terms.Identifier, terms.Number, terms.ParenExpr, terms.DotGet].includes(property.type.id)) {
|
||||||
throw new CompilerError(
|
throw new CompilerError(
|
||||||
`DotGet property must be an Identifier, Number, ParenExpr, or DotGet, got ${property.type.name}`,
|
`DotGet property must be an Identifier, Number, ParenExpr, or DotGet, got ${property.type.name}`,
|
||||||
property.from,
|
property.from,
|
||||||
property.to,
|
property.to
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -321,7 +323,7 @@ export const getTryExprParts = (node: SyntaxNode, input: string) => {
|
||||||
throw new CompilerError(
|
throw new CompilerError(
|
||||||
`TryExpr expected at least 3 children, got ${children.length}`,
|
`TryExpr expected at least 3 children, got ${children.length}`,
|
||||||
node.from,
|
node.from,
|
||||||
node.to,
|
node.to
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -332,7 +334,7 @@ export const getTryExprParts = (node: SyntaxNode, input: string) => {
|
||||||
let finallyBody: SyntaxNode | undefined
|
let finallyBody: SyntaxNode | undefined
|
||||||
|
|
||||||
rest.forEach((child) => {
|
rest.forEach((child) => {
|
||||||
if (child.type.is('CatchExpr')) {
|
if (child.type.id === terms.CatchExpr) {
|
||||||
catchExpr = child
|
catchExpr = child
|
||||||
const catchChildren = getAllChildren(child)
|
const catchChildren = getAllChildren(child)
|
||||||
const [_catchKeyword, identifierNode, _colon, body] = catchChildren
|
const [_catchKeyword, identifierNode, _colon, body] = catchChildren
|
||||||
|
|
@ -340,12 +342,12 @@ export const getTryExprParts = (node: SyntaxNode, input: string) => {
|
||||||
throw new CompilerError(
|
throw new CompilerError(
|
||||||
`CatchExpr expected identifier and body, got ${catchChildren.length} children`,
|
`CatchExpr expected identifier and body, got ${catchChildren.length} children`,
|
||||||
child.from,
|
child.from,
|
||||||
child.to,
|
child.to
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
catchVariable = input.slice(identifierNode.from, identifierNode.to)
|
catchVariable = input.slice(identifierNode.from, identifierNode.to)
|
||||||
catchBody = body
|
catchBody = body
|
||||||
} else if (child.type.is('FinallyExpr')) {
|
} else if (child.type.id === terms.FinallyExpr) {
|
||||||
finallyExpr = child
|
finallyExpr = child
|
||||||
const finallyChildren = getAllChildren(child)
|
const finallyChildren = getAllChildren(child)
|
||||||
const [_finallyKeyword, _colon, body] = finallyChildren
|
const [_finallyKeyword, _colon, body] = finallyChildren
|
||||||
|
|
@ -353,7 +355,7 @@ export const getTryExprParts = (node: SyntaxNode, input: string) => {
|
||||||
throw new CompilerError(
|
throw new CompilerError(
|
||||||
`FinallyExpr expected body, got ${finallyChildren.length} children`,
|
`FinallyExpr expected body, got ${finallyChildren.length} children`,
|
||||||
child.from,
|
child.from,
|
||||||
child.to,
|
child.to
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
finallyBody = body
|
finallyBody = body
|
||||||
|
|
|
||||||
261
src/editor/commands.ts
Normal file
261
src/editor/commands.ts
Normal 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 = (fn: () => 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 }
|
||||||
|
}
|
||||||
|
|
@ -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],
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
@ -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
|
|
||||||
})
|
|
||||||
|
|
@ -1,78 +1,53 @@
|
||||||
:root {
|
#output {
|
||||||
/* Background colors */
|
flex: 1;
|
||||||
--bg-editor: #011627;
|
background: var(--bg-output);
|
||||||
--bg-output: #40318D;
|
color: var(--text-output);
|
||||||
--bg-status-bar: #1E2A4A;
|
padding: 20px;
|
||||||
--bg-status-border: #0E1A3A;
|
overflow-y: auto;
|
||||||
--bg-selection: #1D3B53;
|
white-space: pre-wrap;
|
||||||
|
|
||||||
/* 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);
|
|
||||||
font-family: 'Pixeloid Mono', 'Courier New', monospace;
|
font-family: 'Pixeloid Mono', 'Courier New', monospace;
|
||||||
font-size: 18px;
|
font-size: 18px;
|
||||||
height: 100vh;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
#root {
|
#status-bar {
|
||||||
height: 100vh;
|
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);
|
||||||
}
|
}
|
||||||
|
|
@ -1,70 +1,140 @@
|
||||||
import { EditorView, basicSetup } from 'codemirror'
|
import { EditorView } from '@codemirror/view'
|
||||||
import { Shrimp } from '#/index'
|
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 '#editor/editor.css'
|
||||||
import { createShrimpDiagnostics } from './diagnostics'
|
|
||||||
import { shrimpHighlighter } from './highlighter'
|
|
||||||
import { createShrimpCompletions } from './completions'
|
|
||||||
import { shrimpKeymap } from './keymap'
|
|
||||||
import { getContent, persistence } from './persistence'
|
|
||||||
|
|
||||||
type EditorProps = {
|
const lineNumbersCompartment = new Compartment()
|
||||||
initialCode?: string
|
|
||||||
onChange?: (code: string) => void
|
|
||||||
extensions?: import('@codemirror/state').Extension[]
|
|
||||||
shrimp?: Shrimp
|
|
||||||
}
|
|
||||||
|
|
||||||
export const Editor = ({
|
connectToNose()
|
||||||
initialCode = '',
|
|
||||||
onChange,
|
export const outputSignal = new Signal<Value | string>()
|
||||||
extensions: customExtensions = [],
|
export const errorSignal = new Signal<string>()
|
||||||
shrimp = new Shrimp(),
|
export const multilineModeSignal = new Signal<boolean>()
|
||||||
}: EditorProps) => {
|
|
||||||
|
export const Editor = () => {
|
||||||
return (
|
return (
|
||||||
|
<>
|
||||||
<div
|
<div
|
||||||
ref={(el: Element) => {
|
ref={(ref: Element) => {
|
||||||
if (!el?.querySelector('.cm-editor'))
|
if (ref?.querySelector('.cm-editor')) return
|
||||||
createEditorView(el, getContent() ?? initialCode, onChange, customExtensions, shrimp)
|
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 = (
|
noseSignals.connect((message) => {
|
||||||
el: Element,
|
if (message.type === 'error') {
|
||||||
initialCode: string,
|
log.error(`Nose error: ${message.data}`)
|
||||||
onChange: ((code: string) => void) | undefined,
|
errorSignal.emit(`Nose error: ${message.data}`)
|
||||||
customExtensions: import('@codemirror/state').Extension[],
|
} else if (message.type === 'reef-output') {
|
||||||
shrimp: Shrimp
|
const x = outputSignal.emit(message.data)
|
||||||
) => {
|
} else if (message.type === 'connected') {
|
||||||
const extensions = [
|
outputSignal.emit(`╞ Connected to Nose VM`)
|
||||||
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())
|
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
|
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>
|
||||||
)
|
)
|
||||||
}
|
|
||||||
|
|
||||||
new EditorView({
|
// Now go through the nodes and put it in the right spot based on order. Higher number means further right
|
||||||
parent: el,
|
const nodes = Array.from(sideEl.childNodes)
|
||||||
doc: initialCode,
|
const index = nodes.findIndex((node) => {
|
||||||
extensions,
|
if (!(node instanceof HTMLElement)) return false
|
||||||
|
return Number(node.dataset.order) > (order ?? 0)
|
||||||
})
|
})
|
||||||
|
|
||||||
// Trigger onChange with initial content
|
if (index === -1) {
|
||||||
onChange?.(initialCode)
|
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}>`
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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')!)
|
|
||||||
}
|
|
||||||
|
|
@ -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>
|
|
||||||
|
|
@ -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}`)
|
|
||||||
|
|
@ -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',
|
|
||||||
}
|
|
||||||
|
|
@ -1 +0,0 @@
|
||||||
export { Editor } from './editor'
|
|
||||||
|
|
@ -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
59
src/editor/noseClient.ts
Normal 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 }))
|
||||||
|
}
|
||||||
9
src/editor/plugins/catchErrors.ts
Normal file
9
src/editor/plugins/catchErrors.ts
Normal 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)}`
|
||||||
|
)
|
||||||
|
})
|
||||||
35
src/editor/plugins/debugTags.ts
Normal file
35
src/editor/plugins/debugTags.ts
Normal 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,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
)
|
||||||
62
src/editor/plugins/errors.ts
Normal file
62
src/editor/plugins/errors.ts
Normal 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,
|
||||||
|
}
|
||||||
|
)
|
||||||
232
src/editor/plugins/inlineHints.tsx
Normal file
232
src/editor/plugins/inlineHints.tsx
Normal 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)
|
||||||
|
}
|
||||||
|
}
|
||||||
184
src/editor/plugins/keymap.tsx
Normal file
184
src/editor/plugins/keymap.tsx
Normal 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()
|
||||||
|
|
@ -1,6 +1,6 @@
|
||||||
import { ViewPlugin, ViewUpdate } from '@codemirror/view'
|
import { ViewPlugin, ViewUpdate } from '@codemirror/view'
|
||||||
|
|
||||||
export const persistence = ViewPlugin.fromClass(
|
export const persistencePlugin = ViewPlugin.fromClass(
|
||||||
class {
|
class {
|
||||||
saveTimeout?: ReturnType<typeof setTimeout>
|
saveTimeout?: ReturnType<typeof setTimeout>
|
||||||
|
|
||||||
|
|
@ -24,6 +24,6 @@ export const getContent = () => {
|
||||||
return localStorage.getItem('shrimp-editor-content') || ''
|
return localStorage.getItem('shrimp-editor-content') || ''
|
||||||
}
|
}
|
||||||
|
|
||||||
export const setContent = (data: string) => {
|
const setContent = (data: string) => {
|
||||||
localStorage.setItem('shrimp-editor-content', data)
|
localStorage.setItem('shrimp-editor-content', data)
|
||||||
}
|
}
|
||||||
9
src/editor/plugins/shrimpLanguage.ts
Normal file
9
src/editor/plugins/shrimpLanguage.ts
Normal 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)
|
||||||
35
src/editor/plugins/shrimpSetup.ts
Normal file
35
src/editor/plugins/shrimpSetup.ts
Normal 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,
|
||||||
|
]
|
||||||
|
}
|
||||||
60
src/editor/plugins/theme.tsx
Normal file
60
src/editor/plugins/theme.tsx
Normal 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
38
src/editor/runCode.tsx
Normal 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)}`)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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 }
|
|
||||||
)
|
|
||||||
|
|
@ -3,7 +3,7 @@ import { VM, fromValue, toValue, isValue, type Bytecode } from 'reefvm'
|
||||||
import { Compiler } from '#compiler/compiler'
|
import { Compiler } from '#compiler/compiler'
|
||||||
import { parse } from '#parser/parser2'
|
import { parse } from '#parser/parser2'
|
||||||
import { Tree } from '#parser/node'
|
import { Tree } from '#parser/node'
|
||||||
import { globals as parserGlobals, setGlobals as setParserGlobals } from '#parser/parser2'
|
import { globals as parserGlobals, setGlobals as setParserGlobals } from '#parser/tokenizer'
|
||||||
import { globals as prelude } from '#prelude'
|
import { globals as prelude } from '#prelude'
|
||||||
|
|
||||||
export { Compiler } from '#compiler/compiler'
|
export { Compiler } from '#compiler/compiler'
|
||||||
|
|
@ -53,10 +53,7 @@ export class Shrimp {
|
||||||
let bytecode
|
let bytecode
|
||||||
|
|
||||||
if (typeof code === 'string') {
|
if (typeof code === 'string') {
|
||||||
const compiler = new Compiler(
|
const compiler = new Compiler(code, Object.keys(Object.assign({}, prelude, this.globals ?? {}, locals ?? {})))
|
||||||
code,
|
|
||||||
Object.keys(Object.assign({}, prelude, this.globals ?? {}, locals ?? {})),
|
|
||||||
)
|
|
||||||
bytecode = compiler.bytecode
|
bytecode = compiler.bytecode
|
||||||
} else {
|
} else {
|
||||||
bytecode = code
|
bytecode = code
|
||||||
|
|
@ -69,6 +66,7 @@ export class Shrimp {
|
||||||
|
|
||||||
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> {
|
export async function runFile(path: string, globals?: Record<string, any>): Promise<any> {
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,7 @@
|
||||||
|
import { parser } from '#parser/shrimp.ts'
|
||||||
import { parse } from '#parser/parser2'
|
import { parse } from '#parser/parser2'
|
||||||
import type { SyntaxNode } from '#parser/node'
|
import type { SyntaxNode } from '#parser/node'
|
||||||
import { isIdentStart, isIdentChar } from './tokenizer2'
|
import { isIdentStart, isIdentChar } from './tokenizer'
|
||||||
|
|
||||||
// Turns a { curly string } into strings and nodes for interpolation
|
// Turns a { curly string } into strings and nodes for interpolation
|
||||||
export const tokenizeCurlyString = (value: string): (string | [string, SyntaxNode])[] => {
|
export const tokenizeCurlyString = (value: string): (string | [string, SyntaxNode])[] => {
|
||||||
|
|
@ -38,13 +39,14 @@ export const tokenizeCurlyString = (value: string): (string | [string, SyntaxNod
|
||||||
|
|
||||||
const input = value.slice(start + 2, pos) // skip '$('
|
const input = value.slice(start + 2, pos) // skip '$('
|
||||||
tokens.push([input, parse(input)])
|
tokens.push([input, parse(input)])
|
||||||
start = pos + 1 // start after ')'
|
start = ++pos // skip ')'
|
||||||
} else {
|
} else {
|
||||||
char = value[++pos]
|
char = value[++pos]
|
||||||
if (!char) break
|
if (!char) break
|
||||||
if (!isIdentStart(char.charCodeAt(0))) 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 '$'
|
const input = value.slice(start + 1, pos) // skip '$'
|
||||||
tokens.push([input, parse(input)])
|
tokens.push([input, parse(input)])
|
||||||
|
|
|
||||||
|
|
@ -1,17 +1,21 @@
|
||||||
import { type Token, TokenType } from './tokenizer2'
|
import { type Token, TokenType } from './tokenizer2'
|
||||||
|
import * as term from './shrimp.terms'
|
||||||
|
|
||||||
export type NodeType =
|
export type NodeType =
|
||||||
| 'Program'
|
| 'Program'
|
||||||
| 'Block'
|
| 'Block'
|
||||||
|
|
||||||
| 'FunctionCall'
|
| 'FunctionCall'
|
||||||
| 'FunctionCallOrIdentifier'
|
| 'FunctionCallOrIdentifier'
|
||||||
| 'FunctionCallWithBlock'
|
| 'FunctionCallWithBlock'
|
||||||
| 'PositionalArg'
|
| 'PositionalArg'
|
||||||
| 'NamedArg'
|
| 'NamedArg'
|
||||||
| 'NamedArgPrefix'
|
| 'NamedArgPrefix'
|
||||||
|
|
||||||
| 'FunctionDef'
|
| 'FunctionDef'
|
||||||
| 'Params'
|
| 'Params'
|
||||||
| 'NamedParam'
|
| 'NamedParam'
|
||||||
|
|
||||||
| 'Null'
|
| 'Null'
|
||||||
| 'Boolean'
|
| 'Boolean'
|
||||||
| 'Number'
|
| 'Number'
|
||||||
|
|
@ -29,6 +33,7 @@ export type NodeType =
|
||||||
| 'Array'
|
| 'Array'
|
||||||
| 'Dict'
|
| 'Dict'
|
||||||
| 'Comment'
|
| 'Comment'
|
||||||
|
|
||||||
| 'BinOp'
|
| 'BinOp'
|
||||||
| 'ConditionalOp'
|
| 'ConditionalOp'
|
||||||
| 'ParenExpr'
|
| 'ParenExpr'
|
||||||
|
|
@ -36,6 +41,7 @@ export type NodeType =
|
||||||
| 'CompoundAssign'
|
| 'CompoundAssign'
|
||||||
| 'DotGet'
|
| 'DotGet'
|
||||||
| 'PipeExpr'
|
| 'PipeExpr'
|
||||||
|
|
||||||
| 'IfExpr'
|
| 'IfExpr'
|
||||||
| 'ElseIfExpr'
|
| 'ElseIfExpr'
|
||||||
| 'ElseExpr'
|
| 'ElseExpr'
|
||||||
|
|
@ -44,12 +50,13 @@ export type NodeType =
|
||||||
| 'CatchExpr'
|
| 'CatchExpr'
|
||||||
| 'FinallyExpr'
|
| 'FinallyExpr'
|
||||||
| 'Throw'
|
| 'Throw'
|
||||||
| 'Not'
|
|
||||||
| 'Eq'
|
| 'Eq'
|
||||||
| 'Modulo'
|
| 'Modulo'
|
||||||
| 'Plus'
|
| 'Plus'
|
||||||
| 'Star'
|
| 'Star'
|
||||||
| 'Slash'
|
| 'Slash'
|
||||||
|
|
||||||
| 'Import'
|
| 'Import'
|
||||||
| 'Do'
|
| 'Do'
|
||||||
| 'Underscore'
|
| 'Underscore'
|
||||||
|
|
@ -60,13 +67,13 @@ export type NodeType =
|
||||||
// TODO: remove this when we switch from lezer
|
// TODO: remove this when we switch from lezer
|
||||||
export const operators: Record<string, any> = {
|
export const operators: Record<string, any> = {
|
||||||
// Logic
|
// Logic
|
||||||
and: 'And',
|
'and': 'And',
|
||||||
or: 'Or',
|
'or': 'Or',
|
||||||
|
|
||||||
// Bitwise
|
// Bitwise
|
||||||
band: 'Band',
|
'band': 'Band',
|
||||||
bor: 'Bor',
|
'bor': 'Bor',
|
||||||
bxor: 'Bxor',
|
'bxor': 'Bxor',
|
||||||
'>>>': 'Ushr',
|
'>>>': 'Ushr',
|
||||||
'>>': 'Shr',
|
'>>': 'Shr',
|
||||||
'<<': 'Shl',
|
'<<': 'Shl',
|
||||||
|
|
@ -132,6 +139,180 @@ export class Tree {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// TODO: TEMPORARY SHIM
|
||||||
|
class SyntaxNodeType {
|
||||||
|
constructor(public nodeType: NodeType) { }
|
||||||
|
|
||||||
|
is(other: string) {
|
||||||
|
return this.nodeType === other
|
||||||
|
}
|
||||||
|
|
||||||
|
get id(): number {
|
||||||
|
switch (this.nodeType) {
|
||||||
|
case 'Program':
|
||||||
|
return term.Program
|
||||||
|
|
||||||
|
case 'Block':
|
||||||
|
return term.Block
|
||||||
|
|
||||||
|
case 'FunctionCall':
|
||||||
|
return term.FunctionCall
|
||||||
|
|
||||||
|
case 'FunctionCallOrIdentifier':
|
||||||
|
return term.FunctionCallOrIdentifier
|
||||||
|
|
||||||
|
case 'FunctionCallWithBlock':
|
||||||
|
return term.FunctionCallWithBlock
|
||||||
|
|
||||||
|
case 'PositionalArg':
|
||||||
|
return term.PositionalArg
|
||||||
|
|
||||||
|
case 'NamedArg':
|
||||||
|
return term.NamedArg
|
||||||
|
|
||||||
|
case 'FunctionDef':
|
||||||
|
return term.FunctionDef
|
||||||
|
|
||||||
|
case 'Params':
|
||||||
|
return term.Params
|
||||||
|
|
||||||
|
case 'NamedParam':
|
||||||
|
return term.NamedParam
|
||||||
|
|
||||||
|
case 'Null':
|
||||||
|
return term.Null
|
||||||
|
|
||||||
|
case 'Boolean':
|
||||||
|
return term.Boolean
|
||||||
|
|
||||||
|
case 'Number':
|
||||||
|
return term.Number
|
||||||
|
|
||||||
|
case 'String':
|
||||||
|
return term.String
|
||||||
|
|
||||||
|
case 'StringFragment':
|
||||||
|
return term.StringFragment
|
||||||
|
|
||||||
|
case 'CurlyString':
|
||||||
|
return term.CurlyString
|
||||||
|
|
||||||
|
case 'DoubleQuote':
|
||||||
|
return term.DoubleQuote
|
||||||
|
|
||||||
|
case 'EscapeSeq':
|
||||||
|
return term.EscapeSeq
|
||||||
|
|
||||||
|
case 'Interpolation':
|
||||||
|
return term.Interpolation
|
||||||
|
|
||||||
|
case 'Regex':
|
||||||
|
return term.Regex
|
||||||
|
|
||||||
|
case 'Identifier':
|
||||||
|
return term.Identifier
|
||||||
|
|
||||||
|
case 'AssignableIdentifier':
|
||||||
|
return term.AssignableIdentifier
|
||||||
|
|
||||||
|
case 'IdentifierBeforeDot':
|
||||||
|
return term.IdentifierBeforeDot
|
||||||
|
|
||||||
|
case 'Word':
|
||||||
|
return term.Word
|
||||||
|
|
||||||
|
case 'Array':
|
||||||
|
return term.Array
|
||||||
|
|
||||||
|
case 'Dict':
|
||||||
|
return term.Dict
|
||||||
|
|
||||||
|
case 'Comment':
|
||||||
|
return term.Comment
|
||||||
|
|
||||||
|
case 'BinOp':
|
||||||
|
return term.BinOp
|
||||||
|
|
||||||
|
case 'ConditionalOp':
|
||||||
|
return term.ConditionalOp
|
||||||
|
|
||||||
|
case 'ParenExpr':
|
||||||
|
return term.ParenExpr
|
||||||
|
|
||||||
|
case 'Assign':
|
||||||
|
return term.Assign
|
||||||
|
|
||||||
|
case 'CompoundAssign':
|
||||||
|
return term.CompoundAssign
|
||||||
|
|
||||||
|
case 'DotGet':
|
||||||
|
return term.DotGet
|
||||||
|
|
||||||
|
case 'PipeExpr':
|
||||||
|
return term.PipeExpr
|
||||||
|
|
||||||
|
case 'IfExpr':
|
||||||
|
return term.IfExpr
|
||||||
|
|
||||||
|
case 'ElseIfExpr':
|
||||||
|
return term.ElseIfExpr
|
||||||
|
|
||||||
|
case 'ElseExpr':
|
||||||
|
return term.ElseExpr
|
||||||
|
|
||||||
|
case 'WhileExpr':
|
||||||
|
return term.WhileExpr
|
||||||
|
|
||||||
|
case 'TryExpr':
|
||||||
|
return term.TryExpr
|
||||||
|
|
||||||
|
case 'CatchExpr':
|
||||||
|
return term.CatchExpr
|
||||||
|
|
||||||
|
case 'FinallyExpr':
|
||||||
|
return term.FinallyExpr
|
||||||
|
|
||||||
|
case 'Throw':
|
||||||
|
return term.Throw
|
||||||
|
|
||||||
|
case 'Eq':
|
||||||
|
return term.Eq
|
||||||
|
|
||||||
|
case 'Modulo':
|
||||||
|
return term.Modulo
|
||||||
|
|
||||||
|
case 'Plus':
|
||||||
|
return term.Plus
|
||||||
|
|
||||||
|
case 'Star':
|
||||||
|
return term.Star
|
||||||
|
|
||||||
|
case 'Slash':
|
||||||
|
return term.Slash
|
||||||
|
|
||||||
|
case 'Import':
|
||||||
|
return term.Import
|
||||||
|
|
||||||
|
case 'Do':
|
||||||
|
return term.Do
|
||||||
|
|
||||||
|
case 'Underscore':
|
||||||
|
return term.Underscore
|
||||||
|
|
||||||
|
case 'colon':
|
||||||
|
return term.colon
|
||||||
|
|
||||||
|
case 'keyword':
|
||||||
|
return term.keyword
|
||||||
|
}
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
|
||||||
|
get name(): string {
|
||||||
|
return this.nodeType
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
export class SyntaxNode {
|
export class SyntaxNode {
|
||||||
#type: NodeType
|
#type: NodeType
|
||||||
#isError = false
|
#isError = false
|
||||||
|
|
@ -151,18 +332,8 @@ export class SyntaxNode {
|
||||||
return new SyntaxNode(TokenType[token.type] as NodeType, token.from, token.to, parent ?? null)
|
return new SyntaxNode(TokenType[token.type] as NodeType, token.from, token.to, parent ?? null)
|
||||||
}
|
}
|
||||||
|
|
||||||
get type(): {
|
get type(): SyntaxNodeType {
|
||||||
type: NodeType
|
return new SyntaxNodeType(this.#type)
|
||||||
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) {
|
set type(name: NodeType) {
|
||||||
|
|
@ -209,7 +380,7 @@ export class SyntaxNode {
|
||||||
}
|
}
|
||||||
|
|
||||||
push(...nodes: SyntaxNode[]): SyntaxNode {
|
push(...nodes: SyntaxNode[]): SyntaxNode {
|
||||||
nodes.forEach((child) => (child.parent = this))
|
nodes.forEach(child => child.parent = this)
|
||||||
this.children.push(...nodes)
|
this.children.push(...nodes)
|
||||||
return this
|
return this
|
||||||
}
|
}
|
||||||
|
|
@ -222,8 +393,8 @@ export class SyntaxNode {
|
||||||
// Operator precedence (binding power) - higher = tighter binding
|
// Operator precedence (binding power) - higher = tighter binding
|
||||||
export const precedence: Record<string, number> = {
|
export const precedence: Record<string, number> = {
|
||||||
// Logical
|
// Logical
|
||||||
or: 10,
|
'or': 10,
|
||||||
and: 20,
|
'and': 20,
|
||||||
|
|
||||||
// Comparison
|
// Comparison
|
||||||
'==': 30,
|
'==': 30,
|
||||||
|
|
@ -246,9 +417,9 @@ export const precedence: Record<string, number> = {
|
||||||
'-': 40,
|
'-': 40,
|
||||||
|
|
||||||
// Bitwise AND/OR/XOR (higher precedence than addition)
|
// Bitwise AND/OR/XOR (higher precedence than addition)
|
||||||
band: 45,
|
'band': 45,
|
||||||
bor: 45,
|
'bor': 45,
|
||||||
bxor: 45,
|
'bxor': 45,
|
||||||
|
|
||||||
// Multiplication/Division/Modulo
|
// Multiplication/Division/Modulo
|
||||||
'*': 50,
|
'*': 50,
|
||||||
|
|
@ -259,6 +430,10 @@ export const precedence: Record<string, number> = {
|
||||||
'**': 60,
|
'**': 60,
|
||||||
}
|
}
|
||||||
|
|
||||||
export const conditionals = new Set(['==', '!=', '<', '>', '<=', '>=', '??', 'and', 'or'])
|
export const conditionals = new Set([
|
||||||
|
'==', '!=', '<', '>', '<=', '>=', '??', 'and', 'or'
|
||||||
|
])
|
||||||
|
|
||||||
export const compounds = ['??=', '+=', '-=', '*=', '/=', '%=']
|
export const compounds = [
|
||||||
|
'??=', '+=', '-=', '*=', '/=', '%='
|
||||||
|
]
|
||||||
|
|
|
||||||
99
src/parser/operatorTokenizer.ts
Normal file
99
src/parser/operatorTokenizer.ts
Normal 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
|
||||||
|
}
|
||||||
|
|
@ -1,17 +1,11 @@
|
||||||
import { CompilerError } from '#compiler/compilerError'
|
import { CompilerError } from '#compiler/compilerError'
|
||||||
import { Scanner, type Token, TokenType } from './tokenizer2'
|
import { Scanner, type Token, TokenType } from './tokenizer2'
|
||||||
import { SyntaxNode, operators, precedence, conditionals, compounds } from './node'
|
import { SyntaxNode, operators, precedence, conditionals, compounds } from './node'
|
||||||
|
import { globals } from './tokenizer'
|
||||||
import { parseString } from './stringParser'
|
import { parseString } from './stringParser'
|
||||||
|
|
||||||
const $T = TokenType
|
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 => {
|
export const parse = (input: string): SyntaxNode => {
|
||||||
const parser = new Parser()
|
const parser = new Parser()
|
||||||
return parser.parse(input)
|
return parser.parse(input)
|
||||||
|
|
@ -42,7 +36,7 @@ export class Parser {
|
||||||
pos = 0
|
pos = 0
|
||||||
inParens = 0
|
inParens = 0
|
||||||
input = ''
|
input = ''
|
||||||
scope = new Scope()
|
scope = new Scope
|
||||||
inTestExpr = false
|
inTestExpr = false
|
||||||
|
|
||||||
parse(input: string): SyntaxNode {
|
parse(input: string): SyntaxNode {
|
||||||
|
|
@ -78,11 +72,14 @@ export class Parser {
|
||||||
|
|
||||||
// statement is a line of code
|
// statement is a line of code
|
||||||
statement(): SyntaxNode | null {
|
statement(): SyntaxNode | null {
|
||||||
if (this.is($T.Comment)) return this.comment()
|
if (this.is($T.Comment))
|
||||||
|
return this.comment()
|
||||||
|
|
||||||
while (this.is($T.Newline) || this.is($T.Semicolon)) this.next()
|
while (this.is($T.Newline) || this.is($T.Semicolon))
|
||||||
|
this.next()
|
||||||
|
|
||||||
if (this.isEOF() || this.isExprEndKeyword()) return null
|
if (this.isEOF() || this.isExprEndKeyword())
|
||||||
|
return null
|
||||||
|
|
||||||
return this.expression()
|
return this.expression()
|
||||||
}
|
}
|
||||||
|
|
@ -96,38 +93,51 @@ export class Parser {
|
||||||
let expr
|
let expr
|
||||||
|
|
||||||
// x = value
|
// x = value
|
||||||
if (
|
if (this.is($T.Identifier) && (
|
||||||
this.is($T.Identifier) &&
|
this.nextIs($T.Operator, '=') || compounds.some(x => this.nextIs($T.Operator, x))
|
||||||
(this.nextIs($T.Operator, '=') || compounds.some((x) => this.nextIs($T.Operator, x)))
|
))
|
||||||
)
|
|
||||||
expr = this.assign()
|
expr = this.assign()
|
||||||
|
|
||||||
// if, while, do, etc
|
// if, while, do, etc
|
||||||
else if (this.is($T.Keyword)) expr = this.keywords()
|
else if (this.is($T.Keyword))
|
||||||
|
expr = this.keywords()
|
||||||
|
|
||||||
// dotget
|
// dotget
|
||||||
else if (this.nextIs($T.Operator, '.')) expr = this.dotGetFunctionCall()
|
else if (this.nextIs($T.Operator, '.'))
|
||||||
|
expr = this.dotGetFunctionCall()
|
||||||
|
|
||||||
// echo hello world
|
// echo hello world
|
||||||
else if (this.is($T.Identifier) && !this.nextIs($T.Operator) && !this.nextIsExprEnd())
|
else if (this.is($T.Identifier) && !this.nextIs($T.Operator) && !this.nextIsExprEnd())
|
||||||
expr = this.functionCall()
|
expr = this.functionCall()
|
||||||
|
|
||||||
// bare-function-call
|
// bare-function-call
|
||||||
else if (this.is($T.Identifier) && this.nextIsExprEnd()) expr = this.functionCallOrIdentifier()
|
else if (this.is($T.Identifier) && this.nextIsExprEnd())
|
||||||
|
expr = this.functionCallOrIdentifier()
|
||||||
|
|
||||||
// everything else
|
// everything else
|
||||||
else expr = this.exprWithPrecedence()
|
else
|
||||||
|
expr = this.exprWithPrecedence()
|
||||||
|
|
||||||
// check for destructuring
|
// check for destructuring
|
||||||
if (expr.type.is('Array') && this.is($T.Operator, '=')) return this.destructure(expr)
|
if (expr.type.is('Array') && this.is($T.Operator, '='))
|
||||||
|
return this.destructure(expr)
|
||||||
|
|
||||||
// check for parens function call
|
// check for parens function call
|
||||||
// ex: (ref my-func) my-arg
|
// ex: (ref my-func) my-arg
|
||||||
if (expr.type.is('ParenExpr') && !this.isExprEnd()) expr = this.functionCall(expr)
|
if (expr.type.is('ParenExpr') && !this.isExprEnd())
|
||||||
|
expr = this.functionCall(expr)
|
||||||
|
|
||||||
// if dotget is followed by binary operator, continue parsing as binary expression
|
// 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, '|'))
|
if (expr.type.is('DotGet') && this.is($T.Operator) && !this.is($T.Operator, '|'))
|
||||||
expr = this.dotGetBinOp(expr)
|
expr = this.dotGetBinOp(expr)
|
||||||
|
|
||||||
// one | echo
|
// one | echo
|
||||||
if (allowPipe && this.isPipe()) return this.pipe(expr)
|
if (allowPipe && this.isPipe())
|
||||||
|
return this.pipe(expr)
|
||||||
|
|
||||||
// regular
|
// regular
|
||||||
else return expr
|
else
|
||||||
|
return expr
|
||||||
}
|
}
|
||||||
|
|
||||||
// piping | stuff | is | cool
|
// piping | stuff | is | cool
|
||||||
|
|
@ -191,19 +201,23 @@ export class Parser {
|
||||||
|
|
||||||
// if, while, do, etc
|
// if, while, do, etc
|
||||||
keywords(): SyntaxNode {
|
keywords(): SyntaxNode {
|
||||||
if (this.is($T.Keyword, 'if')) return this.if()
|
if (this.is($T.Keyword, 'if'))
|
||||||
|
return this.if()
|
||||||
|
|
||||||
if (this.is($T.Keyword, 'while')) return this.while()
|
if (this.is($T.Keyword, 'while'))
|
||||||
|
return this.while()
|
||||||
|
|
||||||
if (this.is($T.Keyword, 'do')) return this.do()
|
if (this.is($T.Keyword, 'do'))
|
||||||
|
return this.do()
|
||||||
|
|
||||||
if (this.is($T.Keyword, 'try')) return this.try()
|
if (this.is($T.Keyword, 'try'))
|
||||||
|
return this.try()
|
||||||
|
|
||||||
if (this.is($T.Keyword, 'throw')) return this.throw()
|
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()
|
||||||
if (this.is($T.Keyword, 'import')) return this.import()
|
|
||||||
|
|
||||||
return this.expect($T.Keyword, 'if/while/do/import') as never
|
return this.expect($T.Keyword, 'if/while/do/import') as never
|
||||||
}
|
}
|
||||||
|
|
@ -215,12 +229,15 @@ export class Parser {
|
||||||
// 3. binary operations
|
// 3. binary operations
|
||||||
// 4. anywhere an expression can be used
|
// 4. anywhere an expression can be used
|
||||||
value(): SyntaxNode {
|
value(): SyntaxNode {
|
||||||
if (this.is($T.OpenParen)) return this.parens()
|
if (this.is($T.OpenParen))
|
||||||
|
return this.parens()
|
||||||
|
|
||||||
if (this.is($T.OpenBracket)) return this.arrayOrDict()
|
if (this.is($T.OpenBracket))
|
||||||
|
return this.arrayOrDict()
|
||||||
|
|
||||||
// dotget
|
// dotget
|
||||||
if (this.nextIs($T.Operator, '.')) return this.dotGet()
|
if (this.nextIs($T.Operator, '.'))
|
||||||
|
return this.dotGet()
|
||||||
|
|
||||||
return this.atom()
|
return this.atom()
|
||||||
}
|
}
|
||||||
|
|
@ -298,7 +315,8 @@ export class Parser {
|
||||||
}
|
}
|
||||||
|
|
||||||
// probably an array
|
// probably an array
|
||||||
if (curr.type !== $T.Comment && curr.type !== $T.Semicolon && curr.type !== $T.Newline) break
|
if (curr.type !== $T.Comment && curr.type !== $T.Semicolon && curr.type !== $T.Newline)
|
||||||
|
break
|
||||||
|
|
||||||
curr = this.peek(peek++)
|
curr = this.peek(peek++)
|
||||||
}
|
}
|
||||||
|
|
@ -316,7 +334,7 @@ export class Parser {
|
||||||
const node = new SyntaxNode(
|
const node = new SyntaxNode(
|
||||||
opToken.value === '=' ? 'Assign' : 'CompoundAssign',
|
opToken.value === '=' ? 'Assign' : 'CompoundAssign',
|
||||||
ident.from,
|
ident.from,
|
||||||
expr.to,
|
expr.to
|
||||||
)
|
)
|
||||||
|
|
||||||
return node.push(ident, op, expr)
|
return node.push(ident, op, expr)
|
||||||
|
|
@ -333,7 +351,8 @@ export class Parser {
|
||||||
|
|
||||||
// atoms are the basic building blocks: literals, identifiers, words
|
// atoms are the basic building blocks: literals, identifiers, words
|
||||||
atom(): SyntaxNode {
|
atom(): SyntaxNode {
|
||||||
if (this.is($T.String)) return this.string()
|
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))
|
if (this.isAny($T.Null, $T.Boolean, $T.Number, $T.Identifier, $T.Word, $T.Regex, $T.Underscore))
|
||||||
return SyntaxNode.from(this.next())
|
return SyntaxNode.from(this.next())
|
||||||
|
|
@ -374,7 +393,8 @@ export class Parser {
|
||||||
const keyword = this.keyword('catch')
|
const keyword = this.keyword('catch')
|
||||||
|
|
||||||
let catchVar
|
let catchVar
|
||||||
if (this.is($T.Identifier)) catchVar = this.identifier()
|
if (this.is($T.Identifier))
|
||||||
|
catchVar = this.identifier()
|
||||||
|
|
||||||
const block = this.block()
|
const block = this.block()
|
||||||
|
|
||||||
|
|
@ -478,14 +498,12 @@ export class Parser {
|
||||||
this.scope.add(varName)
|
this.scope.add(varName)
|
||||||
|
|
||||||
let arg
|
let arg
|
||||||
if (this.is($T.Identifier)) arg = this.identifier()
|
if (this.is($T.Identifier))
|
||||||
else if (this.is($T.NamedArgPrefix)) arg = this.namedParam()
|
arg = this.identifier()
|
||||||
|
else if (this.is($T.NamedArgPrefix))
|
||||||
|
arg = this.namedParam()
|
||||||
else
|
else
|
||||||
throw new CompilerError(
|
throw new CompilerError(`Expected Identifier or NamedArgPrefix, got ${TokenType[this.current().type]}`, this.current().from, this.current().to)
|
||||||
`Expected Identifier or NamedArgPrefix, got ${TokenType[this.current().type]}`,
|
|
||||||
this.current().from,
|
|
||||||
this.current().to,
|
|
||||||
)
|
|
||||||
|
|
||||||
params.push(arg)
|
params.push(arg)
|
||||||
}
|
}
|
||||||
|
|
@ -493,9 +511,11 @@ export class Parser {
|
||||||
const block = this.block(false)
|
const block = this.block(false)
|
||||||
let catchNode, finalNode
|
let catchNode, finalNode
|
||||||
|
|
||||||
if (this.is($T.Keyword, 'catch')) catchNode = this.catch()
|
if (this.is($T.Keyword, 'catch'))
|
||||||
|
catchNode = this.catch()
|
||||||
|
|
||||||
if (this.is($T.Keyword, 'finally')) finalNode = this.finally()
|
if (this.is($T.Keyword, 'finally'))
|
||||||
|
finalNode = this.finally()
|
||||||
|
|
||||||
const end = this.keyword('end')
|
const end = this.keyword('end')
|
||||||
|
|
||||||
|
|
@ -507,7 +527,11 @@ export class Parser {
|
||||||
|
|
||||||
node.add(doNode)
|
node.add(doNode)
|
||||||
|
|
||||||
const paramsNode = new SyntaxNode('Params', params[0]?.from ?? 0, params.at(-1)?.to ?? 0)
|
const paramsNode = new SyntaxNode(
|
||||||
|
'Params',
|
||||||
|
params[0]?.from ?? 0,
|
||||||
|
params.at(-1)?.to ?? 0
|
||||||
|
)
|
||||||
|
|
||||||
if (params.length) paramsNode.push(...params)
|
if (params.length) paramsNode.push(...params)
|
||||||
node.add(paramsNode)
|
node.add(paramsNode)
|
||||||
|
|
@ -528,7 +552,8 @@ export class Parser {
|
||||||
const ident = this.input.slice(left.from, left.to)
|
const ident = this.input.slice(left.from, left.to)
|
||||||
|
|
||||||
// not in scope, just return Word
|
// not in scope, just return Word
|
||||||
if (!this.scope.has(ident)) return this.word(left)
|
if (!this.scope.has(ident))
|
||||||
|
return this.word(left)
|
||||||
|
|
||||||
if (left.type.is('Identifier')) left.type = 'IdentifierBeforeDot'
|
if (left.type.is('Identifier')) left.type = 'IdentifierBeforeDot'
|
||||||
|
|
||||||
|
|
@ -568,13 +593,16 @@ export class Parser {
|
||||||
const dotGet = this.dotGet()
|
const dotGet = this.dotGet()
|
||||||
|
|
||||||
// if followed by a binary operator (not pipe), return dotGet/Word as-is for expression parser
|
// 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
|
if (this.is($T.Operator) && !this.is($T.Operator, '|'))
|
||||||
|
return dotGet
|
||||||
|
|
||||||
// dotget not in scope, regular Word
|
// dotget not in scope, regular Word
|
||||||
if (dotGet.type.is('Word')) return dotGet
|
if (dotGet.type.is('Word')) return dotGet
|
||||||
|
|
||||||
if (this.isExprEnd()) return this.functionCallOrIdentifier(dotGet)
|
if (this.isExprEnd())
|
||||||
else return this.functionCall(dotGet)
|
return this.functionCallOrIdentifier(dotGet)
|
||||||
|
else
|
||||||
|
return this.functionCall(dotGet)
|
||||||
}
|
}
|
||||||
|
|
||||||
// can be used in functions or try block
|
// can be used in functions or try block
|
||||||
|
|
@ -726,30 +754,17 @@ export class Parser {
|
||||||
const val = this.value()
|
const val = this.value()
|
||||||
|
|
||||||
if (!['Null', 'Boolean', 'Number', 'String'].includes(val.type.name))
|
if (!['Null', 'Boolean', 'Number', 'String'].includes(val.type.name))
|
||||||
throw new CompilerError(
|
throw new CompilerError(`Default value must be null, boolean, number, or string, got ${val.type.name}`, val.from, val.to)
|
||||||
`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)
|
const node = new SyntaxNode('NamedParam', prefix.from, val.to)
|
||||||
return node.push(prefix, val)
|
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 + - =
|
// operators like + - =
|
||||||
op(op?: string): SyntaxNode {
|
op(op?: string): SyntaxNode {
|
||||||
const token = op ? this.expect($T.Operator, op) : this.expect($T.Operator)
|
const token = op ? this.expect($T.Operator, op) : this.expect($T.Operator)
|
||||||
const name = operators[token.value!]
|
const name = operators[token.value!]
|
||||||
if (!name)
|
if (!name) throw new CompilerError(`Operator not registered: ${token.value!}`, token.from, token.to)
|
||||||
throw new CompilerError(`Operator not registered: ${token.value!}`, token.from, token.to)
|
|
||||||
return new SyntaxNode(name, token.from, token.to)
|
return new SyntaxNode(name, token.from, token.to)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -796,9 +811,11 @@ export class Parser {
|
||||||
let last = tryBlock.at(-1)
|
let last = tryBlock.at(-1)
|
||||||
let catchNode, finalNode
|
let catchNode, finalNode
|
||||||
|
|
||||||
if (this.is($T.Keyword, 'catch')) catchNode = this.catch()
|
if (this.is($T.Keyword, 'catch'))
|
||||||
|
catchNode = this.catch()
|
||||||
|
|
||||||
if (this.is($T.Keyword, 'finally')) finalNode = this.finally()
|
if (this.is($T.Keyword, 'finally'))
|
||||||
|
finalNode = this.finally()
|
||||||
|
|
||||||
const end = this.keyword('end')
|
const end = this.keyword('end')
|
||||||
|
|
||||||
|
|
@ -808,9 +825,11 @@ export class Parser {
|
||||||
const node = new SyntaxNode('TryExpr', tryNode.from, last!.to)
|
const node = new SyntaxNode('TryExpr', tryNode.from, last!.to)
|
||||||
node.push(tryNode, ...tryBlock)
|
node.push(tryNode, ...tryBlock)
|
||||||
|
|
||||||
if (catchNode) node.push(catchNode)
|
if (catchNode)
|
||||||
|
node.push(catchNode)
|
||||||
|
|
||||||
if (finalNode) node.push(finalNode)
|
if (finalNode)
|
||||||
|
node.push(finalNode)
|
||||||
|
|
||||||
return node.push(end)
|
return node.push(end)
|
||||||
}
|
}
|
||||||
|
|
@ -832,7 +851,8 @@ export class Parser {
|
||||||
|
|
||||||
while (this.is($T.Operator, '.')) {
|
while (this.is($T.Operator, '.')) {
|
||||||
this.next()
|
this.next()
|
||||||
if (this.isAny($T.Word, $T.Identifier, $T.Number)) parts.push(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)
|
return new SyntaxNode('Word', parts[0]!.from, parts.at(-1)!.to)
|
||||||
|
|
@ -855,7 +875,8 @@ export class Parser {
|
||||||
let offset = 1
|
let offset = 1
|
||||||
let peek = this.peek(offset)
|
let peek = this.peek(offset)
|
||||||
|
|
||||||
while (peek && peek.type === $T.Newline) peek = this.peek(++offset)
|
while (peek && peek.type === $T.Newline)
|
||||||
|
peek = this.peek(++offset)
|
||||||
|
|
||||||
if (!peek || peek.type !== type) return false
|
if (!peek || peek.type !== type) return false
|
||||||
if (value !== undefined && peek.value !== value) return false
|
if (value !== undefined && peek.value !== value) return false
|
||||||
|
|
@ -876,7 +897,7 @@ export class Parser {
|
||||||
}
|
}
|
||||||
|
|
||||||
isAny(...type: TokenType[]): boolean {
|
isAny(...type: TokenType[]): boolean {
|
||||||
return type.some((x) => this.is(x))
|
return type.some(x => this.is(x))
|
||||||
}
|
}
|
||||||
|
|
||||||
nextIs(type: TokenType, value?: string): boolean {
|
nextIs(type: TokenType, value?: string): boolean {
|
||||||
|
|
@ -887,58 +908,43 @@ export class Parser {
|
||||||
}
|
}
|
||||||
|
|
||||||
nextIsAny(...type: TokenType[]): boolean {
|
nextIsAny(...type: TokenType[]): boolean {
|
||||||
return type.some((x) => this.nextIs(x))
|
return type.some(x => this.nextIs(x))
|
||||||
}
|
}
|
||||||
|
|
||||||
isExprEnd(): boolean {
|
isExprEnd(): boolean {
|
||||||
return (
|
return this.isAny($T.Colon, $T.Semicolon, $T.Newline, $T.CloseParen, $T.CloseBracket) ||
|
||||||
this.isAny($T.Colon, $T.Semicolon, $T.Newline, $T.CloseParen, $T.CloseBracket) ||
|
|
||||||
this.is($T.Operator, '|') ||
|
this.is($T.Operator, '|') ||
|
||||||
this.isExprEndKeyword() ||
|
this.isExprEndKeyword() || !this.current()
|
||||||
!this.current()
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
nextIsExprEnd(): boolean {
|
nextIsExprEnd(): boolean {
|
||||||
// pipes act like expression end for function arg parsing
|
// pipes act like expression end for function arg parsing
|
||||||
if (this.nextIs($T.Operator, '|')) return true
|
if (this.nextIs($T.Operator, '|'))
|
||||||
|
return true
|
||||||
|
|
||||||
return (
|
return this.nextIsAny($T.Colon, $T.Semicolon, $T.Newline, $T.CloseBracket, $T.CloseParen) ||
|
||||||
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, 'end') ||
|
this.nextIs($T.Keyword, 'catch') || this.nextIs($T.Keyword, 'finally') ||
|
||||||
this.nextIs($T.Keyword, 'else') ||
|
|
||||||
this.nextIs($T.Keyword, 'catch') ||
|
|
||||||
this.nextIs($T.Keyword, 'finally') ||
|
|
||||||
!this.peek()
|
!this.peek()
|
||||||
)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
isExprEndKeyword(): boolean {
|
isExprEndKeyword(): boolean {
|
||||||
return (
|
return this.is($T.Keyword, 'end') || this.is($T.Keyword, 'else') ||
|
||||||
this.is($T.Keyword, 'end') ||
|
this.is($T.Keyword, 'catch') || this.is($T.Keyword, 'finally')
|
||||||
this.is($T.Keyword, 'else') ||
|
|
||||||
this.is($T.Keyword, 'catch') ||
|
|
||||||
this.is($T.Keyword, 'finally')
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
isPipe(): boolean {
|
isPipe(): boolean {
|
||||||
// inside parens, only look for pipes on same line (don't look past newlines)
|
// inside parens, only look for pipes on same line (don't look past newlines)
|
||||||
const canLookPastNewlines = this.inParens === 0
|
const canLookPastNewlines = this.inParens === 0
|
||||||
|
|
||||||
return (
|
return this.is($T.Operator, '|') ||
|
||||||
this.is($T.Operator, '|') || (canLookPastNewlines && this.peekPastNewlines($T.Operator, '|'))
|
(canLookPastNewlines && this.peekPastNewlines($T.Operator, '|'))
|
||||||
)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
expect(type: TokenType, value?: string): Token | never {
|
expect(type: TokenType, value?: string): Token | never {
|
||||||
if (!this.is(type, value)) {
|
if (!this.is(type, value)) {
|
||||||
const token = this.current()
|
const token = this.current()
|
||||||
throw new CompilerError(
|
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)
|
||||||
`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()
|
return this.next()
|
||||||
}
|
}
|
||||||
|
|
@ -958,7 +964,7 @@ function collapseDotGets(origNodes: SyntaxNode[]): SyntaxNode {
|
||||||
|
|
||||||
if (left.type.is('Identifier')) left.type = 'IdentifierBeforeDot'
|
if (left.type.is('Identifier')) left.type = 'IdentifierBeforeDot'
|
||||||
|
|
||||||
const dot = new SyntaxNode('DotGet', left.from, right.to)
|
const dot = new SyntaxNode("DotGet", left.from, right.to)
|
||||||
dot.push(left, right)
|
dot.push(left, right)
|
||||||
|
|
||||||
right = dot
|
right = dot
|
||||||
|
|
|
||||||
129
src/parser/parserScopeContext.ts
Normal file
129
src/parser/parserScopeContext.ts
Normal 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 /* = */
|
||||||
|
}
|
||||||
293
src/parser/shrimp.grammar
Normal file
293
src/parser/shrimp.grammar
Normal file
|
|
@ -0,0 +1,293 @@
|
||||||
|
@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" }
|
||||||
|
semicolon { ";" }
|
||||||
|
eof { @eof }
|
||||||
|
space { " " | "\t" }
|
||||||
|
Comment { "#" ![\n]* }
|
||||||
|
leftParen { "(" }
|
||||||
|
rightParen { ")" }
|
||||||
|
colon[closedBy="end", @name="colon"] { ":" }
|
||||||
|
Underscore { "_" }
|
||||||
|
Dollar { "$" }
|
||||||
|
Regex { "//" (![/\\\n[] | "\\" ![\n] | "[" (![\n\\\]] | "\\" ![\n])* "]")+ ("//" $[gimsuy]*)? } // Stolen from the lezer JavaScript grammar
|
||||||
|
"|"[@name=operator]
|
||||||
|
}
|
||||||
|
|
||||||
|
newlineOrSemicolon { newline | semicolon }
|
||||||
|
|
||||||
|
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 tokens pipeStartsLineTokenizer from "./tokenizer" { newline, pipeStartsLine }
|
||||||
|
@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 (pipeStartsLine? "|") newlineOrSemicolon* 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 (DotGet | Number | Identifier | ParenExpr) |
|
||||||
|
Dollar dot (DotGet | Number | Identifier | ParenExpr)
|
||||||
|
}
|
||||||
|
|
||||||
|
String {
|
||||||
|
"'" stringContent* "'" | CurlyString | DoubleQuote
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
stringContent {
|
||||||
|
StringFragment |
|
||||||
|
Interpolation |
|
||||||
|
EscapeSeq
|
||||||
|
}
|
||||||
|
|
||||||
|
Interpolation {
|
||||||
|
"$" FunctionCallOrIdentifier |
|
||||||
|
"$" 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
4
src/parser/shrimp.grammar.d.ts
vendored
Normal file
|
|
@ -0,0 +1,4 @@
|
||||||
|
declare module '*.grammar' {
|
||||||
|
const content: string
|
||||||
|
export default content
|
||||||
|
}
|
||||||
81
src/parser/shrimp.terms.ts
Normal file
81
src/parser/shrimp.terms.ts
Normal file
|
|
@ -0,0 +1,81 @@
|
||||||
|
// 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,
|
||||||
|
newline = 101,
|
||||||
|
pipeStartsLine = 102,
|
||||||
|
Do = 33,
|
||||||
|
Comment = 34,
|
||||||
|
Program = 35,
|
||||||
|
PipeExpr = 36,
|
||||||
|
WhileExpr = 38,
|
||||||
|
keyword = 84,
|
||||||
|
ConditionalOp = 40,
|
||||||
|
ParenExpr = 41,
|
||||||
|
FunctionCallWithNewlines = 42,
|
||||||
|
DotGet = 43,
|
||||||
|
Number = 44,
|
||||||
|
Dollar = 45,
|
||||||
|
PositionalArg = 46,
|
||||||
|
FunctionDef = 47,
|
||||||
|
Params = 48,
|
||||||
|
NamedParam = 49,
|
||||||
|
NamedArgPrefix = 50,
|
||||||
|
String = 51,
|
||||||
|
StringFragment = 52,
|
||||||
|
Interpolation = 53,
|
||||||
|
FunctionCallOrIdentifier = 54,
|
||||||
|
EscapeSeq = 55,
|
||||||
|
DoubleQuote = 56,
|
||||||
|
Boolean = 57,
|
||||||
|
Null = 58,
|
||||||
|
colon = 59,
|
||||||
|
CatchExpr = 60,
|
||||||
|
Block = 62,
|
||||||
|
FinallyExpr = 63,
|
||||||
|
Underscore = 66,
|
||||||
|
NamedArg = 67,
|
||||||
|
IfExpr = 68,
|
||||||
|
FunctionCall = 70,
|
||||||
|
ElseIfExpr = 71,
|
||||||
|
ElseExpr = 73,
|
||||||
|
BinOp = 74,
|
||||||
|
Regex = 75,
|
||||||
|
Dict = 76,
|
||||||
|
Array = 77,
|
||||||
|
FunctionCallWithBlock = 78,
|
||||||
|
TryExpr = 79,
|
||||||
|
Throw = 81,
|
||||||
|
Import = 83,
|
||||||
|
CompoundAssign = 85,
|
||||||
|
Assign = 86
|
||||||
27
src/parser/shrimp.ts
Normal file
27
src/parser/shrimp.ts
Normal 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, pipeStartsLineTokenizer, specializeKeyword} from "./tokenizer"
|
||||||
|
import {trackScope} from "./parserScopeContext"
|
||||||
|
import {highlighting} from "./highlight"
|
||||||
|
const spec_Identifier = {__proto__:null,while:78, null:116, catch:122, finally:128, end:130, if:138, else:144, try:160, throw:164, import:168}
|
||||||
|
export const parser = LRParser.deserialize({
|
||||||
|
version: 14,
|
||||||
|
states: "?[QYQ!SOOOOQ!Q'#Ek'#EkO!sO!bO'#DXO%kQ!TO'#DdO&UOSO'#DaOOQ!R'#Da'#DaO)SQ!TO'#EnOOQ!Q'#E{'#E{O)pQRO'#DxO+xQ!TO'#EjO,fQ!SO'#DVOOQ!R'#Dz'#DzO/WQ!SO'#D{OOQ!R'#En'#EnO/_Q!TO'#EnO1cQ!TO'#EmO2qQ!TO'#EjO3OQRO'#ETOOQ!Q'#Ej'#EjO3gQ!SO'#EjO3nQrO'#EiOOQ!Q'#Ei'#EiOOQ!Q'#EV'#EVQYQ!SOOO4PQbO'#D]O4[QbO'#DrO5YQbO'#DSO6WQQO'#D}O5YQbO'#EPO6]QbO'#ERO6eObO,59sOOQ!Q'#D['#D[O6vQbO'#DqOOQ!Q'#Eq'#EqOOQ!Q'#E_'#E_O7QQ!SO,5:`OOQ!R'#Em'#EmO8QQbO'#DcO8`QWO'#DeOOOO'#Es'#EsOOOO'#E['#E[O8tOSO,59{OOQ!R,59{,59{O5YQbO,5:dO5YQbO,5:dO5YQbO,5:dO5YQbO,5:dO5YQbO,59pO5YQbO,59pO5YQbO,59pO5YQbO,59pOOQ!Q'#EX'#EXO,fQ!SO,59qO9SQ!TO'#DdO9^Q!TO'#EnO9hQsO,59qO9uQQO,59qO9zQrO,59qO:VQrO,59qO:eQsO,59qO;TQsO,59qO;[QrO'#DQO;dQ!SO,5:gO;kQrO,5:fOOQ!R,5:g,5:gO;yQ!SO,5:gO<WQbO,5:pO<WQbO,5:oOYQ!SO,5:hO=kQ!SO,59lOOQ!Q,5;T,5;TOYQ!SO'#EWO>]QQO'#EWOOQ!Q-E8T-E8TOOQ!Q'#EY'#EYO>bQbO'#D^O>mQbO'#D_OOQO'#EZ'#EZO>eQQO'#D^O?RQQO,59wO?WQcO'#EmO@TQRO'#EzOAQQRO'#EzOOQO'#Ez'#EzOAXQQO,5:^OA^QRO,59nOAeQRO,59nOYQ!SO,5:iOAsQ!TO,5:kOCXQ!TO,5:kOC{Q!TO,5:kODYQ!SO,5:mOOQ!Q'#Ec'#EcO6]QbO,5:mOOQ!R1G/_1G/_OOQ!Q,5:],5:]OOQ!Q-E8]-E8]OOOO'#Dd'#DdOOOO,59},59}OOOO,5:P,5:POOOO-E8Y-E8YOOQ!R1G/g1G/gOOQ!R1G0O1G0OOF_Q!TO1G0OOFiQ!TO1G0OOG}Q!TO1G0OOHXQ!TO1G0OOHfQ!TO1G0OOOQ!R1G/[1G/[OI}Q!TO1G/[OJUQ!TO1G/[OJ]Q!TO1G/[OKbQ!TO1G/[OJdQ!TO1G/[OOQ!Q-E8V-E8VOKxQsO1G/]OLVQQO1G/]OL[QrO1G/]OLgQrO1G/]OLuQsO1G/]OL|QsO1G/]OMTQ!SO,59rOM_QrO1G/]OOQ!R1G/]1G/]OMjQrO1G0QOOQ!R1G0R1G0ROMxQ!SO1G0ROOQp'#Ea'#EaOMjQrO1G0QOOQ!R1G0Q1G0QOOQ!Q'#Eb'#EbOMxQ!SO1G0RONVQ!SO1G0[ONwQ!SO1G0ZO! iQ!SO'#DlO! }Q!SO'#DlO!!_QbO1G0SOOQ!Q-E8U-E8UOYQ!SO,5:rOOQ!Q,5:r,5:rOYQ!SO,5:rOOQ!Q-E8W-E8WO!!jQQO,59xOOQO,59y,59yOOQO-E8X-E8XOYQ!SO1G/cOYQ!SO1G/xOYQ!SO1G/YO!!rQbO1G0TO!!}Q!SO1G0XO!#rQ!SO1G0XOOQ!Q-E8a-E8aO!#yQrO7+$wOOQ!R7+$w7+$wO!$UQrO1G/^O!$aQrO7+%lOOQ!R7+%l7+%lO!$oQ!SO7+%mOOQ!R7+%m7+%mOOQp-E8_-E8_OOQ!Q-E8`-E8`OOQ!Q'#E]'#E]O!$|QrO'#E]O!%[Q!SO'#EyOOQ`,5:W,5:WO!%lQbO'#DjO!%qQQO'#DmOOQ!Q7+%n7+%nO!%vQbO7+%nO!%{QbO7+%nOOQ!Q1G0^1G0^OYQ!SO1G0^O!&TQ!SO7+$}O!&fQ!SO7+$}O!&sQbO7+%dO!&{QbO7+$tOOQ!Q7+%o7+%oO!'QQbO7+%oO!'VQbO7+%oO!'_Q!SO7+%sOOQ!R<<Hc<<HcO!(SQ!SO7+$xO!(aQrO7+$xOOQ!R<<IW<<IWOOQ!R<<IX<<IXOOQ!Q,5:w,5:wOOQ!Q-E8Z-E8ZO!(lQQO,5:UOYQ!SO,5:XOOQ!Q<<IY<<IYO!(qQbO<<IYOOQ!Q7+%x7+%xOOQ!Q<<Hi<<HiO!(vQbO<<HiO!({QbO<<HiO!)TQbO<<HiOOQ`'#E`'#E`O!)`QbO<<IOO!)hQbO'#DwOOQ!Q<<IO<<IOO!)pQbO<<IOOOQ!Q<<H`<<H`OOQ!Q<<IZ<<IZO!)uQbO<<IZOOQp,5:x,5:xO!)zQ!SO<<HdOOQp-E8[-E8[OYQ!SO1G/pOOQ`1G/s1G/sOOQ!QAN>tAN>tOOQ!QAN>TAN>TO!*XQbOAN>TO!*^QbOAN>TOOQ`-E8^-E8^OOQ!QAN>jAN>jO!*fQbOAN>jO4[QbO,5:aOYQ!SO,5:cOOQ!QAN>uAN>uPMTQ!SO'#EXOOQ`7+%[7+%[OOQ!QG23oG23oO!*kQbOG23oP!)kQbO'#DuOOQ!QG24UG24UO!*pQQO1G/{OOQ`1G/}1G/}OOQ!QLD)ZLD)ZOYQ!SO7+%gOOQ`<<IR<<IRO!*uObO,59sO!+WO!bO'#DX",
|
||||||
|
stateData: "!+`~O#[OSrOS~OlROmaOn]OoQOpTOqhOwjO|]O}QO!YTO!Z]O![]O!giO!m]O!rkO!tlO!vmO#XPO#`PO#cYO#fSO#qZO#r[O~O#dnO~OltOn]OoQOpTOqhO|]O}QO!SpO!YTO!Z]O![]O!doO!m]O#cYO#fSO#qZO#r[OP#aXQ#aXR#aXS#aXT#aXU#aXW#aXX#aXY#aXZ#aX[#aX]#aX^#aXd#aXe#aXf#aXg#aXh#aXi#aXj#aXu!WX!]!WX#Y!WX#p!WX~O#X!WX#`!WX#t!WX!_!WX!b!WX!c!WX!j!WX~P!xO!UwO#fzO#huO#ivO~OltOn]OoQOpTOqhO|]O}QO!SpO!YTO!Z]O![]O!doO!m]O#cYO#fSO#qZO#r[OP#bXQ#bXR#bXS#bXT#bXU#bXW#bXX#bXY#bXZ#bX[#bX]#bX^#bXd#bXe#bXf#bXg#bXh#bXi#bXj#bXu#bX#Y#bX#p#bX~O#X#bX#`#bX#t#bX!]#bX!_#bX!b#bX!c#bX!j#bX~P&dOP|OQ|OR}OS}OT!QOU!ROW!POX!POY!POZ!PO[!PO]!PO^{Od!OOe!OOf!OOg!OOh!OOi!OOj!SO~OP|OQ|OR}OS}Od!OOe!OOf!OOg!OOh!OOi!OOu#^X#Y#^X~O#X#^X#`#^X#t#^X!_#^X!b#^X!c#^X#p#^X!j#^X~P+QOl!VOmaOn]OoQOpTOqhOwjO|]O}QO!YTO!Z]O![]O!giO!m]O!rkO!tlO!vmO#XPO#`PO#cYO#fSO#qZO#r[O~OltOn]OoQOpTO|]O}QO!SpO!YTO!Z]O![]O!m]O#XPO#`PO#cYO#fSO#qZO#r[O~O#s!bO~P.POV!dO#X#bX#`#bX#t#bX!_#bX!b#bX!c#bX!j#bX~P'iOP#aXQ#aXR#aXS#aXT#aXU#aXW#aXX#aXY#aXZ#aX[#aX]#aX^#aXd#aXe#aXf#aXg#aXh#aXi#aXj#aXu#^X#Y#^X~O#X#^X#`#^X#t#^X!_#^X!b#^X!c#^X#p#^X!j#^X~P/{Ou#^X#X#^X#Y#^X#`#^X#t#^X!_#^X!b#^X!c#^X#p#^X!j#^X~OT!QOU!ROj!SO~P2POV!dO_!eO`!eOa!eOb!eOc!eOk!eO~O!]!fO~P2POu!iO#XPO#Y!jO#`PO#t!hO~Ol!lO!S!nO!]!QP~Ol!rOn]OoQOpTO|]O}QO!YTO!Z]O![]O!m]O#cYO#fSO#qZO#r[O~OltOn]OoQOpTO|]O}QO!YTO!Z]O![]O!m]O#cYO#fSO#qZO#r[O~O!]!yO~Ol!lO!SpO~Ol#QOoQO|#QO}QO#cYO~OqhO!d#RO~P5YOqhO!SpO!doOu!ha!]!ha#X!ha#Y!ha#`!ha#t!ha#p!ha!_!ha!b!ha!c!ha!j!ha~P5YOl#TOo&PO}&PO#cYO~O#f#VO#h#VO#i#VO#j#VO#k#VO#l#VO~O!UwO#f#XO#huO#ivO~O#XPO#`PO~P!xO#XPO#`PO~P&dO#XPO#`PO#p#oO~P+QO#p#oO~O#p#oOu#^X#Y#^X~O!]!fO#p#oOu#^X#Y#^X~O#p#oO~P/{OT!QOU!ROj!SO#XPO#`POu#^X#Y#^X~O#p#oO~P:lOu!iO#Y!jO~O#s#qO~P.PO!SpO#XPO#`PO#s#uO~O#XPO#`PO#s#qO~P5YOlROmaOn]OoQOpTOqhOwjO|]O}QO!YTO!Z]O![]O!giO!m]O!rkO!tlO!vmO#cYO#fSO#qZO#r[O~Ou!iO#Y!jO#Xta#`ta#tta#pta!_ta!bta!cta!jta~Ou$QO~Ol!lO!S!nO!]!QX~OpTO|$TO!YTO!Z$TO![$TO#fSO~O!]$VO~OqhO!SpO!doOT#aXU#aXW#aXX#aXY#aXZ#aX[#aX]#aXj#aX!]#aX~P5YOT!QOU!ROj!SO!]#nX~OT!QOU!ROW!POX!POY!POZ!PO[!PO]!POj!SO~O!]#nX~P@cO!]$WO~O!]$XO~P@cOT!QOU!ROj!SO!]$XO~Ou!sa#X!sa#Y!sa#`!sa#t!sa!_!sa!b!sa!c!sa#p!sa!j!sa~P)pOu!sa#X!sa#Y!sa#`!sa#t!sa!_!sa!b!sa!c!sa#p!sa!j!sa~OP|OQ|OR}OS}Od!OOe!OOf!OOg!OOh!OOi!OO~PBgOT!QOU!ROj!SO~PBgOl!lO!SpOu!ua#X!ua#Y!ua#`!ua#t!ua!_!ua!b!ua!c!ua#p!ua!j!ua~O^{OR!liS!lid!lie!lif!lig!lih!lii!liu!li#X!li#Y!li#`!li#t!li#p!li!_!li!b!li!c!li!j!li~OP!liQ!li~PEQOP|OQ|O~PEQOP|OQ|Od!lie!lif!lig!lih!lii!liu!li#X!li#Y!li#`!li#t!li#p!li!_!li!b!li!c!li!j!li~OR!liS!li~PFsOR}OS}O^{O~PFsOR}OS}O~PFsOW!POX!POY!POZ!PO[!PO]!POTxijxiuxi#Xxi#Yxi#`xi#txi#pxi!]xi!_xi!bxi!cxi!jxi~OU!RO~PHpOU!RO~PISOUxi~PHpOT!QOU!ROjxiuxi#Xxi#Yxi#`xi#txi#pxi!]xi!_xi!bxi!cxi!jxi~OW!POX!POY!POZ!PO[!PO]!PO~PJdO#XPO#`PO#p$_O~P+QO#p$_O~O#p$_Ou#^X#Y#^X~O!]!fO#p$_Ou#^X#Y#^X~O#p$_O~P/{O#p$_O~P:lOqhO!doO~P.PO#XPO#`PO#p$_O~O!SpO#XPO#`PO#s$bO~O#XPO#`PO#s$dO~P5YOu!iO#Y!jO#X!xi#`!xi#t!xi!_!xi!b!xi!c!xi#p!xi!j!xi~Ou!iO#Y!jO#X!wi#`!wi#t!wi!_!wi!b!wi!c!wi#p!wi!j!wi~Ou!iO#Y!jO!_!`X!b!`X!c!`X!j!`X~O!_#mP!b#mP!c#mP!j#mP~PYO!_$kO!b$lO!c$mO~O!S!nO!]!Qa~O!_$kO!b$lO!c$vO~O!SpOu!ui#X!ui#Y!ui#`!ui#t!ui!_!ui!b!ui!c!ui#p!ui!j!ui~Ol!lO~P!!}O#XPO#`PO#p$zO~O#XPO#`PO#pzi~O!SpO#XPO#`PO#s$}O~O#XPO#`PO#s%OO~P5YOu!iO#XPO#Y!jO#`PO~O!_#mX!b#mX!c#mX!j#mX~PYOl%RO~O!]%SO~O!c%TO~O!b$lO!c%TO~Ou!iO!_$kO!b$lO!c%WO#Y!jO~O!_#mP!b#mP!c#mP~PYO!c%_O!j%^O~O!c%aO~O!c%bO~O!b$lO!c%bO~O!SpOu!uq#X!uq#Y!uq#`!uq#t!uq!_!uq!b!uq!c!uq#p!uq!j!uq~OqhO!doO#pzq~P.PO#XPO#`PO#pzq~O!]%gO~O!c%iO~O!c%jO~O!b$lO!c%jO~O!_$kO!b$lO!c%jO~O!c%nO!j%^O~O!]%qO!g%pO~O!c%nO~O!c%rO~OqhO!doO#pzy~P.PO!c%uO~O!b$lO!c%uO~O!c%xO~O!c%{O~O!]%|O~Ol#QOo&PO|#QO}&PO#cYO~O#d&OO~O|!m~",
|
||||||
|
goto: "<[#pPPPPPPPPPPPPPPPPPPPPPPPPPPPPPPPPPPPP#qP$_P$w%x'['bPP(v)S*P*SP*YP+d+h+dPPPP,TP,a,yPPP-a#qP.R.oP.s.yP/s0z$_$_P$_P$_P$_$_2T2Z2g3c3q3{4R4Y4`4j4p4z5UPPPPP5d5h6dP7v9oPP:|P;^PPPPP;b;h;nxbOg!d!e!f!i!y#{$O$Q$V$W$X$i$q$s%S%g%q%|Q!ZYR#i!U}bOYg!U!d!e!f!i!y#{$O$Q$V$W$X$i$q$s%S%g%q%|x`Og!d!e!f!i!y#{$O$Q$V$W$X$i$q$s%S%g%q%|Q!^YS!si%pQ!xjQ!|lQ#`!RQ#b!QQ#e!SR#l!U|UOgi!d!e!f!i!y#{$O$Q$V$W$X$i$q$s%S%g%p%q%|!W]RU[jlps{|}!O!P!Q!R!S!V!W!`!c!r#m#r#w$c${%e%sS!WY!US#Qn&OR#UuQ!YYR#h!UxROg!d!e!f!i!y#{$O$Q$V$W$X$i$q$s%S%g%q%|!WtRU[jlps{|}!O!P!Q!R!S!V!W!`!c!r#m#r#w$c${%e%sS!VY!US!ri%pS#Qn&OR#TueqRUs!V!W!r#m${%e%sxbOg!d!e!f!i!y#{$O$Q$V$W$X$i$q$s%S%g%q%|doRUs!V!W!r#m${%e%sQ!ZYQ#RpR#i!UR!qhX!oh!m!p$S#Y]ORUY[gijlps{|}!O!P!Q!R!S!U!V!W!`!c!d!e!f!i!r!y#m#r#w#{$O$Q$V$W$X$c$i$q$s${%S%e%g%p%q%s%|R$T!nTwSy|VOYg!U!d!e!f!i!y#{$O$Q$V$W$X$i$q$s%S%g%q%|R#UuQ$o#|Q$x$YQ%Y$rR%l%ZQ#|!fQ$Y!yQ$t$WQ$u$XQ%h%SQ%t%gQ%z%qR%}%|Q$n#|Q$w$YQ%U$oQ%X$rQ%c$xS%k%Y%ZR%v%ldqRUs!V!W!r#m${%e%sQ!a[[#Om!}#P$Z$[$yQ#p!`X#s!a#p#t$a|VOYg!U!d!e!f!i!y#{$O$Q$V$W$X$i$q$s%S%g%q%|T!ui%pT%[$t%]Q%`$tR%o%]xXOg!d!e!f!i!y#{$O$Q$V$W$X$i$q$s%S%g%q%|Q!XYQ!{lQ#Y|Q#]}Q#_!OR#g!U#Z]ORUY[gijlps{|}!O!P!Q!R!S!U!V!W!`!c!d!e!f!i!r!y#m#r#w#{$O$Q$V$W$X$c$i$q$s${%S%e%g%p%q%s%|![]RU[ijlps{|}!O!P!Q!R!S!V!W!`!c!r#m#r#w$c${%e%p%s}^OYg!U!d!e!f!i!y#{$O$Q$V$W$X$i$q$s%S%g%q%|QgOR!kg^!gd!_#x#y#z$h$rR#}!gQ!UYQ!`[d#f!U!`#m#n$O$^$q${%e%sS#m!V!WS#n!X!^Q$O!iS$^#g#lQ$q$QQ${$`R%e$|Q!mhQ!}mU$R!m!}$[R$[#PQ!phQ$S!mT$U!p$SQySR#WyS$i#{$sR%Q$iQ$|$`R%f$|YsRU!V!W!rR#SsQ%]$tR%m%]Q#t!aQ$a#pT$e#t$aQ#w!cQ$c#rT$f#w$cQ#PmQ$Z!}U$]#P$Z$yR$y$[TfOgSdOgS!_Y!UQ#x!dQ#y!e`#z!f!y$W$X%S%g%q%|Q$P!iU$h#{$i$sS$p$O$QQ$r$VR%V$qSeOg|!TY[!U!V!W!X!^!`!i#g#l#m#n$O$Q$^$`$q${$|%e%sQ!hdW#s!a#p#t$aW#v!c#r#w$c`#{!f!y$W$X%S%g%q%|U$g#{$i$sQ$s$VR%P$h|WOYg!U!d!e!f!i!y#{$O$Q$V$W$X$i$q$s%S%g%q%|doRUs!V!W!r#m${%e%sQ!c[S!ti%pQ!wjQ!zlQ#RpQ#Y{Q#Z|Q#[}Q#^!OQ#`!PQ#a!QQ#c!RQ#d!SQ#r!`X#v!c#r#w$cx_Og!d!e!f!i!y#{$O$Q$V$W$X$i$q$s%S%g%q%|![tRU[ijlps{|}!O!P!Q!R!S!V!W!`!c!r#m#r#w$c${%e%p%sQ!]YR#k!U[rRUs!V!W!rQ$`#mV%d${%e%sTxSyQ$j#{R%Z$sQ!viR%y%pxcOg!d!e!f!i!y#{$O$Q$V$W$X$i$q$s%S%g%q%|Q![YR#j!U",
|
||||||
|
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 Dollar PositionalArg FunctionDef Params NamedParam NamedArgPrefix String StringFragment Interpolation FunctionCallOrIdentifier EscapeSeq DoubleQuote Boolean Null colon CatchExpr keyword Block FinallyExpr keyword keyword Underscore NamedArg IfExpr keyword FunctionCall ElseIfExpr keyword ElseExpr BinOp Regex Dict Array FunctionCallWithBlock TryExpr keyword Throw keyword Import keyword CompoundAssign Assign",
|
||||||
|
maxTerm: 128,
|
||||||
|
context: trackScope,
|
||||||
|
nodeProps: [
|
||||||
|
["closedBy", 59,"end"]
|
||||||
|
],
|
||||||
|
propSources: [highlighting],
|
||||||
|
skippedNodes: [0,34],
|
||||||
|
repeatNodeCount: 13,
|
||||||
|
tokenData: "Lp~R}OX$OXY$mYp$Opq$mqr$Ors%Wst'^tu(uuw$Owx(|xy)Ryz)lz{$O{|*V|}$O}!O*V!O!P$O!P!Q3r!Q!R*w!R![-l![!]<_!]!^<x!^!}$O!}#O=c#O#P?X#P#Q?^#Q#R$O#R#S?w#S#T$O#T#Y@b#Y#ZA|#Z#b@b#b#cGj#c#f@b#f#gHm#g#h@b#h#iIp#i#o@b#o#p$O#p#qLQ#q;'S$O;'S;=`$g<%l~$O~O$O~~LkS$TU!USOt$Ouw$Ox#O$O#P;'S$O;'S;=`$g<%lO$OS$jP;=`<%l$O^$tU!US#[YOt$Ouw$Ox#O$O#P;'S$O;'S;=`$g<%lO$OU%]Z!USOr%Wrs&Ost%Wtu&iuw%Wwx&ix#O%W#O#P&i#P;'S%W;'S;=`'W<%lO%WU&VU!YQ!USOt$Ouw$Ox#O$O#P;'S$O;'S;=`$g<%lO$OQ&lTOr&irs&{s;'S&i;'S;=`'Q<%lO&iQ'QO!YQQ'TP;=`<%l&iU'ZP;=`<%l%W^'eZrY!USOY'^YZ$OZt'^tu(Wuw'^wx(Wx#O'^#O#P(W#P;'S'^;'S;=`(o<%lO'^Y(]SrYOY(WZ;'S(W;'S;=`(i<%lO(WY(lP;=`<%l(W^(rP;=`<%l'^^(|O#h[}Q~)RO#f~U)YU!US#cQOt$Ouw$Ox#O$O#P;'S$O;'S;=`$g<%lO$OU)sU!US#pQOt$Ouw$Ox#O$O#P;'S$O;'S;=`$g<%lO$OU*[X!USOt$Ouw$Ox!Q$O!Q!R*w!R![-l![#O$O#P;'S$O;'S;=`$g<%lO$OU+Ob!US|QOt$Ouw$Ox!O$O!O!P,W!P!Q$O!Q![-l![#O$O#P#R$O#R#S.i#S#U$O#U#V/W#V#c$O#c#d0l#d#l$O#l#m1z#m;'S$O;'S;=`$g<%lO$OU,]W!USOt$Ouw$Ox!Q$O!Q![,u![#O$O#P;'S$O;'S;=`$g<%lO$OU,|Y!US|QOt$Ouw$Ox!Q$O!Q![,u![#O$O#P#R$O#R#S,W#S;'S$O;'S;=`$g<%lO$OU-s[!US|QOt$Ouw$Ox!O$O!O!P,W!P!Q$O!Q![-l![#O$O#P#R$O#R#S.i#S;'S$O;'S;=`$g<%lO$OU.nW!USOt$Ouw$Ox!Q$O!Q![-l![#O$O#P;'S$O;'S;=`$g<%lO$OU/]X!USOt$Ouw$Ox!Q$O!Q!R/x!R!S/x!S#O$O#P;'S$O;'S;=`$g<%lO$OU0PX!US|QOt$Ouw$Ox!Q$O!Q!R/x!R!S/x!S#O$O#P;'S$O;'S;=`$g<%lO$OU0qW!USOt$Ouw$Ox!Q$O!Q!Y1Z!Y#O$O#P;'S$O;'S;=`$g<%lO$OU1bW!US|QOt$Ouw$Ox!Q$O!Q!Y1Z!Y#O$O#P;'S$O;'S;=`$g<%lO$OU2P[!USOt$Ouw$Ox!Q$O!Q![2u![!c$O!c!i2u!i#O$O#P#T$O#T#Z2u#Z;'S$O;'S;=`$g<%lO$OU2|[!US|QOt$Ouw$Ox!Q$O!Q![2u![!c$O!c!i2u!i#O$O#P#T$O#T#Z2u#Z;'S$O;'S;=`$g<%lO$OU3wW!USOt$Ouw$Ox!P$O!P!Q4a!Q#O$O#P;'S$O;'S;=`$g<%lO$OU4f^!USOY5bYZ$OZt5btu6euw5bwx6ex!P5b!P!Q$O!Q!}5b!}#O;W#O#P8s#P;'S5b;'S;=`<X<%lO5bU5i^!US!mQOY5bYZ$OZt5btu6euw5bwx6ex!P5b!P!Q9Y!Q!}5b!}#O;W#O#P8s#P;'S5b;'S;=`<X<%lO5bQ6jX!mQOY6eZ!P6e!P!Q7V!Q!}6e!}#O7t#O#P8s#P;'S6e;'S;=`9S<%lO6eQ7YP!P!Q7]Q7bU!mQ#Z#[7]#]#^7]#a#b7]#g#h7]#i#j7]#m#n7]Q7wVOY7tZ#O7t#O#P8^#P#Q6e#Q;'S7t;'S;=`8m<%lO7tQ8aSOY7tZ;'S7t;'S;=`8m<%lO7tQ8pP;=`<%l7tQ8vSOY6eZ;'S6e;'S;=`9S<%lO6eQ9VP;=`<%l6eU9_W!USOt$Ouw$Ox!P$O!P!Q9w!Q#O$O#P;'S$O;'S;=`$g<%lO$OU:Ob!US!mQOt$Ouw$Ox#O$O#P#Z$O#Z#[9w#[#]$O#]#^9w#^#a$O#a#b9w#b#g$O#g#h9w#h#i$O#i#j9w#j#m$O#m#n9w#n;'S$O;'S;=`$g<%lO$OU;][!USOY;WYZ$OZt;Wtu7tuw;Wwx7tx#O;W#O#P8^#P#Q5b#Q;'S;W;'S;=`<R<%lO;WU<UP;=`<%l;WU<[P;=`<%l5bU<fU!US!]QOt$Ouw$Ox#O$O#P;'S$O;'S;=`$g<%lO$OU=PU!US#`QOt$Ouw$Ox#O$O#P;'S$O;'S;=`$g<%lO$OU=jW#rQ!USOt$Ouw$Ox!_$O!_!`>S!`#O$O#P;'S$O;'S;=`$g<%lO$OU>XV!USOt$Ouw$Ox#O$O#P#Q>n#Q;'S$O;'S;=`$g<%lO$OU>uU#qQ!USOt$Ouw$Ox#O$O#P;'S$O;'S;=`$g<%lO$O~?^O#i~U?eU#sQ!USOt$Ouw$Ox#O$O#P;'S$O;'S;=`$g<%lO$OU@OU!US!dQOt$Ouw$Ox#O$O#P;'S$O;'S;=`$g<%lO$OU@g^!USOt$Ouw$Ox}$O}!O@b!O!Q$O!Q![@b![!_$O!_!`Ac!`#O$O#P#T$O#T#o@b#o;'S$O;'S;=`$g<%lO$OUAjU!SQ!USOt$Ouw$Ox#O$O#P;'S$O;'S;=`$g<%lO$OUBR_!USOt$Ouw$Ox}$O}!O@b!O!Q$O!Q![@b![!_$O!_!`Ac!`#O$O#P#T$O#T#UCQ#U#o@b#o;'S$O;'S;=`$g<%lO$OUCV`!USOt$Ouw$Ox}$O}!O@b!O!Q$O!Q![@b![!_$O!_!`Ac!`#O$O#P#T$O#T#`@b#`#aDX#a#o@b#o;'S$O;'S;=`$g<%lO$OUD^`!USOt$Ouw$Ox}$O}!O@b!O!Q$O!Q![@b![!_$O!_!`Ac!`#O$O#P#T$O#T#g@b#g#hE`#h#o@b#o;'S$O;'S;=`$g<%lO$OUEe`!USOt$Ouw$Ox}$O}!O@b!O!Q$O!Q![@b![!_$O!_!`Ac!`#O$O#P#T$O#T#X@b#X#YFg#Y#o@b#o;'S$O;'S;=`$g<%lO$OUFn^!ZQ!USOt$Ouw$Ox}$O}!O@b!O!Q$O!Q![@b![!_$O!_!`Ac!`#O$O#P#T$O#T#o@b#o;'S$O;'S;=`$g<%lO$O^Gq^#jW!USOt$Ouw$Ox}$O}!O@b!O!Q$O!Q![@b![!_$O!_!`Ac!`#O$O#P#T$O#T#o@b#o;'S$O;'S;=`$g<%lO$O^Ht^#lW!USOt$Ouw$Ox}$O}!O@b!O!Q$O!Q![@b![!_$O!_!`Ac!`#O$O#P#T$O#T#o@b#o;'S$O;'S;=`$g<%lO$O^Iw`#kW!USOt$Ouw$Ox}$O}!O@b!O!Q$O!Q![@b![!_$O!_!`Ac!`#O$O#P#T$O#T#f@b#f#gJy#g#o@b#o;'S$O;'S;=`$g<%lO$OUKO`!USOt$Ouw$Ox}$O}!O@b!O!Q$O!Q![@b![!_$O!_!`Ac!`#O$O#P#T$O#T#i@b#i#jE`#j#o@b#o;'S$O;'S;=`$g<%lO$OULXUuQ!USOt$Ouw$Ox#O$O#P;'S$O;'S;=`$g<%lO$O~LpO#t~",
|
||||||
|
tokenizers: [operatorTokenizer, 1, 2, 3, tokenizer, pipeStartsLineTokenizer, new LocalTokenGroup("[~RP!O!PU~ZO#d~~", 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: 2589
|
||||||
|
})
|
||||||
|
|
@ -39,18 +39,11 @@ export const parseString = (input: string, from: number, to: number, parser: any
|
||||||
* Parse single-quoted string: 'hello $name\n'
|
* Parse single-quoted string: 'hello $name\n'
|
||||||
* Supports: interpolation ($var, $(expr)), escape sequences (\n, \$, etc)
|
* Supports: interpolation ($var, $(expr)), escape sequences (\n, \$, etc)
|
||||||
*/
|
*/
|
||||||
const parseSingleQuoteString = (
|
const parseSingleQuoteString = (stringNode: SyntaxNode, input: string, from: number, to: number, parser: any) => {
|
||||||
stringNode: SyntaxNode,
|
|
||||||
input: string,
|
|
||||||
from: number,
|
|
||||||
to: number,
|
|
||||||
parser: any,
|
|
||||||
) => {
|
|
||||||
let pos = from + 1 // Skip opening '
|
let pos = from + 1 // Skip opening '
|
||||||
let fragmentStart = pos
|
let fragmentStart = pos
|
||||||
|
|
||||||
while (pos < to - 1) {
|
while (pos < to - 1) { // -1 to skip closing '
|
||||||
// -1 to skip closing '
|
|
||||||
const char = input[pos]
|
const char = input[pos]
|
||||||
|
|
||||||
// Escape sequence
|
// Escape sequence
|
||||||
|
|
@ -122,13 +115,7 @@ const parseSingleQuoteString = (
|
||||||
* Supports: interpolation ($var, $(expr)), nested braces
|
* Supports: interpolation ($var, $(expr)), nested braces
|
||||||
* Does NOT support: escape sequences (raw content)
|
* Does NOT support: escape sequences (raw content)
|
||||||
*/
|
*/
|
||||||
const parseCurlyString = (
|
const parseCurlyString = (stringNode: SyntaxNode, input: string, from: number, to: number, parser: any) => {
|
||||||
stringNode: SyntaxNode,
|
|
||||||
input: string,
|
|
||||||
from: number,
|
|
||||||
to: number,
|
|
||||||
parser: any,
|
|
||||||
) => {
|
|
||||||
let pos = from + 1 // Skip opening {
|
let pos = from + 1 // Skip opening {
|
||||||
let fragmentStart = from // Include the opening { in the fragment
|
let fragmentStart = from // Include the opening { in the fragment
|
||||||
let depth = 1
|
let depth = 1
|
||||||
|
|
@ -201,11 +188,7 @@ const parseCurlyString = (
|
||||||
* Returns the parsed expression node and the position after the closing )
|
* Returns the parsed expression node and the position after the closing )
|
||||||
* pos is position of the opening ( in the full input string
|
* pos is position of the opening ( in the full input string
|
||||||
*/
|
*/
|
||||||
const parseInterpolationExpr = (
|
const parseInterpolationExpr = (input: string, pos: number, parser: any): { node: SyntaxNode, endPos: number } => {
|
||||||
input: string,
|
|
||||||
pos: number,
|
|
||||||
parser: any,
|
|
||||||
): { node: SyntaxNode; endPos: number } => {
|
|
||||||
// Find matching closing paren
|
// Find matching closing paren
|
||||||
let depth = 1
|
let depth = 1
|
||||||
let start = pos
|
let start = pos
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,7 @@
|
||||||
import { expect, describe, test } from 'bun:test'
|
import { expect, describe, test } from 'bun:test'
|
||||||
|
|
||||||
|
import '../shrimp.grammar' // Importing this so changes cause it to retest!
|
||||||
|
|
||||||
describe('null', () => {
|
describe('null', () => {
|
||||||
test('parses null', () => {
|
test('parses null', () => {
|
||||||
expect('null').toMatchTree(`Null null`)
|
expect('null').toMatchTree(`Null null`)
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,7 @@
|
||||||
import { expect, describe, test } from 'bun:test'
|
import { expect, describe, test } from 'bun:test'
|
||||||
|
|
||||||
|
import '../shrimp.grammar' // Importing this so changes cause it to retest!
|
||||||
|
|
||||||
describe('bitwise operators - grammar', () => {
|
describe('bitwise operators - grammar', () => {
|
||||||
test('parses band (bitwise AND)', () => {
|
test('parses band (bitwise AND)', () => {
|
||||||
expect('5 band 3').toMatchTree(`
|
expect('5 band 3').toMatchTree(`
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,7 @@
|
||||||
import { expect, describe, test } from 'bun:test'
|
import { expect, describe, test } from 'bun:test'
|
||||||
|
|
||||||
|
import '../shrimp.grammar' // Importing this so changes cause it to retest!
|
||||||
|
|
||||||
describe('if/else if/else', () => {
|
describe('if/else if/else', () => {
|
||||||
test('parses single line if', () => {
|
test('parses single line if', () => {
|
||||||
expect(`if y == 1: 'cool' end`).toMatchTree(`
|
expect(`if y == 1: 'cool' end`).toMatchTree(`
|
||||||
|
|
@ -195,6 +197,7 @@ describe('if/else if/else', () => {
|
||||||
`)
|
`)
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|
||||||
test('parses 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(`
|
expect(`if false: true else if var? 'abc': true end`).toMatchTree(`
|
||||||
IfExpr
|
IfExpr
|
||||||
|
|
@ -285,6 +288,7 @@ describe('while', () => {
|
||||||
keyword end`)
|
keyword end`)
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|
||||||
test('compound expression', () => {
|
test('compound expression', () => {
|
||||||
expect(`while a > 0 and b < 100 and c < 1000: true end`).toMatchTree(`
|
expect(`while a > 0 and b < 100 and c < 1000: true end`).toMatchTree(`
|
||||||
WhileExpr
|
WhileExpr
|
||||||
|
|
@ -342,6 +346,7 @@ describe('while', () => {
|
||||||
keyword end`)
|
keyword end`)
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|
||||||
test('multiline compound expression', () => {
|
test('multiline compound expression', () => {
|
||||||
expect(`
|
expect(`
|
||||||
while a > 0 and b < 100 and c < 1000:
|
while a > 0 and b < 100 and c < 1000:
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,7 @@
|
||||||
import { expect, describe, test } from 'bun:test'
|
import { expect, describe, test } from 'bun:test'
|
||||||
|
|
||||||
|
import '../shrimp.grammar' // Importing this so changes cause it to retest!
|
||||||
|
|
||||||
describe('Array destructuring', () => {
|
describe('Array destructuring', () => {
|
||||||
test('parses array pattern with two variables', () => {
|
test('parses array pattern with two variables', () => {
|
||||||
expect('[ a b ] = [ 1 2 3 4]').toMatchTree(`
|
expect('[ a b ] = [ 1 2 3 4]').toMatchTree(`
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,7 @@
|
||||||
import { expect, describe, test } from 'bun:test'
|
import { expect, describe, test } from 'bun:test'
|
||||||
|
|
||||||
|
import '../shrimp.grammar' // Importing this so changes cause it to retest!
|
||||||
|
|
||||||
describe('try/catch/finally/throw', () => {
|
describe('try/catch/finally/throw', () => {
|
||||||
test('parses try with catch', () => {
|
test('parses try with catch', () => {
|
||||||
expect(`try:
|
expect(`try:
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,7 @@
|
||||||
import { expect, describe, test } from 'bun:test'
|
import { expect, describe, test } from 'bun:test'
|
||||||
|
|
||||||
|
import '../shrimp.grammar' // Importing this so changes cause it to retest!
|
||||||
|
|
||||||
describe('single line function blocks', () => {
|
describe('single line function blocks', () => {
|
||||||
test('work with no args', () => {
|
test('work with no args', () => {
|
||||||
expect(`trap: echo bye bye end`).toMatchTree(`
|
expect(`trap: echo bye bye end`).toMatchTree(`
|
||||||
|
|
@ -14,7 +16,8 @@ describe('single line function blocks', () => {
|
||||||
Identifier bye
|
Identifier bye
|
||||||
PositionalArg
|
PositionalArg
|
||||||
Identifier bye
|
Identifier bye
|
||||||
keyword end`)
|
keyword end`
|
||||||
|
)
|
||||||
})
|
})
|
||||||
|
|
||||||
test('work with one arg', () => {
|
test('work with one arg', () => {
|
||||||
|
|
@ -32,7 +35,8 @@ describe('single line function blocks', () => {
|
||||||
Identifier bye
|
Identifier bye
|
||||||
PositionalArg
|
PositionalArg
|
||||||
Identifier bye
|
Identifier bye
|
||||||
keyword end`)
|
keyword end`
|
||||||
|
)
|
||||||
})
|
})
|
||||||
|
|
||||||
test('work with named args', () => {
|
test('work with named args', () => {
|
||||||
|
|
@ -52,9 +56,11 @@ describe('single line function blocks', () => {
|
||||||
Identifier bye
|
Identifier bye
|
||||||
PositionalArg
|
PositionalArg
|
||||||
Identifier bye
|
Identifier bye
|
||||||
keyword end`)
|
keyword end`
|
||||||
|
)
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|
||||||
test('work with dot-get', () => {
|
test('work with dot-get', () => {
|
||||||
expect(`signals = [=]; signals.trap 'EXIT': echo bye bye end`).toMatchTree(`
|
expect(`signals = [=]; signals.trap 'EXIT': echo bye bye end`).toMatchTree(`
|
||||||
Assign
|
Assign
|
||||||
|
|
@ -77,7 +83,8 @@ describe('single line function blocks', () => {
|
||||||
Identifier bye
|
Identifier bye
|
||||||
PositionalArg
|
PositionalArg
|
||||||
Identifier bye
|
Identifier bye
|
||||||
keyword end`)
|
keyword end`
|
||||||
|
)
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|
@ -99,7 +106,8 @@ end
|
||||||
Identifier bye
|
Identifier bye
|
||||||
PositionalArg
|
PositionalArg
|
||||||
Identifier bye
|
Identifier bye
|
||||||
keyword end`)
|
keyword end`
|
||||||
|
)
|
||||||
})
|
})
|
||||||
|
|
||||||
test('work with one arg', () => {
|
test('work with one arg', () => {
|
||||||
|
|
@ -120,7 +128,8 @@ end`).toMatchTree(`
|
||||||
Identifier bye
|
Identifier bye
|
||||||
PositionalArg
|
PositionalArg
|
||||||
Identifier bye
|
Identifier bye
|
||||||
keyword end`)
|
keyword end`
|
||||||
|
)
|
||||||
})
|
})
|
||||||
|
|
||||||
test('work with named args', () => {
|
test('work with named args', () => {
|
||||||
|
|
@ -146,9 +155,11 @@ end`).toMatchTree(`
|
||||||
Identifier bye
|
Identifier bye
|
||||||
PositionalArg
|
PositionalArg
|
||||||
Identifier bye
|
Identifier bye
|
||||||
keyword end`)
|
keyword end`
|
||||||
|
)
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|
||||||
test('work with dot-get', () => {
|
test('work with dot-get', () => {
|
||||||
expect(`
|
expect(`
|
||||||
signals = [=]
|
signals = [=]
|
||||||
|
|
@ -175,7 +186,8 @@ end`).toMatchTree(`
|
||||||
Identifier bye
|
Identifier bye
|
||||||
PositionalArg
|
PositionalArg
|
||||||
Identifier bye
|
Identifier bye
|
||||||
keyword end`)
|
keyword end`
|
||||||
|
)
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|
@ -252,7 +264,8 @@ end`).toMatchTree(`
|
||||||
p:
|
p:
|
||||||
h1 class=bright style='font-family: helvetica' Heya
|
h1 class=bright style='font-family: helvetica' Heya
|
||||||
h2 man that is (b wild)!
|
h2 man that is (b wild)!
|
||||||
end`).toMatchTree(`
|
end`)
|
||||||
|
.toMatchTree(`
|
||||||
FunctionCallWithBlock
|
FunctionCallWithBlock
|
||||||
FunctionCallOrIdentifier
|
FunctionCallOrIdentifier
|
||||||
Identifier p
|
Identifier p
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,7 @@
|
||||||
import { expect, describe, test } from 'bun:test'
|
import { expect, describe, test } from 'bun:test'
|
||||||
|
|
||||||
|
import '../shrimp.grammar' // Importing this so changes cause it to retest!
|
||||||
|
|
||||||
describe('calling functions', () => {
|
describe('calling functions', () => {
|
||||||
test('call with no args', () => {
|
test('call with no args', () => {
|
||||||
expect('tail').toMatchTree(`
|
expect('tail').toMatchTree(`
|
||||||
|
|
@ -92,6 +94,7 @@ describe('calling functions', () => {
|
||||||
`)
|
`)
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|
||||||
test('command with arg that is also a command', () => {
|
test('command with arg that is also a command', () => {
|
||||||
expect('tail tail').toMatchTree(`
|
expect('tail tail').toMatchTree(`
|
||||||
FunctionCall
|
FunctionCall
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,7 @@
|
||||||
import { expect, describe, test } from 'bun:test'
|
import { expect, describe, test } from 'bun:test'
|
||||||
|
|
||||||
|
import '../shrimp.grammar' // Importing this so changes cause it to retest!
|
||||||
|
|
||||||
describe('import', () => {
|
describe('import', () => {
|
||||||
test('parses single import', () => {
|
test('parses single import', () => {
|
||||||
expect(`import str`).toMatchTree(`
|
expect(`import str`).toMatchTree(`
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,7 @@
|
||||||
import { expect, describe, test } from 'bun:test'
|
import { expect, describe, test } from 'bun:test'
|
||||||
|
|
||||||
|
import '../shrimp.grammar' // Importing this so changes cause it to retest!
|
||||||
|
|
||||||
describe('number literals', () => {
|
describe('number literals', () => {
|
||||||
test('binary numbers', () => {
|
test('binary numbers', () => {
|
||||||
expect('0b110').toMatchTree(`
|
expect('0b110').toMatchTree(`
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,7 @@
|
||||||
import { expect, describe, test } from 'bun:test'
|
import { expect, describe, test } from 'bun:test'
|
||||||
|
|
||||||
|
import '../shrimp.grammar' // Importing this so changes cause it to retest!
|
||||||
|
|
||||||
describe('multiline', () => {
|
describe('multiline', () => {
|
||||||
test('parses multiline strings', () => {
|
test('parses multiline strings', () => {
|
||||||
expect(`'first'\n'second'`).toMatchTree(`
|
expect(`'first'\n'second'`).toMatchTree(`
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,7 @@
|
||||||
import { expect, describe, test } from 'bun:test'
|
import { expect, describe, test } from 'bun:test'
|
||||||
|
import { parser } from '../shrimp'
|
||||||
|
|
||||||
|
import '../shrimp.grammar' // Importing this so changes cause it to retest!
|
||||||
|
|
||||||
describe('pipe expressions', () => {
|
describe('pipe expressions', () => {
|
||||||
test('simple pipe expression', () => {
|
test('simple pipe expression', () => {
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,7 @@
|
||||||
import { expect, describe, test } from 'bun:test'
|
import { expect, describe, test } from 'bun:test'
|
||||||
|
|
||||||
|
import '../shrimp.grammar' // Importing this so changes cause it to retest!
|
||||||
|
|
||||||
describe('string interpolation', () => {
|
describe('string interpolation', () => {
|
||||||
test('string with variable interpolation', () => {
|
test('string with variable interpolation', () => {
|
||||||
expect("'hello $name'").toMatchTree(`
|
expect("'hello $name'").toMatchTree(`
|
||||||
|
|
@ -161,7 +163,7 @@ describe('curly strings', () => {
|
||||||
})
|
})
|
||||||
|
|
||||||
describe('double quoted strings', () => {
|
describe('double quoted strings', () => {
|
||||||
test('work', () => {
|
test("work", () => {
|
||||||
expect(`"hello world"`).toMatchTree(`
|
expect(`"hello world"`).toMatchTree(`
|
||||||
String
|
String
|
||||||
DoubleQuote "hello world"`)
|
DoubleQuote "hello world"`)
|
||||||
|
|
|
||||||
|
|
@ -15,7 +15,10 @@ describe('numbers', () => {
|
||||||
test('non-numbers', () => {
|
test('non-numbers', () => {
|
||||||
expect(`1st`).toMatchToken('Word', '1st')
|
expect(`1st`).toMatchToken('Word', '1st')
|
||||||
expect(`1_`).toMatchToken('Word', '1_')
|
expect(`1_`).toMatchToken('Word', '1_')
|
||||||
expect(`100.`).toMatchTokens({ type: 'Number', value: '100' }, { type: 'Operator', value: '.' })
|
expect(`100.`).toMatchTokens(
|
||||||
|
{ type: 'Number', value: '100' },
|
||||||
|
{ type: 'Operator', value: '.' },
|
||||||
|
)
|
||||||
})
|
})
|
||||||
|
|
||||||
test('simple numbers', () => {
|
test('simple numbers', () => {
|
||||||
|
|
@ -127,7 +130,10 @@ describe('identifiers', () => {
|
||||||
expect('dog#pound').toMatchToken('Word', 'dog#pound')
|
expect('dog#pound').toMatchToken('Word', 'dog#pound')
|
||||||
expect('http://website.com').toMatchToken('Word', 'http://website.com')
|
expect('http://website.com').toMatchToken('Word', 'http://website.com')
|
||||||
expect('school$cool').toMatchToken('Identifier', 'school$cool')
|
expect('school$cool').toMatchToken('Identifier', 'school$cool')
|
||||||
expect('EXIT:').toMatchTokens({ type: 'Word', value: 'EXIT' }, { type: 'Colon' })
|
expect('EXIT:').toMatchTokens(
|
||||||
|
{ type: 'Word', value: 'EXIT' },
|
||||||
|
{ type: 'Colon' },
|
||||||
|
)
|
||||||
expect(`if y == 1: 'cool' end`).toMatchTokens(
|
expect(`if y == 1: 'cool' end`).toMatchTokens(
|
||||||
{ type: 'Keyword', value: 'if' },
|
{ type: 'Keyword', value: 'if' },
|
||||||
{ type: 'Identifier', value: 'y' },
|
{ type: 'Identifier', value: 'y' },
|
||||||
|
|
@ -208,24 +214,18 @@ describe('curly strings', () => {
|
||||||
expect(`{
|
expect(`{
|
||||||
one
|
one
|
||||||
two
|
two
|
||||||
three }`).toMatchToken(
|
three }`).toMatchToken('String', `{
|
||||||
'String',
|
|
||||||
`{
|
|
||||||
one
|
one
|
||||||
two
|
two
|
||||||
three }`,
|
three }`)
|
||||||
)
|
|
||||||
})
|
})
|
||||||
|
|
||||||
test('can contain other curlies', () => {
|
test('can contain other curlies', () => {
|
||||||
expect(`{ { one }
|
expect(`{ { one }
|
||||||
two
|
two
|
||||||
{ three } }`).toMatchToken(
|
{ three } }`).toMatchToken('String', `{ { one }
|
||||||
'String',
|
|
||||||
`{ { one }
|
|
||||||
two
|
two
|
||||||
{ three } }`,
|
{ three } }`)
|
||||||
)
|
|
||||||
})
|
})
|
||||||
|
|
||||||
test('empty curly string', () => {
|
test('empty curly string', () => {
|
||||||
|
|
@ -304,7 +304,6 @@ describe('keywords', () => {
|
||||||
expect(`catch`).toMatchToken('Keyword', 'catch')
|
expect(`catch`).toMatchToken('Keyword', 'catch')
|
||||||
expect(`finally`).toMatchToken('Keyword', 'finally')
|
expect(`finally`).toMatchToken('Keyword', 'finally')
|
||||||
expect(`throw`).toMatchToken('Keyword', 'throw')
|
expect(`throw`).toMatchToken('Keyword', 'throw')
|
||||||
expect(`not`).toMatchToken('Keyword', 'not')
|
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|
@ -408,12 +407,12 @@ f
|
||||||
|
|
||||||
]`).toMatchTokens(
|
]`).toMatchTokens(
|
||||||
{ type: 'OpenBracket' },
|
{ type: 'OpenBracket' },
|
||||||
{ type: 'Identifier', value: 'a' },
|
{ type: 'Identifier', value: "a" },
|
||||||
{ type: 'Identifier', value: 'b' },
|
{ type: 'Identifier', value: "b" },
|
||||||
{ type: 'Identifier', value: 'c' },
|
{ type: 'Identifier', value: "c" },
|
||||||
{ type: 'Identifier', value: 'd' },
|
{ type: 'Identifier', value: "d" },
|
||||||
{ type: 'Identifier', value: 'e' },
|
{ type: 'Identifier', value: "e" },
|
||||||
{ type: 'Identifier', value: 'f' },
|
{ type: 'Identifier', value: "f" },
|
||||||
{ type: 'CloseBracket' },
|
{ type: 'CloseBracket' },
|
||||||
)
|
)
|
||||||
})
|
})
|
||||||
|
|
@ -506,6 +505,7 @@ f
|
||||||
{ type: 'Identifier', value: 'y' },
|
{ type: 'Identifier', value: 'y' },
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
expect(`if (var? 'abc'): y`).toMatchTokens(
|
expect(`if (var? 'abc'): y`).toMatchTokens(
|
||||||
{ type: 'Keyword', value: 'if' },
|
{ type: 'Keyword', value: 'if' },
|
||||||
{ type: 'OpenParen' },
|
{ type: 'OpenParen' },
|
||||||
|
|
@ -551,25 +551,25 @@ end`).toMatchTokens(
|
||||||
|
|
||||||
test('dot operator beginning word with slash', () => {
|
test('dot operator beginning word with slash', () => {
|
||||||
expect(`(basename ./cool)`).toMatchTokens(
|
expect(`(basename ./cool)`).toMatchTokens(
|
||||||
{ type: 'OpenParen' },
|
{ 'type': 'OpenParen' },
|
||||||
{ type: 'Identifier', value: 'basename' },
|
{ 'type': 'Identifier', 'value': 'basename' },
|
||||||
{ type: 'Word', value: './cool' },
|
{ 'type': 'Word', 'value': './cool' },
|
||||||
{ type: 'CloseParen' },
|
{ 'type': 'CloseParen' }
|
||||||
)
|
)
|
||||||
})
|
})
|
||||||
|
|
||||||
test('dot word after identifier with space', () => {
|
test('dot word after identifier with space', () => {
|
||||||
expect(`expand-path .git`).toMatchTokens(
|
expect(`expand-path .git`).toMatchTokens(
|
||||||
{ type: 'Identifier', value: 'expand-path' },
|
{ 'type': 'Identifier', 'value': 'expand-path' },
|
||||||
{ type: 'Word', value: '.git' },
|
{ 'type': 'Word', 'value': '.git' },
|
||||||
)
|
)
|
||||||
})
|
})
|
||||||
|
|
||||||
test('dot operator after identifier without space', () => {
|
test('dot operator after identifier without space', () => {
|
||||||
expect(`config.path`).toMatchTokens(
|
expect(`config.path`).toMatchTokens(
|
||||||
{ type: 'Identifier', value: 'config' },
|
{ 'type': 'Identifier', 'value': 'config' },
|
||||||
{ type: 'Operator', value: '.' },
|
{ 'type': 'Operator', 'value': '.' },
|
||||||
{ type: 'Identifier', value: 'path' },
|
{ 'type': 'Identifier', 'value': 'path' },
|
||||||
)
|
)
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
@ -648,7 +648,11 @@ describe('empty and whitespace input', () => {
|
||||||
})
|
})
|
||||||
|
|
||||||
test('only newlines', () => {
|
test('only newlines', () => {
|
||||||
expect('\n\n\n').toMatchTokens({ type: 'Newline' }, { type: 'Newline' }, { type: 'Newline' })
|
expect('\n\n\n').toMatchTokens(
|
||||||
|
{ type: 'Newline' },
|
||||||
|
{ type: 'Newline' },
|
||||||
|
{ type: 'Newline' },
|
||||||
|
)
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|
@ -660,14 +664,14 @@ describe('named args', () => {
|
||||||
)
|
)
|
||||||
})
|
})
|
||||||
|
|
||||||
test('can have spaces', () => {
|
test("can have spaces", () => {
|
||||||
expect(`named= arg`).toMatchTokens(
|
expect(`named= arg`).toMatchTokens(
|
||||||
{ type: 'NamedArgPrefix', value: 'named=' },
|
{ type: 'NamedArgPrefix', value: 'named=' },
|
||||||
{ type: 'Identifier', value: 'arg' },
|
{ type: 'Identifier', value: 'arg' },
|
||||||
)
|
)
|
||||||
})
|
})
|
||||||
|
|
||||||
test('can include numbers', () => {
|
test("can include numbers", () => {
|
||||||
expect(`named123= arg`).toMatchTokens(
|
expect(`named123= arg`).toMatchTokens(
|
||||||
{ type: 'NamedArgPrefix', value: 'named123=' },
|
{ type: 'NamedArgPrefix', value: 'named123=' },
|
||||||
{ type: 'Identifier', value: 'arg' },
|
{ type: 'Identifier', value: 'arg' },
|
||||||
|
|
|
||||||
389
src/parser/tokenizer.ts
Normal file
389
src/parser/tokenizer.ts
Normal file
|
|
@ -0,0 +1,389 @@
|
||||||
|
import { ExternalTokenizer, InputStream, Stack } from '@lezer/lr'
|
||||||
|
import { Identifier, AssignableIdentifier, Word, IdentifierBeforeDot, Do, CurlyString, DotGet, newline, pipeStartsLine } 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
|
||||||
|
|
||||||
|
// Check if identifier is in scope (lexical scope or globals)
|
||||||
|
const inScope = context?.scope.has(identifierText) || globals.includes(identifierText)
|
||||||
|
|
||||||
|
// property access
|
||||||
|
if (inScope) return IdentifierBeforeDot
|
||||||
|
|
||||||
|
// Not in scope - check if we're inside a DotGet chain
|
||||||
|
// Inside the @skip {} block where DotGet is defined, Word cannot be shifted
|
||||||
|
// but Identifier can be. This tells us we're at the RHS of a DotGet.
|
||||||
|
const canShiftIdentifier = stack.canShift(Identifier)
|
||||||
|
const canShiftWord = stack.canShift(Word)
|
||||||
|
const inDotGetChain = canShiftIdentifier && !canShiftWord
|
||||||
|
|
||||||
|
// continue if we're inside a DotGet
|
||||||
|
return inDotGetChain ? 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
|
||||||
|
|
||||||
|
export const pipeStartsLineTokenizer = new ExternalTokenizer((input: InputStream, stack: Stack) => {
|
||||||
|
const ch = input.peek(0)
|
||||||
|
|
||||||
|
if (ch !== 10 /* \n */) return
|
||||||
|
|
||||||
|
// ignore whitespace
|
||||||
|
let offset = 1
|
||||||
|
let lastNewlineOffset = 0
|
||||||
|
|
||||||
|
while (true) {
|
||||||
|
const ch = input.peek(offset)
|
||||||
|
if (ch === 10 /* \n */) {
|
||||||
|
lastNewlineOffset = offset
|
||||||
|
offset++
|
||||||
|
} else if (isWhiteSpace(ch)) {
|
||||||
|
offset++
|
||||||
|
} else {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// look for pipe after skipping empty lines
|
||||||
|
if (input.peek(offset) === 124 /* | */) {
|
||||||
|
input.advance(lastNewlineOffset + 1)
|
||||||
|
input.acceptToken(pipeStartsLine)
|
||||||
|
} else {
|
||||||
|
input.advance(1)
|
||||||
|
input.acceptToken(newline)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
@ -1,10 +1,10 @@
|
||||||
import { isDebug } from '#utils/utils'
|
const DEBUG = process.env.DEBUG || false
|
||||||
|
|
||||||
export type Token = {
|
export type Token = {
|
||||||
type: TokenType
|
type: TokenType
|
||||||
value?: string
|
value?: string,
|
||||||
from: number
|
from: number,
|
||||||
to: number
|
to: number,
|
||||||
}
|
}
|
||||||
|
|
||||||
export enum TokenType {
|
export enum TokenType {
|
||||||
|
|
@ -36,16 +36,10 @@ export enum TokenType {
|
||||||
|
|
||||||
const valueTokens = new Set([
|
const valueTokens = new Set([
|
||||||
TokenType.Comment,
|
TokenType.Comment,
|
||||||
TokenType.Keyword,
|
TokenType.Keyword, TokenType.Operator,
|
||||||
TokenType.Operator,
|
TokenType.Identifier, TokenType.Word, TokenType.NamedArgPrefix,
|
||||||
TokenType.Identifier,
|
TokenType.Boolean, TokenType.Number, TokenType.String, TokenType.Regex,
|
||||||
TokenType.Word,
|
TokenType.Underscore
|
||||||
TokenType.NamedArgPrefix,
|
|
||||||
TokenType.Boolean,
|
|
||||||
TokenType.Number,
|
|
||||||
TokenType.String,
|
|
||||||
TokenType.Regex,
|
|
||||||
TokenType.Underscore,
|
|
||||||
])
|
])
|
||||||
|
|
||||||
const operators = new Set([
|
const operators = new Set([
|
||||||
|
|
@ -110,12 +104,11 @@ const keywords = new Set([
|
||||||
'catch',
|
'catch',
|
||||||
'finally',
|
'finally',
|
||||||
'throw',
|
'throw',
|
||||||
'not',
|
|
||||||
])
|
])
|
||||||
|
|
||||||
// helper
|
// helper
|
||||||
function c(strings: TemplateStringsArray, ...values: any[]) {
|
function c(strings: TemplateStringsArray, ...values: any[]) {
|
||||||
return strings.reduce((result, str, i) => result + str + (values[i] ?? ''), '').charCodeAt(0)
|
return strings.reduce((result, str, i) => result + str + (values[i] ?? ""), "").charCodeAt(0)
|
||||||
}
|
}
|
||||||
|
|
||||||
function s(c: number): string {
|
function s(c: number): string {
|
||||||
|
|
@ -161,19 +154,13 @@ export class Scanner {
|
||||||
to ??= this.pos - getCharSize(this.char)
|
to ??= this.pos - getCharSize(this.char)
|
||||||
if (to < from) to = from
|
if (to < from) to = from
|
||||||
|
|
||||||
this.tokens.push(
|
this.tokens.push(Object.assign({}, {
|
||||||
Object.assign(
|
|
||||||
{},
|
|
||||||
{
|
|
||||||
type,
|
type,
|
||||||
from,
|
from,
|
||||||
to,
|
to,
|
||||||
},
|
}, valueTokens.has(type) ? { value: this.input.slice(from, to) } : {}))
|
||||||
valueTokens.has(type) ? { value: this.input.slice(from, to) } : {}
|
|
||||||
)
|
|
||||||
)
|
|
||||||
|
|
||||||
if (isDebug()) {
|
if (DEBUG) {
|
||||||
const tok = this.tokens.at(-1)
|
const tok = this.tokens.at(-1)
|
||||||
console.log(`≫ PUSH(${from},${to})`, TokenType[tok?.type || 0], '—', tok?.value)
|
console.log(`≫ PUSH(${from},${to})`, TokenType[tok?.type || 0], '—', tok?.value)
|
||||||
}
|
}
|
||||||
|
|
@ -250,7 +237,8 @@ export class Scanner {
|
||||||
}
|
}
|
||||||
|
|
||||||
if (char === c`\n`) {
|
if (char === c`\n`) {
|
||||||
if (this.inParen === 0 && this.inBracket === 0) this.pushChar(TokenType.Newline)
|
if (this.inParen === 0 && this.inBracket === 0)
|
||||||
|
this.pushChar(TokenType.Newline)
|
||||||
this.next()
|
this.next()
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
|
|
@ -277,20 +265,16 @@ export class Scanner {
|
||||||
switch (this.char) {
|
switch (this.char) {
|
||||||
case c`(`:
|
case c`(`:
|
||||||
this.inParen++
|
this.inParen++
|
||||||
this.pushChar(TokenType.OpenParen)
|
this.pushChar(TokenType.OpenParen); break
|
||||||
break
|
|
||||||
case c`)`:
|
case c`)`:
|
||||||
this.inParen--
|
this.inParen--
|
||||||
this.pushChar(TokenType.CloseParen)
|
this.pushChar(TokenType.CloseParen); break
|
||||||
break
|
|
||||||
case c`[`:
|
case c`[`:
|
||||||
this.inBracket++
|
this.inBracket++
|
||||||
this.pushChar(TokenType.OpenBracket)
|
this.pushChar(TokenType.OpenBracket); break
|
||||||
break
|
|
||||||
case c`]`:
|
case c`]`:
|
||||||
this.inBracket--
|
this.inBracket--
|
||||||
this.pushChar(TokenType.CloseBracket)
|
this.pushChar(TokenType.CloseBracket); break
|
||||||
break
|
|
||||||
}
|
}
|
||||||
this.next()
|
this.next()
|
||||||
}
|
}
|
||||||
|
|
@ -354,14 +338,29 @@ export class Scanner {
|
||||||
const word = this.input.slice(this.start, this.pos - getCharSize(this.char))
|
const word = this.input.slice(this.start, this.pos - getCharSize(this.char))
|
||||||
|
|
||||||
// classify the token based on what we read
|
// classify the token based on what we read
|
||||||
if (word === '_') this.push(TokenType.Underscore)
|
if (word === '_')
|
||||||
else if (word === 'null') this.push(TokenType.Null)
|
this.push(TokenType.Underscore)
|
||||||
else if (word === 'true' || word === 'false') this.push(TokenType.Boolean)
|
|
||||||
else if (isKeyword(word)) this.push(TokenType.Keyword)
|
else if (word === 'null')
|
||||||
else if (isOperator(word)) this.push(TokenType.Operator)
|
this.push(TokenType.Null)
|
||||||
else if (isIdentifer(word)) this.push(TokenType.Identifier)
|
|
||||||
else if (word.endsWith('=')) this.push(TokenType.NamedArgPrefix)
|
else if (word === 'true' || word === 'false')
|
||||||
else this.push(TokenType.Word)
|
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() {
|
readNumber() {
|
||||||
|
|
@ -394,7 +393,8 @@ export class Scanner {
|
||||||
this.next() // skip /
|
this.next() // skip /
|
||||||
|
|
||||||
// read regex flags
|
// read regex flags
|
||||||
while (this.char > 0 && isIdentStart(this.char)) this.next()
|
while (this.char > 0 && isIdentStart(this.char))
|
||||||
|
this.next()
|
||||||
|
|
||||||
// validate regex
|
// validate regex
|
||||||
const to = this.pos - getCharSize(this.char)
|
const to = this.pos - getCharSize(this.char)
|
||||||
|
|
@ -421,29 +421,30 @@ export class Scanner {
|
||||||
}
|
}
|
||||||
|
|
||||||
canBeDotGet(lastToken?: Token): boolean {
|
canBeDotGet(lastToken?: Token): boolean {
|
||||||
return (
|
return !this.prevIsWhitespace && !!lastToken &&
|
||||||
!this.prevIsWhitespace &&
|
|
||||||
!!lastToken &&
|
|
||||||
(lastToken.type === TokenType.Identifier ||
|
(lastToken.type === TokenType.Identifier ||
|
||||||
lastToken.type === TokenType.Number ||
|
lastToken.type === TokenType.Number ||
|
||||||
lastToken.type === TokenType.CloseParen ||
|
lastToken.type === TokenType.CloseParen ||
|
||||||
lastToken.type === TokenType.CloseBracket)
|
lastToken.type === TokenType.CloseBracket)
|
||||||
)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const isNumber = (word: string): boolean => {
|
const isNumber = (word: string): boolean => {
|
||||||
// regular number
|
// regular number
|
||||||
if (/^[+-]?\d+(_?\d+)*(\.(\d+(_?\d+)*))?$/.test(word)) return true
|
if (/^[+-]?\d+(_?\d+)*(\.(\d+(_?\d+)*))?$/.test(word))
|
||||||
|
return true
|
||||||
|
|
||||||
// binary
|
// binary
|
||||||
if (/^[+-]?0b[01]+(_?[01]+)*(\.[01](_?[01]*))?$/.test(word)) return true
|
if (/^[+-]?0b[01]+(_?[01]+)*(\.[01](_?[01]*))?$/.test(word))
|
||||||
|
return true
|
||||||
|
|
||||||
// octal
|
// octal
|
||||||
if (/^[+-]?0o[0-7]+(_?[0-7]+)*(\.[0-7](_?[0-7]*))?$/.test(word)) return true
|
if (/^[+-]?0o[0-7]+(_?[0-7]+)*(\.[0-7](_?[0-7]*))?$/.test(word))
|
||||||
|
return true
|
||||||
|
|
||||||
// hex
|
// hex
|
||||||
if (/^[+-]?0x[0-9a-f]+([0-9a-f]_?[0-9a-f]+)*(\.([0-9a-f]_?[0-9a-f]*))?$/i.test(word)) return true
|
if (/^[+-]?0x[0-9a-f]+([0-9a-f]_?[0-9a-f]+)*(\.([0-9a-f]_?[0-9a-f]*))?$/i.test(word))
|
||||||
|
return true
|
||||||
|
|
||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
|
|
@ -459,26 +460,26 @@ const isIdentifer = (s: string): boolean => {
|
||||||
chars.push(out)
|
chars.push(out)
|
||||||
}
|
}
|
||||||
|
|
||||||
if (chars.length === 1) return isIdentStart(chars[0]!)
|
if (chars.length === 1)
|
||||||
else if (chars.length === 2) return isIdentStart(chars[0]!) && isIdentEnd(chars[1]!)
|
return isIdentStart(chars[0]!)
|
||||||
|
else if (chars.length === 2)
|
||||||
|
return isIdentStart(chars[0]!) && isIdentEnd(chars[1]!)
|
||||||
else
|
else
|
||||||
return (
|
return isIdentStart(chars[0]!) &&
|
||||||
isIdentStart(chars[0]!) &&
|
|
||||||
chars.slice(1, chars.length - 1).every(isIdentChar) &&
|
chars.slice(1, chars.length - 1).every(isIdentChar) &&
|
||||||
isIdentEnd(chars.at(-1)!)
|
isIdentEnd(chars.at(-1)!)
|
||||||
)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const isStringDelim = (ch: number): boolean => {
|
const isStringDelim = (ch: number): boolean => {
|
||||||
return ch === c`'` || ch === c`"`
|
return ch === c`'` || ch === c`"`
|
||||||
}
|
}
|
||||||
|
|
||||||
export const isIdentStart = (char: number | string): boolean => {
|
const isIdentStart = (char: number | string): boolean => {
|
||||||
let ch = typeof char === 'string' ? char.charCodeAt(0) : char
|
let ch = typeof char === 'string' ? char.charCodeAt(0) : char
|
||||||
return isLowercaseLetter(ch) || isEmojiOrUnicode(ch) || ch === 36 /* $ */
|
return isLowercaseLetter(ch) || isEmojiOrUnicode(ch) || ch === 36 /* $ */
|
||||||
}
|
}
|
||||||
|
|
||||||
export const isIdentChar = (char: number | string): boolean => {
|
const isIdentChar = (char: number | string): boolean => {
|
||||||
let ch = typeof char === 'string' ? char.charCodeAt(0) : char
|
let ch = typeof char === 'string' ? char.charCodeAt(0) : char
|
||||||
return isIdentStart(ch) || isDigit(ch) || ch === 45 /* - */ || ch === 63 /* ? */
|
return isIdentStart(ch) || isDigit(ch) || ch === 45 /* - */ || ch === 63 /* ? */
|
||||||
}
|
}
|
||||||
|
|
@ -496,14 +497,9 @@ const isDigit = (ch: number): boolean => {
|
||||||
}
|
}
|
||||||
|
|
||||||
const isWhitespace = (ch: number): boolean => {
|
const isWhitespace = (ch: number): boolean => {
|
||||||
return (
|
return ch === 32 /* space */ || ch === 9 /* tab */ ||
|
||||||
ch === 32 /* space */ ||
|
ch === 13 /* \r */ || ch === 10 /* \n */ ||
|
||||||
ch === 9 /* tab */ ||
|
ch === -1 || ch === 0 /* EOF */
|
||||||
ch === 13 /* \r */ ||
|
|
||||||
ch === 10 /* \n */ ||
|
|
||||||
ch === -1 ||
|
|
||||||
ch === 0
|
|
||||||
) /* EOF */
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const isWordChar = (ch: number): boolean => {
|
const isWordChar = (ch: number): boolean => {
|
||||||
|
|
@ -530,7 +526,8 @@ const isBracket = (char: number): boolean => {
|
||||||
return char === c`(` || char === c`)` || char === c`[` || char === c`]`
|
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 getCharSize = (ch: number) =>
|
||||||
|
(ch > 0xffff ? 2 : 1) // emoji takes 2 UTF-16 code units
|
||||||
|
|
||||||
const getFullCodePoint = (input: string, pos: number): number => {
|
const getFullCodePoint = (input: string, pos: number): number => {
|
||||||
const ch = input[pos]?.charCodeAt(0) || 0
|
const ch = input[pos]?.charCodeAt(0) || 0
|
||||||
|
|
|
||||||
|
|
@ -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(),
|
|
||||||
}
|
|
||||||
|
|
@ -3,11 +3,9 @@ import { type Value, toString } from 'reefvm'
|
||||||
export const dict = {
|
export const dict = {
|
||||||
keys: (dict: Record<string, any>) => Object.keys(dict),
|
keys: (dict: Record<string, any>) => Object.keys(dict),
|
||||||
values: (dict: Record<string, any>) => Object.values(dict),
|
values: (dict: Record<string, any>) => Object.values(dict),
|
||||||
entries: (dict: Record<string, any>) =>
|
entries: (dict: Record<string, any>) => Object.entries(dict).map(([k, v]) => ({ key: k, value: v })),
|
||||||
Object.entries(dict).map(([k, v]) => ({ key: k, value: v })),
|
|
||||||
'has?': (dict: Record<string, any>, key: string) => key in dict,
|
'has?': (dict: Record<string, any>, key: string) => key in dict,
|
||||||
get: (dict: Record<string, any>, key: string, defaultValue: any = null) =>
|
get: (dict: Record<string, any>, key: string, defaultValue: any = null) => dict[key] ?? defaultValue,
|
||||||
dict[key] ?? defaultValue,
|
|
||||||
set: (dict: Value, key: Value, value: Value) => {
|
set: (dict: Value, key: Value, value: Value) => {
|
||||||
const map = dict.value as Map<string, Value>
|
const map = dict.value as Map<string, Value>
|
||||||
map.set(toString(key), value)
|
map.set(toString(key), value)
|
||||||
|
|
|
||||||
|
|
@ -1,27 +1,17 @@
|
||||||
import { join, resolve, basename, dirname, extname } from 'path'
|
import { join, resolve, basename, dirname, extname } from 'path'
|
||||||
import {
|
import {
|
||||||
readdirSync,
|
readdirSync, mkdirSync, rmdirSync,
|
||||||
mkdirSync,
|
readFileSync, writeFileSync, appendFileSync,
|
||||||
rmdirSync,
|
rmSync, copyFileSync,
|
||||||
readFileSync,
|
statSync, lstatSync, chmodSync, symlinkSync, readlinkSync,
|
||||||
writeFileSync,
|
watch
|
||||||
appendFileSync,
|
} from "fs"
|
||||||
rmSync,
|
|
||||||
copyFileSync,
|
|
||||||
statSync,
|
|
||||||
lstatSync,
|
|
||||||
chmodSync,
|
|
||||||
symlinkSync,
|
|
||||||
readlinkSync,
|
|
||||||
watch,
|
|
||||||
} from 'fs'
|
|
||||||
|
|
||||||
export const fs = {
|
export const fs = {
|
||||||
// Directory operations
|
// Directory operations
|
||||||
ls: (path: string) => readdirSync(path),
|
ls: (path: string) => readdirSync(path),
|
||||||
mkdir: (path: string) => mkdirSync(path, { recursive: true }),
|
mkdir: (path: string) => mkdirSync(path, { recursive: true }),
|
||||||
rmdir: (path: string) =>
|
rmdir: (path: string) => rmdirSync(path === '/' || path === '' ? '/tmp/*' : path, { recursive: true }),
|
||||||
rmdirSync(path === '/' || path === '' ? '/tmp/*' : path, { recursive: true }),
|
|
||||||
pwd: () => process.cwd(),
|
pwd: () => process.cwd(),
|
||||||
cd: (path: string) => process.chdir(path),
|
cd: (path: string) => process.chdir(path),
|
||||||
|
|
||||||
|
|
@ -68,50 +58,39 @@ export const fs = {
|
||||||
} catch {
|
} catch {
|
||||||
return {}
|
return {}
|
||||||
}
|
}
|
||||||
|
|
||||||
},
|
},
|
||||||
'exists?': (path: string) => {
|
'exists?': (path: string) => {
|
||||||
try {
|
try {
|
||||||
statSync(path)
|
statSync(path)
|
||||||
return true
|
return true
|
||||||
} catch {
|
}
|
||||||
|
catch {
|
||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
'file?': (path: string) => {
|
'file?': (path: string) => {
|
||||||
try {
|
try { return statSync(path).isFile() }
|
||||||
return statSync(path).isFile()
|
catch { return false }
|
||||||
} catch {
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
},
|
},
|
||||||
'dir?': (path: string) => {
|
'dir?': (path: string) => {
|
||||||
try {
|
try { return statSync(path).isDirectory() }
|
||||||
return statSync(path).isDirectory()
|
catch { return false }
|
||||||
} catch {
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
},
|
},
|
||||||
'symlink?': (path: string) => {
|
'symlink?': (path: string) => {
|
||||||
try {
|
try { return lstatSync(path).isSymbolicLink() }
|
||||||
return lstatSync(path).isSymbolicLink()
|
catch { return false }
|
||||||
} catch {
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
},
|
},
|
||||||
'exec?': (path: string) => {
|
'exec?': (path: string) => {
|
||||||
try {
|
try {
|
||||||
const stats = statSync(path)
|
const stats = statSync(path)
|
||||||
return !!(stats.mode & 0o111)
|
return !!(stats.mode & 0o111)
|
||||||
} catch {
|
|
||||||
return false
|
|
||||||
}
|
}
|
||||||
|
catch { return false }
|
||||||
},
|
},
|
||||||
size: (path: string) => {
|
size: (path: string) => {
|
||||||
try {
|
try { return statSync(path).size }
|
||||||
return statSync(path).size
|
catch { return 0 }
|
||||||
} catch {
|
|
||||||
return 0
|
|
||||||
}
|
|
||||||
},
|
},
|
||||||
|
|
||||||
// Permissions
|
// Permissions
|
||||||
|
|
@ -135,11 +114,14 @@ export const fs = {
|
||||||
return readdirSync(dir)
|
return readdirSync(dir)
|
||||||
.filter((f) => f.endsWith(ext))
|
.filter((f) => f.endsWith(ext))
|
||||||
.map((f) => join(dir, f))
|
.map((f) => join(dir, f))
|
||||||
|
|
||||||
},
|
},
|
||||||
|
|
||||||
watch: (path: string, callback: Function) =>
|
watch: (path: string, callback: Function) =>
|
||||||
watch(path, (event, filename) => callback(event, filename)),
|
watch(path, (event, filename) => callback(event, filename)),
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
; (fs as any).cat = fs.read
|
; (fs as any).cat = fs.read
|
||||||
; (fs as any).mv = fs.move
|
; (fs as any).mv = fs.move
|
||||||
; (fs as any).cp = fs.copy
|
; (fs as any).cp = fs.copy
|
||||||
|
|
|
||||||
|
|
@ -2,15 +2,10 @@
|
||||||
|
|
||||||
import { join, resolve } from 'path'
|
import { join, resolve } from 'path'
|
||||||
import {
|
import {
|
||||||
type Value,
|
type Value, type VM, toValue,
|
||||||
type VM,
|
extractParamInfo, isWrapped, getOriginalFunction,
|
||||||
toValue,
|
|
||||||
extractParamInfo,
|
|
||||||
isWrapped,
|
|
||||||
getOriginalFunction,
|
|
||||||
} from 'reefvm'
|
} from 'reefvm'
|
||||||
|
|
||||||
import { date } from './date'
|
|
||||||
import { dict } from './dict'
|
import { dict } from './dict'
|
||||||
import { fs } from './fs'
|
import { fs } from './fs'
|
||||||
import { json } from './json'
|
import { json } from './json'
|
||||||
|
|
@ -18,11 +13,8 @@ import { load } from './load'
|
||||||
import { list } from './list'
|
import { list } from './list'
|
||||||
import { math } from './math'
|
import { math } from './math'
|
||||||
import { str } from './str'
|
import { str } from './str'
|
||||||
import { types } from './types'
|
|
||||||
import { runningInBrowser } from '#utils/utils'
|
|
||||||
|
|
||||||
export const globals: Record<string, any> = {
|
export const globals = {
|
||||||
date,
|
|
||||||
dict,
|
dict,
|
||||||
fs,
|
fs,
|
||||||
json,
|
json,
|
||||||
|
|
@ -32,19 +24,7 @@ export const globals: Record<string, any> = {
|
||||||
str,
|
str,
|
||||||
|
|
||||||
// shrimp runtime info
|
// shrimp runtime info
|
||||||
$: runningInBrowser
|
$: {
|
||||||
? {
|
|
||||||
args: [],
|
|
||||||
argv: [],
|
|
||||||
env: {},
|
|
||||||
pid: 0,
|
|
||||||
cwd: '',
|
|
||||||
script: {
|
|
||||||
name: '',
|
|
||||||
path: '.',
|
|
||||||
},
|
|
||||||
}
|
|
||||||
: {
|
|
||||||
args: Bun.argv.slice(3),
|
args: Bun.argv.slice(3),
|
||||||
argv: Bun.argv.slice(1),
|
argv: Bun.argv.slice(1),
|
||||||
env: process.env,
|
env: process.env,
|
||||||
|
|
@ -52,18 +32,17 @@ export const globals: Record<string, any> = {
|
||||||
cwd: process.env.PWD,
|
cwd: process.env.PWD,
|
||||||
script: {
|
script: {
|
||||||
name: Bun.argv[2] || '(shrimp)',
|
name: Bun.argv[2] || '(shrimp)',
|
||||||
path: resolve(join('.', Bun.argv[2] ?? '')),
|
path: resolve(join('.', Bun.argv[2] ?? ''))
|
||||||
},
|
},
|
||||||
|
|
||||||
},
|
},
|
||||||
|
|
||||||
// hello
|
// hello
|
||||||
echo: (...args: any[]) => {
|
echo: (...args: any[]) => {
|
||||||
console.log(
|
console.log(...args.map(a => {
|
||||||
...args.map((a) => {
|
|
||||||
const v = toValue(a)
|
const v = toValue(a)
|
||||||
return ['array', 'dict'].includes(v.type) ? formatValue(v, true) : v.value
|
return ['array', 'dict'].includes(v.type) ? formatValue(v, true) : v.value
|
||||||
})
|
}))
|
||||||
)
|
|
||||||
return toValue(null)
|
return toValue(null)
|
||||||
},
|
},
|
||||||
|
|
||||||
|
|
@ -82,10 +61,11 @@ export const globals: Record<string, any> = {
|
||||||
},
|
},
|
||||||
ref: (fn: Function) => fn,
|
ref: (fn: Function) => fn,
|
||||||
import: function (this: VM, atNamed: Record<any, string | string[]> = {}, ...idents: string[]) {
|
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 only = new Set(onlyArray)
|
||||||
const wantsOnly = only.size > 0
|
const wantsOnly = only.size > 0
|
||||||
|
|
||||||
|
|
||||||
for (const ident of idents) {
|
for (const ident of idents) {
|
||||||
const module = this.get(ident)
|
const module = this.get(ident)
|
||||||
|
|
||||||
|
|
@ -104,9 +84,20 @@ export const globals: Record<string, any> = {
|
||||||
exit: (num: number) => process.exit(num ?? 0),
|
exit: (num: number) => process.exit(num ?? 0),
|
||||||
|
|
||||||
// type predicates
|
// 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',
|
'some?': (v: any) => toValue(v).type !== 'null',
|
||||||
|
|
||||||
// boolean/logic
|
// boolean/logic
|
||||||
|
not: (v: any) => !v,
|
||||||
bnot: (n: number) => ~(n | 0),
|
bnot: (n: number) => ~(n | 0),
|
||||||
|
|
||||||
// utilities
|
// utilities
|
||||||
|
|
@ -118,13 +109,9 @@ export const globals: Record<string, any> = {
|
||||||
length: (v: any) => {
|
length: (v: any) => {
|
||||||
const value = toValue(v)
|
const value = toValue(v)
|
||||||
switch (value.type) {
|
switch (value.type) {
|
||||||
case 'string':
|
case 'string': case 'array': return value.value.length
|
||||||
case 'array':
|
case 'dict': return value.value.size
|
||||||
return value.value.length
|
default: throw new Error(`length: expected string, array, or dict, got ${value.type}`)
|
||||||
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) => {
|
at: (collection: any, index: number | string) => {
|
||||||
|
|
@ -132,9 +119,7 @@ export const globals: Record<string, any> = {
|
||||||
if (value.type === 'string' || value.type === 'array') {
|
if (value.type === 'string' || value.type === 'array') {
|
||||||
const idx = typeof index === 'number' ? index : parseInt(index as string)
|
const idx = typeof index === 'number' ? index : parseInt(index as string)
|
||||||
if (idx < 0 || idx >= value.value.length) {
|
if (idx < 0 || idx >= value.value.length) {
|
||||||
throw new Error(
|
throw new Error(`at: index ${idx} out of bounds for ${value.type} of length ${value.value.length}`)
|
||||||
`at: index ${idx} out of bounds for ${value.type} of length ${value.value.length}`
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
return value.value[idx]
|
return value.value[idx]
|
||||||
} else if (value.type === 'dict') {
|
} else if (value.type === 'dict') {
|
||||||
|
|
@ -161,8 +146,7 @@ export const globals: Record<string, any> = {
|
||||||
'empty?': (v: any) => {
|
'empty?': (v: any) => {
|
||||||
const value = toValue(v)
|
const value = toValue(v)
|
||||||
switch (value.type) {
|
switch (value.type) {
|
||||||
case 'string':
|
case 'string': case 'array':
|
||||||
case 'array':
|
|
||||||
return value.value.length === 0
|
return value.value.length === 0
|
||||||
case 'dict':
|
case 'dict':
|
||||||
return value.value.size === 0
|
return value.value.size === 0
|
||||||
|
|
@ -176,6 +160,7 @@ export const globals: Record<string, any> = {
|
||||||
for (const value of list) await cb(value)
|
for (const value of list) await cb(value)
|
||||||
return list
|
return list
|
||||||
},
|
},
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export const colors = {
|
export const colors = {
|
||||||
|
|
@ -188,7 +173,7 @@ export const colors = {
|
||||||
red: '\x1b[31m',
|
red: '\x1b[31m',
|
||||||
blue: '\x1b[34m',
|
blue: '\x1b[34m',
|
||||||
magenta: '\x1b[35m',
|
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 {
|
export function formatValue(value: Value, inner = false): string {
|
||||||
|
|
@ -202,15 +187,15 @@ export function formatValue(value: Value, inner = false): string {
|
||||||
case 'null':
|
case 'null':
|
||||||
return `${colors.dim}null${colors.reset}`
|
return `${colors.dim}null${colors.reset}`
|
||||||
case 'array': {
|
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}`
|
return `${colors.blue}[${colors.reset}${items}${colors.blue}]${colors.reset}`
|
||||||
}
|
}
|
||||||
case 'dict': {
|
case 'dict': {
|
||||||
const entries = Array.from(value.value.entries())
|
const entries = Array.from(value.value.entries()).reverse()
|
||||||
.reverse()
|
|
||||||
.map(([k, v]) => `${k.trim()}${colors.blue}=${colors.reset}${formatValue(v, true)}`)
|
.map(([k, v]) => `${k.trim()}${colors.blue}=${colors.reset}${formatValue(v, true)}`)
|
||||||
.join(' ')
|
.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}`
|
return `${colors.blue}[${colors.reset}${entries}${colors.blue}]${colors.reset}`
|
||||||
}
|
}
|
||||||
case 'function': {
|
case 'function': {
|
||||||
|
|
@ -228,6 +213,3 @@ export function formatValue(value: Value, inner = false): string {
|
||||||
return String(value)
|
return String(value)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// add types functions to top-level namespace
|
|
||||||
for (const [key, value] of Object.entries(types)) globals[key] = value
|
|
||||||
|
|
|
||||||
|
|
@ -2,5 +2,6 @@ export const json = {
|
||||||
encode: (s: any) => JSON.stringify(s),
|
encode: (s: any) => JSON.stringify(s),
|
||||||
decode: (s: string) => JSON.parse(s),
|
decode: (s: string) => JSON.parse(s),
|
||||||
}
|
}
|
||||||
|
|
||||||
; (json as any).parse = json.decode
|
; (json as any).parse = json.decode
|
||||||
; (json as any).stringify = json.encode
|
; (json as any).stringify = json.encode
|
||||||
|
|
@ -46,7 +46,7 @@ export const list = {
|
||||||
},
|
},
|
||||||
'all?': async (list: any[], cb: Function) => {
|
'all?': async (list: any[], cb: Function) => {
|
||||||
for (const value of list) {
|
for (const value of list) {
|
||||||
if (!(await cb(value))) return false
|
if (!await cb(value)) return false
|
||||||
}
|
}
|
||||||
return true
|
return true
|
||||||
},
|
},
|
||||||
|
|
@ -131,7 +131,7 @@ export const list = {
|
||||||
}
|
}
|
||||||
return [truthy, falsy]
|
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) => {
|
'group-by': async (list: any[], cb: Function) => {
|
||||||
const groups: Record<string, any[]> = {}
|
const groups: Record<string, any[]> = {}
|
||||||
for (const value of list) {
|
for (const value of list) {
|
||||||
|
|
@ -143,6 +143,7 @@ export const list = {
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
// raw functions deal directly in Value types, meaning we can modify collection
|
// raw functions deal directly in Value types, meaning we can modify collection
|
||||||
// careful - they MUST return a Value!
|
// careful - they MUST return a Value!
|
||||||
; (list.splice as any).raw = true
|
; (list.splice as any).raw = true
|
||||||
|
|
|
||||||
|
|
@ -20,7 +20,8 @@ export const load = async function (this: VM, path: string): Promise<Record<stri
|
||||||
await this.continue()
|
await this.continue()
|
||||||
|
|
||||||
const module: Record<string, Value> = {}
|
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.scope = scope
|
||||||
this.pc = pc
|
this.pc = pc
|
||||||
|
|
|
||||||
|
|
@ -16,10 +16,7 @@ export const math = {
|
||||||
if (n < 0) throw new Error(`sqrt: cannot take square root of negative number ${n}`)
|
if (n < 0) throw new Error(`sqrt: cannot take square root of negative number ${n}`)
|
||||||
return Math.sqrt(n)
|
return Math.sqrt(n)
|
||||||
},
|
},
|
||||||
random: (min = 0, max = 1) => {
|
random: () => Math.random(),
|
||||||
if (min === 0 && max === 1) return Math.random()
|
|
||||||
return Math.floor(Math.random() * (max - min + 1)) + min
|
|
||||||
},
|
|
||||||
clamp: (n: number, min: number, max: number) => {
|
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})`)
|
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)
|
return Math.min(Math.max(n, min), max)
|
||||||
|
|
|
||||||
|
|
@ -17,33 +17,17 @@ export const str = {
|
||||||
'last-index-of': (str: string, search: string) => String(str ?? '').lastIndexOf(search),
|
'last-index-of': (str: string, search: string) => String(str ?? '').lastIndexOf(search),
|
||||||
|
|
||||||
// transformations
|
// transformations
|
||||||
replace: (str: string, search: string, replacement: string) =>
|
replace: (str: string, search: string, replacement: string) => String(str ?? '').replace(search, replacement),
|
||||||
String(str ?? '').replace(search, replacement),
|
'replace-all': (str: string, search: string, replacement: string) => String(str ?? '').replaceAll(search, replacement),
|
||||||
'replace-all': (str: string, search: string, replacement: string) =>
|
slice: (str: string, start: number, end?: number | null) => String(str ?? '').slice(start, end ?? undefined),
|
||||||
String(str ?? '').replaceAll(search, replacement),
|
substring: (str: string, start: number, end?: number | null) => String(str ?? '').substring(start, end ?? undefined),
|
||||||
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) => {
|
repeat: (str: string, count: number) => {
|
||||||
if (count < 0) throw new Error(`repeat: count must be non-negative, got ${count}`)
|
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}`)
|
if (!Number.isInteger(count)) throw new Error(`repeat: count must be an integer, got ${count}`)
|
||||||
return String(str ?? '').repeat(count)
|
return String(str ?? '').repeat(count)
|
||||||
},
|
},
|
||||||
'pad-start': (str: string, length: number, pad: string = ' ') =>
|
'pad-start': (str: string, length: number, pad: string = ' ') => String(str ?? '').padStart(length, pad),
|
||||||
String(str ?? '').padStart(length, pad),
|
'pad-end': (str: string, length: number, pad: string = ' ') => String(str ?? '').padEnd(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(' ')
|
|
||||||
},
|
|
||||||
lines: (str: string) => String(str ?? '').split('\n'),
|
lines: (str: string) => String(str ?? '').split('\n'),
|
||||||
chars: (str: string) => String(str ?? '').split(''),
|
chars: (str: string) => String(str ?? '').split(''),
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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)
|
|
||||||
})
|
|
||||||
})
|
|
||||||
|
|
@ -314,16 +314,14 @@ describe('fs - other', () => {
|
||||||
writeFileSync(file, 'initial')
|
writeFileSync(file, 'initial')
|
||||||
|
|
||||||
let called = false
|
let called = false
|
||||||
const watcher = fs.watch(file, () => {
|
const watcher = fs.watch(file, () => { called = true })
|
||||||
called = true
|
|
||||||
})
|
|
||||||
|
|
||||||
// Trigger change
|
// Trigger change
|
||||||
await new Promise((resolve) => setTimeout(resolve, 100))
|
await new Promise(resolve => setTimeout(resolve, 100))
|
||||||
writeFileSync(file, 'updated')
|
writeFileSync(file, 'updated')
|
||||||
|
|
||||||
// Wait for watcher
|
// Wait for watcher
|
||||||
await new Promise((resolve) => setTimeout(resolve, 500))
|
await new Promise(resolve => setTimeout(resolve, 500))
|
||||||
|
|
||||||
expect(called).toBe(true)
|
expect(called).toBe(true)
|
||||||
watcher.close?.()
|
watcher.close?.()
|
||||||
|
|
|
||||||
|
|
@ -5,22 +5,14 @@ describe('json', () => {
|
||||||
expect(`json.decode '[1,2,3]'`).toEvaluateTo([1, 2, 3])
|
expect(`json.decode '[1,2,3]'`).toEvaluateTo([1, 2, 3])
|
||||||
expect(`json.decode '"heya"'`).toEvaluateTo('heya')
|
expect(`json.decode '"heya"'`).toEvaluateTo('heya')
|
||||||
expect(`json.decode '[true, false, null]'`).toEvaluateTo([true, false, null])
|
expect(`json.decode '[true, false, null]'`).toEvaluateTo([true, false, null])
|
||||||
expect(`json.decode '{"a": true, "b": false, "c": "yeah"}'`).toEvaluateTo({
|
expect(`json.decode '{"a": true, "b": false, "c": "yeah"}'`).toEvaluateTo({ a: true, b: false, c: "yeah" })
|
||||||
a: true,
|
|
||||||
b: false,
|
|
||||||
c: 'yeah',
|
|
||||||
})
|
|
||||||
})
|
})
|
||||||
|
|
||||||
test('json.encode', () => {
|
test('json.encode', () => {
|
||||||
expect(`json.encode [1 2 3]`).toEvaluateTo('[1,2,3]')
|
expect(`json.encode [1 2 3]`).toEvaluateTo('[1,2,3]')
|
||||||
expect(`json.encode 'heya'`).toEvaluateTo('"heya"')
|
expect(`json.encode 'heya'`).toEvaluateTo('"heya"')
|
||||||
expect(`json.encode [true false null]`).toEvaluateTo('[true,false,null]')
|
expect(`json.encode [true false null]`).toEvaluateTo('[true,false,null]')
|
||||||
expect(`json.encode [a=true b=false c='yeah'] | json.decode`).toEvaluateTo({
|
expect(`json.encode [a=true b=false c='yeah'] | json.decode`).toEvaluateTo({ a: true, b: false, c: "yeah" })
|
||||||
a: true,
|
|
||||||
b: false,
|
|
||||||
c: 'yeah',
|
|
||||||
})
|
|
||||||
})
|
})
|
||||||
|
|
||||||
test('edge cases - empty structures', () => {
|
test('edge cases - empty structures', () => {
|
||||||
|
|
@ -59,31 +51,27 @@ describe('json', () => {
|
||||||
})
|
})
|
||||||
|
|
||||||
test('nested structures - arrays', () => {
|
test('nested structures - arrays', () => {
|
||||||
expect(`json.decode '[[1,2],[3,4],[5,6]]'`).toEvaluateTo([
|
expect(`json.decode '[[1,2],[3,4],[5,6]]'`).toEvaluateTo([[1, 2], [3, 4], [5, 6]])
|
||||||
[1, 2],
|
|
||||||
[3, 4],
|
|
||||||
[5, 6],
|
|
||||||
])
|
|
||||||
expect(`json.decode '[1,[2,[3,[4]]]]'`).toEvaluateTo([1, [2, [3, [4]]]])
|
expect(`json.decode '[1,[2,[3,[4]]]]'`).toEvaluateTo([1, [2, [3, [4]]]])
|
||||||
})
|
})
|
||||||
|
|
||||||
test('nested structures - objects', () => {
|
test('nested structures - objects', () => {
|
||||||
expect(`json.decode '{"user":{"name":"Alice","age":30}}'`).toEvaluateTo({
|
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({
|
expect(`json.decode '{"a":{"b":{"c":"deep"}}}'`).toEvaluateTo({
|
||||||
a: { b: { c: 'deep' } },
|
a: { b: { c: 'deep' } }
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
test('nested structures - mixed arrays and objects', () => {
|
test('nested structures - mixed arrays and objects', () => {
|
||||||
expect(`json.decode '[{"id":1,"tags":["a","b"]},{"id":2,"tags":["c"]}]'`).toEvaluateTo([
|
expect(`json.decode '[{"id":1,"tags":["a","b"]},{"id":2,"tags":["c"]}]'`).toEvaluateTo([
|
||||||
{ id: 1, tags: ['a', 'b'] },
|
{ id: 1, tags: ['a', 'b'] },
|
||||||
{ id: 2, tags: ['c'] },
|
{ id: 2, tags: ['c'] }
|
||||||
])
|
])
|
||||||
expect(`json.decode '{"items":[1,2,3],"meta":{"count":3}}'`).toEvaluateTo({
|
expect(`json.decode '{"items":[1,2,3],"meta":{"count":3}}'`).toEvaluateTo({
|
||||||
items: [1, 2, 3],
|
items: [1, 2, 3],
|
||||||
meta: { count: 3 },
|
meta: { count: 3 }
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -16,18 +16,6 @@ describe('string operations', () => {
|
||||||
await expect(`str.trim '\\n\\thello\\t\\n'`).toEvaluateTo('hello')
|
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 () => {
|
test('split divides string by separator', async () => {
|
||||||
await expect(`str.split 'a,b,c' ','`).toEvaluateTo(['a', 'b', 'c'])
|
await expect(`str.split 'a,b,c' ','`).toEvaluateTo(['a', 'b', 'c'])
|
||||||
await expect(`str.split 'hello' ''`).toEvaluateTo(['h', 'e', 'l', 'l', 'o'])
|
await expect(`str.split 'hello' ''`).toEvaluateTo(['h', 'e', 'l', 'l', 'o'])
|
||||||
|
|
@ -117,17 +105,6 @@ describe('boolean logic', () => {
|
||||||
await expect(`not 42`).toEvaluateTo(false)
|
await expect(`not 42`).toEvaluateTo(false)
|
||||||
await expect(`not null`).toEvaluateTo(true)
|
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', () => {
|
describe('utilities', () => {
|
||||||
|
|
@ -277,10 +254,7 @@ describe('collections', () => {
|
||||||
})
|
})
|
||||||
|
|
||||||
test('list.zip combines two arrays', async () => {
|
test('list.zip combines two arrays', async () => {
|
||||||
await expect(`list.zip [1 2] [3 4]`).toEvaluateTo([
|
await expect(`list.zip [1 2] [3 4]`).toEvaluateTo([[1, 3], [2, 4]])
|
||||||
[1, 3],
|
|
||||||
[2, 4],
|
|
||||||
])
|
|
||||||
})
|
})
|
||||||
|
|
||||||
test('list.first returns first element', async () => {
|
test('list.first returns first element', async () => {
|
||||||
|
|
@ -450,10 +424,7 @@ describe('collections', () => {
|
||||||
await expect(`
|
await expect(`
|
||||||
gt-two = do x: x > 2 end
|
gt-two = do x: x > 2 end
|
||||||
list.partition [1 2 3 4 5] gt-two
|
list.partition [1 2 3 4 5] gt-two
|
||||||
`).toEvaluateTo([
|
`).toEvaluateTo([[3, 4, 5], [1, 2]])
|
||||||
[3, 4, 5],
|
|
||||||
[1, 2],
|
|
||||||
])
|
|
||||||
})
|
})
|
||||||
|
|
||||||
test('list.compact removes null values', async () => {
|
test('list.compact removes null values', async () => {
|
||||||
|
|
|
||||||
|
|
@ -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)
|
|
||||||
})
|
|
||||||
})
|
|
||||||
|
|
@ -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
10
src/server/app.tsx
Normal 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
84
src/server/index.css
Normal 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
12
src/server/index.html
Normal 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
29
src/server/server.tsx
Normal 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}`)
|
||||||
|
|
@ -2,13 +2,36 @@ import { expect } from 'bun:test'
|
||||||
import { diffLines } from 'diff'
|
import { diffLines } from 'diff'
|
||||||
import color from 'kleur'
|
import color from 'kleur'
|
||||||
import { Scanner, TokenType, type Token } from '#parser/tokenizer2'
|
import { Scanner, TokenType, type Token } from '#parser/tokenizer2'
|
||||||
import { parse, setGlobals } from '#parser/parser2'
|
import { parser } from '#parser/shrimp'
|
||||||
import { Tree } from '#parser/node'
|
import { setGlobals } from '#parser/tokenizer'
|
||||||
|
import { parse } from '#parser/parser2'
|
||||||
import { globals as prelude } from '#prelude'
|
import { globals as prelude } from '#prelude'
|
||||||
|
import { $ } from 'bun'
|
||||||
import { assert, errorMessage } from '#utils/utils'
|
import { assert, errorMessage } from '#utils/utils'
|
||||||
import { Compiler } from '#compiler/compiler'
|
import { Compiler } from '#compiler/compiler'
|
||||||
import { run, VM } from 'reefvm'
|
import { run, VM } from 'reefvm'
|
||||||
import { treeToString2, VMResultToValue } from '#utils/tree'
|
import { treeToString2, 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
|
// Type declaration for TypeScript
|
||||||
declare module 'bun:test' {
|
declare module 'bun:test' {
|
||||||
|
|
@ -20,7 +43,7 @@ declare module 'bun:test' {
|
||||||
toFailEvaluation(): Promise<T>
|
toFailEvaluation(): Promise<T>
|
||||||
toBeToken(expected: string): T
|
toBeToken(expected: string): T
|
||||||
toMatchToken(typeOrValue: string, value?: string): T
|
toMatchToken(typeOrValue: string, value?: string): T
|
||||||
toMatchTokens(...tokens: { type: string; value?: string }[]): T
|
toMatchTokens(...tokens: { type: string, value?: string }[]): T
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -50,8 +73,7 @@ expect.extend({
|
||||||
assert(typeof received === 'string', 'toFailParse can only be used with string values')
|
assert(typeof received === 'string', 'toFailParse can only be used with string values')
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const node = parse(received)
|
const tree = parser.parse(received)
|
||||||
const tree = new Tree(node)
|
|
||||||
let hasErrors = false
|
let hasErrors = false
|
||||||
tree.iterate({
|
tree.iterate({
|
||||||
enter(n) {
|
enter(n) {
|
||||||
|
|
@ -68,7 +90,7 @@ expect.extend({
|
||||||
pass: true,
|
pass: true,
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
const actual = treeToString2(node, received)
|
const actual = treeToString(tree, received)
|
||||||
return {
|
return {
|
||||||
message: () => `Expected input to fail parsing, but it parsed successfully:\n${actual}`,
|
message: () => `Expected input to fail parsing, but it parsed successfully:\n${actual}`,
|
||||||
pass: false,
|
pass: false,
|
||||||
|
|
@ -146,7 +168,7 @@ expect.extend({
|
||||||
|
|
||||||
return {
|
return {
|
||||||
message: () => `Expected token type to be ${expected}, but got ${TokenType[value.type]}`,
|
message: () => `Expected token type to be ${expected}, but got ${TokenType[value.type]}`,
|
||||||
pass: value.type === target,
|
pass: value.type === target
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
return {
|
return {
|
||||||
|
|
@ -166,8 +188,7 @@ expect.extend({
|
||||||
|
|
||||||
if (!token) {
|
if (!token) {
|
||||||
return {
|
return {
|
||||||
message: () =>
|
message: () => `Expected token to be ${expectedValue.replaceAll('\n', '\\n')}, got ${token}`,
|
||||||
`Expected token to be ${expectedValue.replaceAll('\n', '\\n')}, got ${token}`,
|
|
||||||
pass: false,
|
pass: false,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -175,14 +196,13 @@ expect.extend({
|
||||||
if (expectedType && TokenType[expectedType as keyof typeof TokenType] !== token.type) {
|
if (expectedType && TokenType[expectedType as keyof typeof TokenType] !== token.type) {
|
||||||
return {
|
return {
|
||||||
message: () => `Expected token to be ${expectedType}, but got ${TokenType[token.type]}`,
|
message: () => `Expected token to be ${expectedType}, but got ${TokenType[token.type]}`,
|
||||||
pass: false,
|
pass: false
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
message: () =>
|
message: () => `Expected token to be ${expectedValue.replaceAll('\n', '\\n')}, but got ${token.value}`,
|
||||||
`Expected token to be ${expectedValue.replaceAll('\n', '\\n')}, but got ${token.value}`,
|
pass: token.value === expectedValue
|
||||||
pass: token.value === expectedValue,
|
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
return {
|
return {
|
||||||
|
|
@ -191,11 +211,11 @@ expect.extend({
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
toMatchTokens(received: unknown, ...tokens: { type: string; value?: string }[]) {
|
toMatchTokens(received: unknown, ...tokens: { type: string, value?: string }[]) {
|
||||||
assert(typeof received === 'string', 'toMatchTokens can only be used with string values')
|
assert(typeof received === 'string', 'toMatchTokens can only be used with string values')
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const result = tokenize(received).map((t) => toHumanToken(t))
|
const result = tokenize(received).map(t => toHumanToken(t))
|
||||||
|
|
||||||
if (result.length === 0 && tokens.length > 0) {
|
if (result.length === 0 && tokens.length > 0) {
|
||||||
return {
|
return {
|
||||||
|
|
@ -209,7 +229,7 @@ expect.extend({
|
||||||
|
|
||||||
return {
|
return {
|
||||||
message: () => `Tokens don't match: \n\n${diff(actual, expected)}`,
|
message: () => `Tokens don't match: \n\n${diff(actual, expected)}`,
|
||||||
pass: expected == actual,
|
pass: expected == actual
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
return {
|
return {
|
||||||
|
|
@ -217,18 +237,18 @@ expect.extend({
|
||||||
pass: false,
|
pass: false,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
const tokenize = (code: string): Token[] => {
|
const tokenize = (code: string): Token[] => {
|
||||||
const scanner = new Scanner()
|
const scanner = new Scanner
|
||||||
return scanner.tokenize(code)
|
return scanner.tokenize(code)
|
||||||
}
|
}
|
||||||
|
|
||||||
const toHumanToken = (tok: Token): { type: string; value?: string } => {
|
const toHumanToken = (tok: Token): { type: string, value?: string } => {
|
||||||
return {
|
return {
|
||||||
type: TokenType[tok.type],
|
type: TokenType[tok.type],
|
||||||
value: tok.value,
|
value: tok.value
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -243,7 +263,7 @@ const trimWhitespace = (str: string): string => {
|
||||||
if (!line.startsWith(leadingWhitespace)) {
|
if (!line.startsWith(leadingWhitespace)) {
|
||||||
let foundWhitespace = line.match(/^(\s*)/)?.[1] || ''
|
let foundWhitespace = line.match(/^(\s*)/)?.[1] || ''
|
||||||
throw new Error(
|
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)
|
return line.slice(leadingWhitespace.length)
|
||||||
|
|
@ -259,7 +279,7 @@ const diff = (a: string, b: string): string => {
|
||||||
if (expected !== actual) {
|
if (expected !== actual) {
|
||||||
const changes = diffLines(actual, expected)
|
const changes = diffLines(actual, expected)
|
||||||
for (const part of changes) {
|
for (const part of changes) {
|
||||||
const sign = part.added ? '+' : part.removed ? '-' : ' '
|
const sign = part.added ? "+" : part.removed ? "-" : " "
|
||||||
let line = sign + part.value
|
let line = sign + part.value
|
||||||
if (part.added) {
|
if (part.added) {
|
||||||
line = color.green(line)
|
line = color.green(line)
|
||||||
|
|
@ -267,7 +287,7 @@ const diff = (a: string, b: string): string => {
|
||||||
line = color.red(line)
|
line = color.red(line)
|
||||||
}
|
}
|
||||||
|
|
||||||
lines.push(line.endsWith('\n') || line.endsWith('\n\u001b[39m') ? line : line + '\n')
|
lines.push(line.endsWith("\n") || line.endsWith("\n\u001b[39m") ? line : line + "\n")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
68
src/utils/signal.ts
Normal file
68
src/utils/signal.ts
Normal 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 = []
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -136,12 +136,3 @@ export const asciiEscapeToHtml = (str: string): HtmlEscapedString => {
|
||||||
|
|
||||||
return result as 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'
|
|
||||||
|
|
|
||||||
5
vscode-extension/.vscode/launch.json
vendored
5
vscode-extension/.vscode/launch.json
vendored
|
|
@ -5,7 +5,10 @@
|
||||||
"name": "Run Extension",
|
"name": "Run Extension",
|
||||||
"type": "extensionHost",
|
"type": "extensionHost",
|
||||||
"request": "launch",
|
"request": "launch",
|
||||||
"args": ["--extensionDevelopmentPath=${workspaceFolder}", "--profile=Shrimp Dev"],
|
"args": [
|
||||||
|
"--extensionDevelopmentPath=${workspaceFolder}",
|
||||||
|
"--profile=Shrimp Dev"
|
||||||
|
],
|
||||||
"outFiles": [
|
"outFiles": [
|
||||||
"${workspaceFolder}/client/dist/**/*.js",
|
"${workspaceFolder}/client/dist/**/*.js",
|
||||||
"${workspaceFolder}/server/dist/**/*.js"
|
"${workspaceFolder}/server/dist/**/*.js"
|
||||||
|
|
|
||||||
|
|
@ -22,7 +22,7 @@ export function activate(context: vscode.ExtensionContext) {
|
||||||
'shrimpLanguageServer',
|
'shrimpLanguageServer',
|
||||||
'Shrimp Language Server',
|
'Shrimp Language Server',
|
||||||
serverOptions,
|
serverOptions,
|
||||||
clientOptions,
|
clientOptions
|
||||||
)
|
)
|
||||||
|
|
||||||
client.start()
|
client.start()
|
||||||
|
|
@ -46,7 +46,7 @@ export function activate(context: vscode.ExtensionContext) {
|
||||||
language: 'text',
|
language: 'text',
|
||||||
})
|
})
|
||||||
await vscode.window.showTextDocument(doc, { preview: false })
|
await vscode.window.showTextDocument(doc, { preview: false })
|
||||||
}),
|
})
|
||||||
)
|
)
|
||||||
|
|
||||||
// Command: Show Bytecode
|
// Command: Show Bytecode
|
||||||
|
|
@ -67,7 +67,7 @@ export function activate(context: vscode.ExtensionContext) {
|
||||||
language: 'text',
|
language: 'text',
|
||||||
})
|
})
|
||||||
await vscode.window.showTextDocument(doc, { preview: false })
|
await vscode.window.showTextDocument(doc, { preview: false })
|
||||||
}),
|
})
|
||||||
)
|
)
|
||||||
|
|
||||||
// Command: Run File
|
// Command: Run File
|
||||||
|
|
@ -93,7 +93,7 @@ export function activate(context: vscode.ExtensionContext) {
|
||||||
const terminal = vscode.window.createTerminal('Shrimp')
|
const terminal = vscode.window.createTerminal('Shrimp')
|
||||||
terminal.show()
|
terminal.show()
|
||||||
terminal.sendText(`${binaryPath} "${filePath}"`)
|
terminal.sendText(`${binaryPath} "${filePath}"`)
|
||||||
}),
|
})
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -113,5 +113,5 @@ console.log(`✓ Generated ${names.length} prelude names to server/src/metadata/
|
||||||
console.log(
|
console.log(
|
||||||
`✓ Generated completions for ${
|
`✓ Generated completions for ${
|
||||||
Object.keys(moduleMetadata).length
|
Object.keys(moduleMetadata).length
|
||||||
} modules to server/src/metadata/prelude-completions.ts`,
|
} modules to server/src/metadata/prelude-completions.ts`
|
||||||
)
|
)
|
||||||
|
|
|
||||||
|
|
@ -10,7 +10,7 @@ import { analyzeCompletionContext } from './contextAnalyzer'
|
||||||
*/
|
*/
|
||||||
export const provideCompletions = (
|
export const provideCompletions = (
|
||||||
document: TextDocument,
|
document: TextDocument,
|
||||||
position: { line: number; character: number },
|
position: { line: number; character: number }
|
||||||
): CompletionItem[] => {
|
): CompletionItem[] => {
|
||||||
const context = analyzeCompletionContext(document, position)
|
const context = analyzeCompletionContext(document, position)
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -14,7 +14,7 @@ export type CompletionContext =
|
||||||
*/
|
*/
|
||||||
export const analyzeCompletionContext = (
|
export const analyzeCompletionContext = (
|
||||||
document: TextDocument,
|
document: TextDocument,
|
||||||
position: { line: number; character: number },
|
position: { line: number; character: number }
|
||||||
): CompletionContext => {
|
): CompletionContext => {
|
||||||
const offset = document.offsetAt(position)
|
const offset = document.offsetAt(position)
|
||||||
const text = document.getText()
|
const text = document.getText()
|
||||||
|
|
|
||||||
File diff suppressed because it is too large
Load Diff
|
|
@ -2,44 +2,39 @@
|
||||||
// Do not edit manually - run 'bun run generate-prelude-metadata' to regenerate
|
// Do not edit manually - run 'bun run generate-prelude-metadata' to regenerate
|
||||||
|
|
||||||
export const PRELUDE_NAMES = [
|
export const PRELUDE_NAMES = [
|
||||||
'$',
|
"$",
|
||||||
'array?',
|
"array?",
|
||||||
'at',
|
"at",
|
||||||
'bnot',
|
"bnot",
|
||||||
'boolean',
|
"boolean?",
|
||||||
'boolean?',
|
"dec",
|
||||||
'date',
|
"describe",
|
||||||
'dec',
|
"dict",
|
||||||
'describe',
|
"dict?",
|
||||||
'dict',
|
"each",
|
||||||
'dict?',
|
"echo",
|
||||||
'each',
|
"empty?",
|
||||||
'echo',
|
"exit",
|
||||||
'empty?',
|
"fs",
|
||||||
'exit',
|
"function?",
|
||||||
'fs',
|
"identity",
|
||||||
'function?',
|
"import",
|
||||||
'identity',
|
"inc",
|
||||||
'import',
|
"inspect",
|
||||||
'inc',
|
"json",
|
||||||
'inspect',
|
"length",
|
||||||
'json',
|
"list",
|
||||||
'length',
|
"load",
|
||||||
'list',
|
"math",
|
||||||
'list?',
|
"not",
|
||||||
'load',
|
"null?",
|
||||||
'math',
|
"number?",
|
||||||
'not',
|
"range",
|
||||||
'null?',
|
"ref",
|
||||||
'number',
|
"some?",
|
||||||
'number?',
|
"str",
|
||||||
'range',
|
"string?",
|
||||||
'ref',
|
"type",
|
||||||
'some?',
|
"var",
|
||||||
'str',
|
"var?"
|
||||||
'string',
|
|
||||||
'string?',
|
|
||||||
'type',
|
|
||||||
'var',
|
|
||||||
'var?',
|
|
||||||
] as const
|
] as const
|
||||||
|
|
|
||||||
|
|
@ -41,7 +41,7 @@ export function buildSemanticTokens(document: TextDocument, tree: Tree): number[
|
||||||
function emitNamedArgPrefix(
|
function emitNamedArgPrefix(
|
||||||
node: SyntaxNode,
|
node: SyntaxNode,
|
||||||
document: TextDocument,
|
document: TextDocument,
|
||||||
builder: SemanticTokensBuilder,
|
builder: SemanticTokensBuilder
|
||||||
) {
|
) {
|
||||||
const text = document.getText({
|
const text = document.getText({
|
||||||
start: document.positionAt(node.from),
|
start: document.positionAt(node.from),
|
||||||
|
|
@ -57,7 +57,7 @@ function emitNamedArgPrefix(
|
||||||
start.character,
|
start.character,
|
||||||
nameLength,
|
nameLength,
|
||||||
TOKEN_TYPES.indexOf(SemanticTokenTypes.property),
|
TOKEN_TYPES.indexOf(SemanticTokenTypes.property),
|
||||||
0,
|
0
|
||||||
)
|
)
|
||||||
|
|
||||||
// Emit token for the "=" part
|
// Emit token for the "=" part
|
||||||
|
|
@ -66,7 +66,7 @@ function emitNamedArgPrefix(
|
||||||
start.character + nameLength,
|
start.character + nameLength,
|
||||||
1, // Just the = character
|
1, // Just the = character
|
||||||
TOKEN_TYPES.indexOf(SemanticTokenTypes.operator),
|
TOKEN_TYPES.indexOf(SemanticTokenTypes.operator),
|
||||||
0,
|
0
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -75,7 +75,7 @@ function walkTree(
|
||||||
node: SyntaxNode,
|
node: SyntaxNode,
|
||||||
document: TextDocument,
|
document: TextDocument,
|
||||||
builder: SemanticTokensBuilder,
|
builder: SemanticTokensBuilder,
|
||||||
scopeTracker: EditorScopeAnalyzer,
|
scopeTracker: EditorScopeAnalyzer
|
||||||
) {
|
) {
|
||||||
// Special handling for NamedArgPrefix to split "name=" into two tokens
|
// Special handling for NamedArgPrefix to split "name=" into two tokens
|
||||||
if (node.type.id === Terms.NamedArgPrefix) {
|
if (node.type.id === Terms.NamedArgPrefix) {
|
||||||
|
|
@ -102,7 +102,7 @@ type TokenInfo = { type: number; modifiers: number } | undefined
|
||||||
function getTokenType(
|
function getTokenType(
|
||||||
node: SyntaxNode,
|
node: SyntaxNode,
|
||||||
document: TextDocument,
|
document: TextDocument,
|
||||||
scopeTracker: EditorScopeAnalyzer,
|
scopeTracker: EditorScopeAnalyzer
|
||||||
): TokenInfo {
|
): TokenInfo {
|
||||||
const nodeTypeId = node.type.id
|
const nodeTypeId = node.type.id
|
||||||
const parentTypeId = node.parent?.type.id
|
const parentTypeId = node.parent?.type.id
|
||||||
|
|
|
||||||
|
|
@ -17,6 +17,7 @@ import {
|
||||||
CompletionItemKind,
|
CompletionItemKind,
|
||||||
TextDocumentChangeEvent,
|
TextDocumentChangeEvent,
|
||||||
} from 'vscode-languageserver/node'
|
} from 'vscode-languageserver/node'
|
||||||
|
import { setGlobals } from '../../../src/parser/tokenizer'
|
||||||
import { globals } from '../../../src/prelude'
|
import { globals } from '../../../src/prelude'
|
||||||
|
|
||||||
// Initialize parser with prelude globals so it knows dict/list/str are in scope
|
// Initialize parser with prelude globals so it knows dict/list/str are in scope
|
||||||
|
|
@ -124,7 +125,7 @@ function handleCompletion(params: any) {
|
||||||
if (contextCompletions.length > 0) {
|
if (contextCompletions.length > 0) {
|
||||||
console.log(
|
console.log(
|
||||||
`✅ Returning ${contextCompletions.length} completions:`,
|
`✅ Returning ${contextCompletions.length} completions:`,
|
||||||
contextCompletions.map((c) => c.label).join(', '),
|
contextCompletions.map((c) => c.label).join(', ')
|
||||||
)
|
)
|
||||||
return contextCompletions
|
return contextCompletions
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,8 +1,4 @@
|
||||||
import {
|
import { SignatureHelp, SignatureInformation, ParameterInformation } from 'vscode-languageserver/node'
|
||||||
SignatureHelp,
|
|
||||||
SignatureInformation,
|
|
||||||
ParameterInformation,
|
|
||||||
} from 'vscode-languageserver/node'
|
|
||||||
import { TextDocument } from 'vscode-languageserver-textdocument'
|
import { TextDocument } from 'vscode-languageserver-textdocument'
|
||||||
import { Tree, SyntaxNode } from '@lezer/common'
|
import { Tree, SyntaxNode } from '@lezer/common'
|
||||||
import { parser } from '../../../src/parser/shrimp'
|
import { parser } from '../../../src/parser/shrimp'
|
||||||
|
|
@ -10,7 +6,7 @@ import { completions } from './metadata/prelude-completions'
|
||||||
|
|
||||||
export const provideSignatureHelp = (
|
export const provideSignatureHelp = (
|
||||||
document: TextDocument,
|
document: TextDocument,
|
||||||
position: { line: number; character: number },
|
position: { line: number; character: number }
|
||||||
): SignatureHelp | undefined => {
|
): SignatureHelp | undefined => {
|
||||||
const text = document.getText()
|
const text = document.getText()
|
||||||
const tree = parser.parse(text)
|
const tree = parser.parse(text)
|
||||||
|
|
@ -104,6 +100,6 @@ const lookupFunctionParams = (funcName: string): string[] | undefined => {
|
||||||
|
|
||||||
const buildSignature = (funcName: string, params: string[]): SignatureInformation => {
|
const buildSignature = (funcName: string, params: string[]): SignatureInformation => {
|
||||||
const label = `${funcName}(${params.join(', ')})`
|
const label = `${funcName}(${params.join(', ')})`
|
||||||
const parameters: ParameterInformation[] = params.map((p) => ({ label: p }))
|
const parameters: ParameterInformation[] = params.map(p => ({ label: p }))
|
||||||
return { label, parameters }
|
return { label, parameters }
|
||||||
}
|
}
|
||||||
|
|
|
||||||
41
vscode-extension/tmp/test-dotget-parse.ts
Normal file
41
vscode-extension/tmp/test-dotget-parse.ts
Normal file
|
|
@ -0,0 +1,41 @@
|
||||||
|
import { parser } from '../../src/parser/shrimp'
|
||||||
|
import { setGlobals } from '../../src/parser/tokenizer'
|
||||||
|
import { PRELUDE_NAMES } from '../server/src/prelude-names'
|
||||||
|
|
||||||
|
// Set globals for DotGet detection
|
||||||
|
setGlobals(PRELUDE_NAMES as unknown as string[])
|
||||||
|
|
||||||
|
// Test cases - does incomplete DotGet parse correctly?
|
||||||
|
const testCases = [
|
||||||
|
'dict.',
|
||||||
|
'dict.g',
|
||||||
|
'dict.get',
|
||||||
|
'$.',
|
||||||
|
'$.e',
|
||||||
|
'$.env',
|
||||||
|
]
|
||||||
|
|
||||||
|
for (const code of testCases) {
|
||||||
|
console.log(`\nTesting: "${code}"`)
|
||||||
|
const tree = parser.parse(code)
|
||||||
|
const cursor = tree.cursor()
|
||||||
|
|
||||||
|
// Print the parse tree
|
||||||
|
const printTree = (depth = 0) => {
|
||||||
|
const indent = ' '.repeat(depth)
|
||||||
|
console.log(`${indent}${cursor.name} [${cursor.from}-${cursor.to}]`)
|
||||||
|
|
||||||
|
if (cursor.firstChild()) {
|
||||||
|
do {
|
||||||
|
printTree(depth + 1)
|
||||||
|
} while (cursor.nextSibling())
|
||||||
|
cursor.parent()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
printTree()
|
||||||
|
|
||||||
|
// Check at cursor position (end of string)
|
||||||
|
const node = tree.resolveInner(code.length, -1)
|
||||||
|
console.log(`Node at end: ${node.name} (type: ${node.type.id})`)
|
||||||
|
}
|
||||||
Loading…
Reference in New Issue
Block a user