Compare commits

..

No commits in common. "prettier" and "main" have entirely different histories.

61 changed files with 1455 additions and 1176 deletions

View File

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

View File

@ -58,10 +58,7 @@ export class Compiler {
bytecode: Bytecode bytecode: Bytecode
pipeCounter = 0 pipeCounter = 0
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)
@ -112,15 +109,9 @@ export class Compiler {
// 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,7 +123,8 @@ export class Compiler {
return [[`PUSH`, numberValue]] return [[`PUSH`, numberValue]]
case 'String': { case 'String': {
if (node.firstChild?.type.is('CurlyString')) return this.#compileCurlyString(value, input) if (node.firstChild?.type.is('CurlyString'))
return this.#compileCurlyString(value, input)
const { parts, hasInterpolation } = getStringParts(node, input) const { parts, hasInterpolation } = getStringParts(node, input)
@ -174,7 +166,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
) )
} }
}) })
@ -382,7 +374,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
) )
} }
@ -430,8 +422,8 @@ export class Compiler {
catchVariable, catchVariable,
catchBody, catchBody,
finallyBody, finallyBody,
input, input
), )
) )
} else { } else {
instructions.push(...compileFunctionBody()) instructions.push(...compileFunctionBody())
@ -540,7 +532,7 @@ 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}:`])
@ -567,7 +559,7 @@ 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`
) )
} }
@ -582,7 +574,7 @@ export class Compiler {
catchVariable, catchVariable,
catchBody, catchBody,
finallyBody, finallyBody,
input, input
) )
} }
@ -595,7 +587,7 @@ export class Compiler {
throw new CompilerError( throw new CompilerError(
`${keyword} expected expression, got ${children.length} children`, `${keyword} expected expression, got ${children.length} children`,
node.from, node.from,
node.to, node.to
) )
} }
@ -609,7 +601,7 @@ export class Compiler {
case 'IfExpr': { case '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))
@ -740,13 +732,13 @@ export class Compiler {
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.is('Underscore')
) )
const isUnderscoreInNamedArgs = namedArgs.some((arg) => { const isUnderscoreInNamedArgs = namedArgs.some((arg) => {
const { valueNode } = getNamedArgParts(arg, input) const { valueNode } = getNamedArgParts(arg, input)
@ -845,12 +837,14 @@ export class Compiler {
case 'Import': { case '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.is('Identifier'))
const namedArgs = nodes.filter((node) => node.type.is('NamedArg')) const namedArgs = nodes.filter(node => node.type.is('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)
@ -873,7 +867,7 @@ export class Compiler {
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.`,
node.from, node.from,
node.to, node.to
) )
} }
} }
@ -883,7 +877,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++

View File

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

View File

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

View File

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

View File

@ -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', () => {
@ -263,7 +263,7 @@ describe('curly strings', () => {
}) })
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')

View File

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

View File

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

View File

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

View File

@ -45,7 +45,7 @@ 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
) )
} }
@ -57,11 +57,10 @@ export const getAssignmentParts = (node: SyntaxNode) => {
if (!left || !left.type.is('AssignableIdentifier')) { if (!left || !left.type.is('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
) )
} }
@ -74,17 +73,16 @@ export const getCompoundAssignmentParts = (node: SyntaxNode) => {
if (!left || !left.type.is('AssignableIdentifier')) { if (!left || !left.type.is('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,7 +97,7 @@ 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
) )
} }
@ -108,7 +106,7 @@ export const getFunctionDefParts = (node: SyntaxNode, input: string) => {
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)
@ -131,7 +129,7 @@ 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)
@ -144,7 +142,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
@ -199,7 +197,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
) )
} }
@ -253,6 +251,7 @@ export const getStringParts = (node: SyntaxNode, input: string) => {
child.type.is('Interpolation') || child.type.is('Interpolation') ||
child.type.is('EscapeSeq') || child.type.is('EscapeSeq') ||
child.type.is('CurlyString') child.type.is('CurlyString')
) )
}) })
@ -267,14 +266,16 @@ export const getStringParts = (node: SyntaxNode, input: string) => {
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.is('Interpolation') || p.type.is('EscapeSeq')
)
return { parts, hasInterpolation } return { parts, hasInterpolation }
} }
@ -286,7 +287,7 @@ 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
) )
} }
@ -294,7 +295,7 @@ export const getDotGetParts = (node: SyntaxNode, input: string) => {
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
) )
} }
@ -302,7 +303,7 @@ export const getDotGetParts = (node: SyntaxNode, input: string) => {
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 +322,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
) )
} }
@ -340,7 +341,7 @@ 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)
@ -353,7 +354,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

View File

@ -123,7 +123,7 @@ const valueToString = (value: Value | string): string => {
return `${value.value.map(valueToString).join('\n')}` return `${value.value.map(valueToString).join('\n')}`
case 'dict': { case 'dict': {
const entries = Array.from(value.value.entries()).map( const entries = Array.from(value.value.entries()).map(
([key, val]) => `"${key}": ${valueToString(val)}`, ([key, val]) => `"${key}": ${valueToString(val)}`
) )
return `{${entries.join(', ')}}` return `{${entries.join(', ')}}`
} }

View File

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

View File

@ -31,5 +31,5 @@ export const debugTags = ViewPlugin.fromClass(
order: -1, order: -1,
}) })
} }
}, }
) )

View File

@ -58,5 +58,5 @@ export const shrimpErrors = ViewPlugin.fromClass(
}, },
{ {
decorations: (v) => v.decorations, decorations: (v) => v.decorations,
}, }
) )

View File

@ -215,7 +215,7 @@ export const inlineHints = [
} }
}, },
}, },
}, }
), ),
ghostTextTheme, ghostTextTheme,
] ]

View File

@ -17,7 +17,7 @@ export const persistencePlugin = ViewPlugin.fromClass(
destroy() { destroy() {
if (this.saveTimeout) clearTimeout(this.saveTimeout) if (this.saveTimeout) clearTimeout(this.saveTimeout)
} }
}, }
) )
export const getContent = () => { export const getContent = () => {

View File

@ -56,5 +56,5 @@ export const shrimpTheme = EditorView.theme(
backgroundColor: 'var(--color-string)', backgroundColor: 'var(--color-string)',
}, },
}, },
{ dark: true }, { dark: true }
) )

View File

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

View File

@ -44,7 +44,8 @@ export const tokenizeCurlyString = (value: string): (string | [string, SyntaxNod
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)])

View File

@ -3,15 +3,18 @@ import { type Token, TokenType } from './tokenizer2'
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 +32,7 @@ export type NodeType =
| 'Array' | 'Array'
| 'Dict' | 'Dict'
| 'Comment' | 'Comment'
| 'BinOp' | 'BinOp'
| 'ConditionalOp' | 'ConditionalOp'
| 'ParenExpr' | 'ParenExpr'
@ -36,6 +40,7 @@ export type NodeType =
| 'CompoundAssign' | 'CompoundAssign'
| 'DotGet' | 'DotGet'
| 'PipeExpr' | 'PipeExpr'
| 'IfExpr' | 'IfExpr'
| 'ElseIfExpr' | 'ElseIfExpr'
| 'ElseExpr' | 'ElseExpr'
@ -44,12 +49,14 @@ export type NodeType =
| 'CatchExpr' | 'CatchExpr'
| 'FinallyExpr' | 'FinallyExpr'
| 'Throw' | 'Throw'
| 'Not' | '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',
@ -107,7 +114,7 @@ export const operators: Record<string, any> = {
} }
export class Tree { export class Tree {
constructor(public topNode: SyntaxNode) {} constructor(public topNode: SyntaxNode) { }
get length(): number { get length(): number {
return this.topNode.to return this.topNode.to
@ -151,17 +158,12 @@ 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(): { type: NodeType, name: NodeType, isError: boolean, is: (other: NodeType) => boolean } {
type: NodeType
name: NodeType
isError: boolean
is: (other: NodeType) => boolean
} {
return { return {
type: this.#type, type: this.#type,
name: this.#type, name: this.#type,
isError: this.#isError, isError: this.#isError,
is: (other: NodeType) => other === this.#type, is: (other: NodeType) => other === this.#type
} }
} }
@ -209,7 +211,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 +224,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 +248,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 +261,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 = [
'??=', '+=', '-=', '*=', '/=', '%='
]

View File

@ -42,7 +42,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 +78,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 +99,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 +207,26 @@ 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, '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 +238,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 +324,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 +343,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 +360,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 +402,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 +507,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 +520,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 +536,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 +561,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 +602,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,11 +763,7 @@ 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)
@ -748,8 +781,7 @@ export class Parser {
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 +828,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 +842,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 +868,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 +892,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 +914,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 +925,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 +981,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

View File

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

View File

@ -195,6 +195,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 +286,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 +344,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:

View File

@ -14,7 +14,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 +33,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 +54,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 +81,8 @@ describe('single line function blocks', () => {
Identifier bye Identifier bye
PositionalArg PositionalArg
Identifier bye Identifier bye
keyword end`) keyword end`
)
}) })
}) })
@ -99,7 +104,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 +126,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 +153,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 +184,8 @@ end`).toMatchTree(`
Identifier bye Identifier bye
PositionalArg PositionalArg
Identifier bye Identifier bye
keyword end`) keyword end`
)
}) })
}) })
@ -252,7 +262,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

View File

@ -92,6 +92,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

View File

@ -161,7 +161,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"`)

View File

@ -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', () => {
@ -408,12 +408,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 +506,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 +552,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 +649,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 +665,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' },

View File

@ -2,9 +2,9 @@ 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([
@ -115,7 +109,7 @@ const keywords = new Set([
// 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,17 +155,11 @@ 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 (DEBUG) { if (DEBUG) {
const tok = this.tokens.at(-1) const tok = this.tokens.at(-1)
@ -250,7 +238,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 +266,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 +339,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 +394,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 +422,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,14 +461,14 @@ 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 => {
@ -496,14 +498,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 +527,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

View File

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

View File

@ -3,11 +3,9 @@ import { type Value, toString } from 'reefvm'
export const dict = { 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)
@ -32,6 +30,6 @@ export const dict = {
'from-entries': (entries: [string, any][]) => Object.fromEntries(entries), 'from-entries': (entries: [string, any][]) => Object.fromEntries(entries),
} }
// 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!
;(dict.set as any).raw = true ; (dict.set as any).raw = true

View File

@ -1,33 +1,23 @@
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),
// Reading // Reading
read: (path: string) => readFileSync(path, 'utf-8'), read: (path: string) => readFileSync(path, 'utf-8'),
cat: (path: string) => {}, // added below cat: (path: string) => { }, // added below
'read-bytes': (path: string) => [...readFileSync(path)], 'read-bytes': (path: string) => [...readFileSync(path)],
// Writing // Writing
@ -36,13 +26,13 @@ export const fs = {
// File operations // File operations
delete: (path: string) => rmSync(path), delete: (path: string) => rmSync(path),
rm: (path: string) => {}, // added below rm: (path: string) => { }, // added below
copy: (from: string, to: string) => copyFileSync(from, to), copy: (from: string, to: string) => copyFileSync(from, to),
move: (from: string, to: string) => { move: (from: string, to: string) => {
fs.copy(from, to) fs.copy(from, to)
fs.rm(from) fs.rm(from)
}, },
mv: (from: string, to: string) => {}, // added below mv: (from: string, to: string) => { }, // added below
// Path operations // Path operations
basename: (path: string) => basename(path), basename: (path: string) => basename(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,12 +114,15 @@ 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).mv = fs.move
;(fs as any).cp = fs.copy ; (fs as any).cat = fs.read
;(fs as any).rm = fs.delete ; (fs as any).mv = fs.move
; (fs as any).cp = fs.copy
; (fs as any).rm = fs.delete

View File

@ -2,12 +2,8 @@
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 { date } from './date'
@ -39,18 +35,16 @@ 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)
}, },
@ -69,10 +63,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)
@ -105,13 +100,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) => {
@ -119,9 +110,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') {
@ -148,8 +137,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
@ -163,6 +151,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 = {
@ -175,7 +164,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 {
@ -189,15 +178,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': {
@ -217,4 +206,5 @@ export function formatValue(value: Value, inner = false): string {
} }
// add types functions to top-level namespace // add types functions to top-level namespace
for (const [key, value] of Object.entries(types)) globals[key] = value for (const [key, value] of Object.entries(types))
globals[key] = value

View File

@ -2,5 +2,6 @@ export const json = {
encode: (s: any) => JSON.stringify(s), 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).stringify = json.encode ; (json as any).parse = json.decode
; (json as any).stringify = json.encode

View File

@ -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,11 +143,12 @@ export const list = {
}, },
} }
// raw functions deal directly in Value types, meaning we can modify collection
// careful - they MUST return a Value! // raw functions deal directly in Value types, meaning we can modify collection
;(list.splice as any).raw = true // careful - they MUST return a Value!
;(list.push as any).raw = true ; (list.splice as any).raw = true
;(list.pop as any).raw = true ; (list.push as any).raw = true
;(list.shift as any).raw = true ; (list.pop as any).raw = true
;(list.unshift as any).raw = true ; (list.shift as any).raw = true
;(list.insert as any).raw = true ; (list.unshift as any).raw = true
; (list.insert as any).raw = true

View File

@ -20,7 +20,8 @@ export const load = async function (this: VM, path: string): Promise<Record<stri
await this.continue() 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

View File

@ -17,23 +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) => { capitalize: (str: string) => {
const s = String(str ?? '') const s = String(str ?? '')
return s.charAt(0).toUpperCase() + s.slice(1).toLowerCase() return s.charAt(0).toUpperCase() + s.slice(1).toLowerCase()

View File

@ -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?.()

View File

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

View File

@ -277,10 +277,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 +447,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 () => {

View File

@ -10,6 +10,7 @@ export const types = {
'string?': (v: any) => toValue(v).type === 'string', 'string?': (v: any) => toValue(v).type === 'string',
string: (v: any) => String(v), string: (v: any) => String(v),
'array?': (v: any) => toValue(v).type === 'array', 'array?': (v: any) => toValue(v).type === 'array',
'list?': (v: any) => toValue(v).type === 'array', 'list?': (v: any) => toValue(v).type === 'array',

View File

@ -1,50 +1,50 @@
:root { :root {
/* Background colors */ /* Background colors */
--bg-editor: #011627; --bg-editor: #011627;
--bg-output: #40318d; --bg-output: #40318D;
--bg-status-bar: #1e2a4a; --bg-status-bar: #1E2A4A;
--bg-status-border: #0e1a3a; --bg-status-border: #0E1A3A;
--bg-selection: #1d3b53; --bg-selection: #1D3B53;
--bg-variable-def: #1e2a4a; --bg-variable-def: #1E2A4A;
/* Text colors */ /* Text colors */
--text-editor: #d6deeb; --text-editor: #D6DEEB;
--text-output: #7c70da; --text-output: #7C70DA;
--text-status: #b3a9ff55; --text-status: #B3A9FF55;
--caret: #80a4c2; --caret: #80A4C2;
/* Syntax highlighting colors */ /* Syntax highlighting colors */
--color-keyword: #c792ea; --color-keyword: #C792EA;
--color-function: #82aaff; --color-function: #82AAFF;
--color-string: #c3e88d; --color-string: #C3E88D;
--color-number: #f78c6c; --color-number: #F78C6C;
--color-bool: #ff5370; --color-bool: #FF5370;
--color-operator: #89ddff; --color-operator: #89DDFF;
--color-paren: #676e95; --color-paren: #676E95;
--color-function-call: #ff9cac; --color-function-call: #FF9CAC;
--color-variable-def: #ffcb6b; --color-variable-def: #FFCB6B;
--color-error: #ff6e6e; --color-error: #FF6E6E;
--color-regex: #e1acff; --color-regex: #E1ACFF;
/* ANSI terminal colors */ /* ANSI terminal colors */
--ansi-black: #011627; --ansi-black: #011627;
--ansi-red: #ff5370; --ansi-red: #FF5370;
--ansi-green: #c3e88d; --ansi-green: #C3E88D;
--ansi-yellow: #ffcb6b; --ansi-yellow: #FFCB6B;
--ansi-blue: #82aaff; --ansi-blue: #82AAFF;
--ansi-magenta: #c792ea; --ansi-magenta: #C792EA;
--ansi-cyan: #89ddff; --ansi-cyan: #89DDFF;
--ansi-white: #d6deeb; --ansi-white: #D6DEEB;
/* ANSI bright colors (slightly more vibrant) */ /* ANSI bright colors (slightly more vibrant) */
--ansi-bright-black: #676e95; --ansi-bright-black: #676E95;
--ansi-bright-red: #ff6e90; --ansi-bright-red: #FF6E90;
--ansi-bright-green: #d4f6a8; --ansi-bright-green: #D4F6A8;
--ansi-bright-yellow: #ffe082; --ansi-bright-yellow: #FFE082;
--ansi-bright-blue: #a8c7fa; --ansi-bright-blue: #A8C7FA;
--ansi-bright-magenta: #e1acff; --ansi-bright-magenta: #E1ACFF;
--ansi-bright-cyan: #a8f5ff; --ansi-bright-cyan: #A8F5FF;
--ansi-bright-white: #ffffff; --ansi-bright-white: #FFFFFF;
} }
@font-face { @font-face {

View File

@ -1,4 +1,4 @@
<!doctype html> <!DOCTYPE html>
<html lang="en"> <html lang="en">
<head> <head>
<meta charset="UTF-8" /> <meta charset="UTF-8" />

View File

@ -20,7 +20,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
} }
} }
@ -146,7 +146,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 +166,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 +174,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 +189,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 +207,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 +215,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 +241,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 +257,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 +265,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")
} }
} }

View File

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

View File

@ -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}"`)
}), })
) )
} }

View File

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

View File

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

View File

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

View File

@ -2,44 +2,44 @@
// 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?', "boolean?",
'date', "date",
'dec', "dec",
'describe', "describe",
'dict', "dict",
'dict?', "dict?",
'each', "each",
'echo', "echo",
'empty?', "empty?",
'exit', "exit",
'fs', "fs",
'function?', "function?",
'identity', "identity",
'import', "import",
'inc', "inc",
'inspect', "inspect",
'json', "json",
'length', "length",
'list', "list",
'list?', "list?",
'load', "load",
'math', "math",
'not', "not",
'null?', "null?",
'number', "number",
'number?', "number?",
'range', "range",
'ref', "ref",
'some?', "some?",
'str', "str",
'string', "string",
'string?', "string?",
'type', "type",
'var', "var",
'var?', "var?"
] as const ] as const

View File

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

View File

@ -124,7 +124,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
} }

View File

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