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, {
method: currentMethod,
line: i + 1,
calls: new Set(),
calls: new Set()
})
}
}
@ -85,7 +85,7 @@ function buildTree(
indent = '',
isLast = true,
depth = 0,
maxDepth = 3,
maxDepth = 3
): string[] {
const lines: string[] = []
const info = callGraph.get(method)
@ -93,7 +93,7 @@ function buildTree(
if (!info) return lines
// Add current method
const prefix = depth === 0 ? '' : isLast ? '└─> ' : '├─> '
const prefix = depth === 0 ? '' : (isLast ? '└─> ' : '├─> ')
const suffix = info.isRecursive ? ' (recursive)' : ''
const lineNum = `[line ${info.line}]`
lines.push(`${indent}${prefix}${method}() ${lineNum}${suffix}`)
@ -116,9 +116,9 @@ function buildTree(
// Get sorted unique calls (filter out recursive self-calls for display)
const calls = Array.from(info.calls)
.filter((c) => callGraph.has(c)) // Only show parser methods
.filter((c) => c !== method) // Don't show immediate self-recursion
.filter((c) => !helperPatterns.test(c)) // Filter out helpers
.filter(c => callGraph.has(c)) // Only show parser methods
.filter(c => c !== method) // Don't show immediate self-recursion
.filter(c => !helperPatterns.test(c)) // Filter out helpers
.sort()
// Add children
@ -131,7 +131,7 @@ function buildTree(
newIndent,
idx === calls.length - 1,
depth + 1,
maxDepth,
maxDepth
)
lines.push(...childLines)
})
@ -163,11 +163,11 @@ console.log(` Entry point: parse()`)
// Find methods that are never called (potential dead code or entry points)
const allCalled = new Set<string>()
for (const info of callGraph.values()) {
info.calls.forEach((c) => allCalled.add(c))
info.calls.forEach(c => allCalled.add(c))
}
const uncalled = Array.from(callGraph.keys())
.filter((m) => !allCalled.has(m) && m !== 'parse')
.filter(m => !allCalled.has(m) && m !== 'parse')
.sort()
if (uncalled.length > 0) {

View File

@ -58,10 +58,7 @@ export class Compiler {
bytecode: Bytecode
pipeCounter = 0
constructor(
public input: string,
globals?: string[] | Record<string, any>,
) {
constructor(public input: string, globals?: string[] | Record<string, any>) {
try {
if (globals) setGlobals(Array.isArray(globals) ? globals : Object.keys(globals))
const ast = parse(input)
@ -112,15 +109,9 @@ export class Compiler {
// Handle sign prefix for hex, binary, and octal literals
// Number() doesn't parse '-0xFF', '+0xFF', '-0o77', etc. correctly
let numberValue: number
if (
value.startsWith('-') &&
(value.includes('0x') || value.includes('0b') || value.includes('0o'))
) {
if (value.startsWith('-') && (value.includes('0x') || value.includes('0b') || value.includes('0o'))) {
numberValue = -Number(value.slice(1))
} else if (
value.startsWith('+') &&
(value.includes('0x') || value.includes('0b') || value.includes('0o'))
) {
} else if (value.startsWith('+') && (value.includes('0x') || value.includes('0b') || value.includes('0o'))) {
numberValue = Number(value.slice(1))
} else {
numberValue = Number(value)
@ -132,7 +123,8 @@ export class Compiler {
return [[`PUSH`, numberValue]]
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)
@ -174,7 +166,7 @@ export class Compiler {
throw new CompilerError(
`Unexpected string part: ${part.type.name}`,
part.from,
part.to,
part.to
)
}
})
@ -382,7 +374,7 @@ export class Compiler {
throw new CompilerError(
`Unknown compound operator: ${opValue}`,
operator.from,
operator.to,
operator.to
)
}
@ -430,8 +422,8 @@ export class Compiler {
catchVariable,
catchBody,
finallyBody,
input,
),
input
)
)
} else {
instructions.push(...compileFunctionBody())
@ -540,7 +532,7 @@ export class Compiler {
...block
.filter((x) => x.type.name !== 'keyword')
.map((x) => this.#compileNode(x!, input))
.flat(),
.flat()
)
instructions.push(['RETURN'])
instructions.push([`${afterLabel}:`])
@ -567,7 +559,7 @@ export class Compiler {
instructions.push(...body)
} else {
throw new Error(
`FunctionCallWithBlock: Expected FunctionCallOrIdentifier or FunctionCall`,
`FunctionCallWithBlock: Expected FunctionCallOrIdentifier or FunctionCall`
)
}
@ -582,7 +574,7 @@ export class Compiler {
catchVariable,
catchBody,
finallyBody,
input,
input
)
}
@ -595,7 +587,7 @@ export class Compiler {
throw new CompilerError(
`${keyword} expected expression, got ${children.length} children`,
node.from,
node.to,
node.to
)
}
@ -609,7 +601,7 @@ export class Compiler {
case 'IfExpr': {
const { conditionNode, thenBlock, elseIfBlocks, elseThenBlock } = getIfExprParts(
node,
input,
input
)
const instructions: ProgramItem[] = []
instructions.push(...this.#compileNode(conditionNode, input))
@ -740,13 +732,13 @@ export class Compiler {
const { identifierNode, namedArgs, positionalArgs } = getFunctionCallParts(
pipeReceiver,
input,
input
)
instructions.push(...this.#compileNode(identifierNode, input))
const isUnderscoreInPositionalArgs = positionalArgs.some((arg) =>
arg.type.is('Underscore'),
const isUnderscoreInPositionalArgs = positionalArgs.some(
(arg) => arg.type.is('Underscore')
)
const isUnderscoreInNamedArgs = namedArgs.some((arg) => {
const { valueNode } = getNamedArgParts(arg, input)
@ -845,12 +837,14 @@ export class Compiler {
case 'Import': {
const instructions: ProgramItem[] = []
const [_import, ...nodes] = getAllChildren(node)
const args = nodes.filter((node) => node.type.is('Identifier'))
const namedArgs = nodes.filter((node) => node.type.is('NamedArg'))
const args = nodes.filter(node => node.type.is('Identifier'))
const namedArgs = nodes.filter(node => node.type.is('NamedArg'))
instructions.push(['LOAD', 'import'])
args.forEach((dict) => instructions.push(['PUSH', input.slice(dict.from, dict.to)]))
args.forEach((dict) =>
instructions.push(['PUSH', input.slice(dict.from, dict.to)])
)
namedArgs.forEach((arg) => {
const { name, valueNode } = getNamedArgParts(arg, input)
@ -873,7 +867,7 @@ export class Compiler {
throw new CompilerError(
`Compiler doesn't know how to handle a "${node.type.name}" node.`,
node.from,
node.to,
node.to
)
}
}
@ -883,7 +877,7 @@ export class Compiler {
catchVariable: string | undefined,
catchBody: SyntaxNode | undefined,
finallyBody: SyntaxNode | undefined,
input: string,
input: string
): ProgramItem[] {
const instructions: ProgramItem[] = []
this.tryLabelCount++

View File

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

View File

@ -112,12 +112,8 @@ describe('compiler', () => {
test('function call with no args', () => {
expect(`bloop = do: 'bleep' end; bloop`).toEvaluateTo('bleep')
expect(`bloop = [ go=do: 'bleep' end ]; bloop.go`).toEvaluateTo('bleep')
expect(`bloop = [ go=do: 'bleep' end ]; abc = do x: x end; abc (bloop.go)`).toEvaluateTo(
'bleep',
)
expect(`num = ((math.random) * 10 + 1) | math.floor; num >= 1 and num <= 10 `).toEvaluateTo(
true,
)
expect(`bloop = [ go=do: 'bleep' end ]; abc = do x: x end; abc (bloop.go)`).toEvaluateTo('bleep')
expect(`num = ((math.random) * 10 + 1) | math.floor; num >= 1 and num <= 10 `).toEvaluateTo(true)
})
test('function call with if statement and multiple expressions', () => {
@ -380,7 +376,7 @@ describe('default params', () => {
age: 60,
})
expect(
'make-person = do person=[name=Bob age=60]: person end; make-person [name=Jon age=21]',
'make-person = do person=[name=Bob age=60]: person end; make-person [name=Jon age=21]'
).toEvaluateTo({ name: 'Jon', age: 21 })
})
})
@ -412,9 +408,7 @@ describe('Nullish coalescing operator (??)', () => {
})
test('short-circuits evaluation', () => {
const throwError = () => {
throw new Error('Should not evaluate')
}
const throwError = () => { throw new Error('Should not evaluate') }
expect('5 ?? throw-error').toEvaluateTo(5, { 'throw-error': throwError })
})
@ -430,7 +424,7 @@ describe('Nullish coalescing operator (??)', () => {
// Use explicit call syntax to invoke the function
expect('(get-value) ?? (get-default)').toEvaluateTo(42, {
'get-value': getValue,
'get-default': getDefault,
'get-default': getDefault
})
})
})
@ -462,9 +456,7 @@ describe('Nullish coalescing assignment (??=)', () => {
})
test('short-circuits evaluation when not null', () => {
const throwError = () => {
throw new Error('Should not evaluate')
}
const throwError = () => { throw new Error('Should not evaluate') }
expect('x = 5; x ??= throw-error; x').toEvaluateTo(5, { 'throw-error': throwError })
})

View File

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

View File

@ -219,7 +219,7 @@ describe('dict literals', () => {
describe('curly strings', () => {
test('work on one line', () => {
expect('{ one two three }').toEvaluateTo(' one two three ')
expect('{ one two three }').toEvaluateTo(" one two three ")
})
test('work on multiple lines', () => {
@ -227,7 +227,7 @@ describe('curly strings', () => {
one
two
three
}`).toEvaluateTo('\n one\n two\n three\n ')
}`).toEvaluateTo("\n one\n two\n three\n ")
})
test('can contain other curlies', () => {
@ -235,7 +235,7 @@ describe('curly strings', () => {
{ one }
two
{ three }
}`).toEvaluateTo('\n { one }\n two\n { three }\n ')
}`).toEvaluateTo("\n { one }\n two\n { three }\n ")
})
test('interpolates variables', () => {
@ -263,7 +263,7 @@ describe('curly strings', () => {
})
describe('double quoted strings', () => {
test('work', () => {
test("work", () => {
expect(`"hello world"`).toEvaluateTo('hello world')
})
@ -272,11 +272,11 @@ describe('double quoted strings', () => {
expect(`"hello $(1 + 2)"`).toEvaluateTo('hello $(1 + 2)')
})
test('equal regular strings', () => {
test("equal regular strings", () => {
expect(`"hello world" == 'hello world'`).toEvaluateTo(true)
})
test('can contain newlines', () => {
test("can contain newlines", () => {
expect(`
"hello
world"`).toEvaluateTo('hello\n world')

View File

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

View File

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

View File

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

View File

@ -45,7 +45,7 @@ export const getAssignmentParts = (node: SyntaxNode) => {
throw new CompilerError(
`Assign expected 3 children, got ${children.length}`,
node.from,
node.to,
node.to
)
}
@ -57,11 +57,10 @@ export const getAssignmentParts = (node: SyntaxNode) => {
if (!left || !left.type.is('AssignableIdentifier')) {
throw new CompilerError(
`Assign left child must be an AssignableIdentifier or Array, got ${
left ? left.type.name : 'none'
`Assign left child must be an AssignableIdentifier or Array, got ${left ? left.type.name : 'none'
}`,
node.from,
node.to,
node.to
)
}
@ -74,17 +73,16 @@ export const getCompoundAssignmentParts = (node: SyntaxNode) => {
if (!left || !left.type.is('AssignableIdentifier')) {
throw new CompilerError(
`CompoundAssign left child must be an AssignableIdentifier, got ${
left ? left.type.name : 'none'
`CompoundAssign left child must be an AssignableIdentifier, got ${left ? left.type.name : 'none'
}`,
node.from,
node.to,
node.to
)
} else if (!operator || !right) {
throw new CompilerError(
`CompoundAssign expected 3 children, got ${children.length}`,
node.from,
node.to,
node.to
)
}
@ -99,7 +97,7 @@ export const getFunctionDefParts = (node: SyntaxNode, input: string) => {
throw new CompilerError(
`FunctionDef expected at least 4 children, got ${children.length}`,
node.from,
node.to,
node.to
)
}
@ -108,7 +106,7 @@ export const getFunctionDefParts = (node: SyntaxNode, input: string) => {
throw new CompilerError(
`FunctionDef params must be Identifier or NamedParam, got ${param.type.name}`,
param.from,
param.to,
param.to
)
}
return input.slice(param.from, param.to)
@ -131,7 +129,7 @@ export const getFunctionDefParts = (node: SyntaxNode, input: string) => {
throw new CompilerError(
`CatchExpr expected identifier and body, got ${catchChildren.length} children`,
child.from,
child.to,
child.to
)
}
catchVariable = input.slice(identifierNode.from, identifierNode.to)
@ -144,7 +142,7 @@ export const getFunctionDefParts = (node: SyntaxNode, input: string) => {
throw new CompilerError(
`FinallyExpr expected body, got ${finallyChildren.length} children`,
child.from,
child.to,
child.to
)
}
finallyBody = body
@ -199,7 +197,7 @@ export const getIfExprParts = (node: SyntaxNode, input: string) => {
throw new CompilerError(
`IfExpr expected at least 4 children, got ${children.length}`,
node.from,
node.to,
node.to
)
}
@ -253,6 +251,7 @@ export const getStringParts = (node: SyntaxNode, input: string) => {
child.type.is('Interpolation') ||
child.type.is('EscapeSeq') ||
child.type.is('CurlyString')
)
})
@ -267,14 +266,16 @@ export const getStringParts = (node: SyntaxNode, input: string) => {
throw new CompilerError(
`String child must be StringFragment, Interpolation, or EscapeSeq, got ${part.type.name}`,
part.from,
part.to,
part.to
)
}
})
// hasInterpolation means the string has interpolation ($var) or escape sequences (\n)
// A simple string like 'hello' has one StringFragment but no interpolation
const hasInterpolation = parts.some((p) => p.type.is('Interpolation') || p.type.is('EscapeSeq'))
const hasInterpolation = parts.some(
(p) => p.type.is('Interpolation') || p.type.is('EscapeSeq')
)
return { parts, hasInterpolation }
}
@ -286,7 +287,7 @@ export const getDotGetParts = (node: SyntaxNode, input: string) => {
throw new CompilerError(
`DotGet expected 2 identifier children, got ${children.length}`,
node.from,
node.to,
node.to
)
}
@ -294,7 +295,7 @@ export const getDotGetParts = (node: SyntaxNode, input: string) => {
throw new CompilerError(
`DotGet object must be an IdentifierBeforeDot, got ${object.type.name}`,
object.from,
object.to,
object.to
)
}
@ -302,7 +303,7 @@ export const getDotGetParts = (node: SyntaxNode, input: string) => {
throw new CompilerError(
`DotGet property must be an Identifier, Number, ParenExpr, or DotGet, got ${property.type.name}`,
property.from,
property.to,
property.to
)
}
@ -321,7 +322,7 @@ export const getTryExprParts = (node: SyntaxNode, input: string) => {
throw new CompilerError(
`TryExpr expected at least 3 children, got ${children.length}`,
node.from,
node.to,
node.to
)
}
@ -340,7 +341,7 @@ export const getTryExprParts = (node: SyntaxNode, input: string) => {
throw new CompilerError(
`CatchExpr expected identifier and body, got ${catchChildren.length} children`,
child.from,
child.to,
child.to
)
}
catchVariable = input.slice(identifierNode.from, identifierNode.to)
@ -353,7 +354,7 @@ export const getTryExprParts = (node: SyntaxNode, input: string) => {
throw new CompilerError(
`FinallyExpr expected body, got ${finallyChildren.length} children`,
child.from,
child.to,
child.to
)
}
finallyBody = body

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -53,10 +53,7 @@ export class Shrimp {
let bytecode
if (typeof code === 'string') {
const compiler = new Compiler(
code,
Object.keys(Object.assign({}, prelude, this.globals ?? {}, locals ?? {})),
)
const compiler = new Compiler(code, Object.keys(Object.assign({}, prelude, this.globals ?? {}, locals ?? {})))
bytecode = compiler.bytecode
} else {
bytecode = code
@ -69,6 +66,7 @@ export class Shrimp {
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> {

View File

@ -44,7 +44,8 @@ export const tokenizeCurlyString = (value: string): (string | [string, SyntaxNod
if (!char) break
if (!isIdentStart(char.charCodeAt(0))) break
while (char && isIdentChar(char.charCodeAt(0))) char = value[++pos]
while (char && isIdentChar(char.charCodeAt(0)))
char = value[++pos]
const input = value.slice(start + 1, pos) // skip '$'
tokens.push([input, parse(input)])

View File

@ -3,15 +3,18 @@ import { type Token, TokenType } from './tokenizer2'
export type NodeType =
| 'Program'
| 'Block'
| 'FunctionCall'
| 'FunctionCallOrIdentifier'
| 'FunctionCallWithBlock'
| 'PositionalArg'
| 'NamedArg'
| 'NamedArgPrefix'
| 'FunctionDef'
| 'Params'
| 'NamedParam'
| 'Null'
| 'Boolean'
| 'Number'
@ -29,6 +32,7 @@ export type NodeType =
| 'Array'
| 'Dict'
| 'Comment'
| 'BinOp'
| 'ConditionalOp'
| 'ParenExpr'
@ -36,6 +40,7 @@ export type NodeType =
| 'CompoundAssign'
| 'DotGet'
| 'PipeExpr'
| 'IfExpr'
| 'ElseIfExpr'
| 'ElseExpr'
@ -44,12 +49,14 @@ export type NodeType =
| 'CatchExpr'
| 'FinallyExpr'
| 'Throw'
| 'Not'
| 'Eq'
| 'Modulo'
| 'Plus'
| 'Star'
| 'Slash'
| 'Import'
| 'Do'
| 'Underscore'
@ -60,13 +67,13 @@ export type NodeType =
// TODO: remove this when we switch from lezer
export const operators: Record<string, any> = {
// Logic
and: 'And',
or: 'Or',
'and': 'And',
'or': 'Or',
// Bitwise
band: 'Band',
bor: 'Bor',
bxor: 'Bxor',
'band': 'Band',
'bor': 'Bor',
'bxor': 'Bxor',
'>>>': 'Ushr',
'>>': 'Shr',
'<<': 'Shl',
@ -107,7 +114,7 @@ export const operators: Record<string, any> = {
}
export class Tree {
constructor(public topNode: SyntaxNode) {}
constructor(public topNode: SyntaxNode) { }
get length(): number {
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)
}
get type(): {
type: NodeType
name: NodeType
isError: boolean
is: (other: NodeType) => boolean
} {
get type(): { type: NodeType, name: NodeType, isError: boolean, is: (other: NodeType) => boolean } {
return {
type: this.#type,
name: this.#type,
isError: this.#isError,
is: (other: NodeType) => other === this.#type,
is: (other: NodeType) => other === this.#type
}
}
@ -209,7 +211,7 @@ export class SyntaxNode {
}
push(...nodes: SyntaxNode[]): SyntaxNode {
nodes.forEach((child) => (child.parent = this))
nodes.forEach(child => child.parent = this)
this.children.push(...nodes)
return this
}
@ -222,8 +224,8 @@ export class SyntaxNode {
// Operator precedence (binding power) - higher = tighter binding
export const precedence: Record<string, number> = {
// Logical
or: 10,
and: 20,
'or': 10,
'and': 20,
// Comparison
'==': 30,
@ -246,9 +248,9 @@ export const precedence: Record<string, number> = {
'-': 40,
// Bitwise AND/OR/XOR (higher precedence than addition)
band: 45,
bor: 45,
bxor: 45,
'band': 45,
'bor': 45,
'bxor': 45,
// Multiplication/Division/Modulo
'*': 50,
@ -259,6 +261,10 @@ export const precedence: Record<string, number> = {
'**': 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
inParens = 0
input = ''
scope = new Scope()
scope = new Scope
inTestExpr = false
parse(input: string): SyntaxNode {
@ -78,11 +78,14 @@ export class Parser {
// statement is a line of code
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()
}
@ -96,38 +99,51 @@ export class Parser {
let expr
// x = value
if (
this.is($T.Identifier) &&
(this.nextIs($T.Operator, '=') || compounds.some((x) => this.nextIs($T.Operator, x)))
)
if (this.is($T.Identifier) && (
this.nextIs($T.Operator, '=') || compounds.some(x => this.nextIs($T.Operator, x))
))
expr = this.assign()
// if, while, do, etc
else if (this.is($T.Keyword)) expr = this.keywords()
else if (this.is($T.Keyword))
expr = this.keywords()
// dotget
else if (this.nextIs($T.Operator, '.')) expr = this.dotGetFunctionCall()
else if (this.nextIs($T.Operator, '.'))
expr = this.dotGetFunctionCall()
// echo hello world
else if (this.is($T.Identifier) && !this.nextIs($T.Operator) && !this.nextIsExprEnd())
expr = this.functionCall()
// bare-function-call
else if (this.is($T.Identifier) && this.nextIsExprEnd()) expr = this.functionCallOrIdentifier()
else if (this.is($T.Identifier) && this.nextIsExprEnd())
expr = this.functionCallOrIdentifier()
// everything else
else expr = this.exprWithPrecedence()
else
expr = this.exprWithPrecedence()
// 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
// 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 (expr.type.is('DotGet') && this.is($T.Operator) && !this.is($T.Operator, '|'))
expr = this.dotGetBinOp(expr)
// one | echo
if (allowPipe && this.isPipe()) return this.pipe(expr)
if (allowPipe && this.isPipe())
return this.pipe(expr)
// regular
else return expr
else
return expr
}
// piping | stuff | is | cool
@ -191,19 +207,26 @@ export class Parser {
// if, while, do, etc
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
}
@ -215,12 +238,15 @@ export class Parser {
// 3. binary operations
// 4. anywhere an expression can be used
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
if (this.nextIs($T.Operator, '.')) return this.dotGet()
if (this.nextIs($T.Operator, '.'))
return this.dotGet()
return this.atom()
}
@ -298,7 +324,8 @@ export class Parser {
}
// 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++)
}
@ -316,7 +343,7 @@ export class Parser {
const node = new SyntaxNode(
opToken.value === '=' ? 'Assign' : 'CompoundAssign',
ident.from,
expr.to,
expr.to
)
return node.push(ident, op, expr)
@ -333,7 +360,8 @@ export class Parser {
// atoms are the basic building blocks: literals, identifiers, words
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))
return SyntaxNode.from(this.next())
@ -374,7 +402,8 @@ export class Parser {
const keyword = this.keyword('catch')
let catchVar
if (this.is($T.Identifier)) catchVar = this.identifier()
if (this.is($T.Identifier))
catchVar = this.identifier()
const block = this.block()
@ -478,14 +507,12 @@ export class Parser {
this.scope.add(varName)
let arg
if (this.is($T.Identifier)) arg = this.identifier()
else if (this.is($T.NamedArgPrefix)) arg = this.namedParam()
if (this.is($T.Identifier))
arg = this.identifier()
else if (this.is($T.NamedArgPrefix))
arg = this.namedParam()
else
throw new CompilerError(
`Expected Identifier or NamedArgPrefix, got ${TokenType[this.current().type]}`,
this.current().from,
this.current().to,
)
throw new CompilerError(`Expected Identifier or NamedArgPrefix, got ${TokenType[this.current().type]}`, this.current().from, this.current().to)
params.push(arg)
}
@ -493,9 +520,11 @@ export class Parser {
const block = this.block(false)
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')
@ -507,7 +536,11 @@ export class Parser {
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)
node.add(paramsNode)
@ -528,7 +561,8 @@ export class Parser {
const ident = this.input.slice(left.from, left.to)
// not in scope, just return Word
if (!this.scope.has(ident)) return this.word(left)
if (!this.scope.has(ident))
return this.word(left)
if (left.type.is('Identifier')) left.type = 'IdentifierBeforeDot'
@ -568,13 +602,16 @@ export class Parser {
const dotGet = this.dotGet()
// if followed by a binary operator (not pipe), return dotGet/Word as-is for expression parser
if (this.is($T.Operator) && !this.is($T.Operator, '|')) return dotGet
if (this.is($T.Operator) && !this.is($T.Operator, '|'))
return dotGet
// dotget not in scope, regular Word
if (dotGet.type.is('Word')) return dotGet
if (this.isExprEnd()) return this.functionCallOrIdentifier(dotGet)
else return this.functionCall(dotGet)
if (this.isExprEnd())
return this.functionCallOrIdentifier(dotGet)
else
return this.functionCall(dotGet)
}
// can be used in functions or try block
@ -726,11 +763,7 @@ export class Parser {
const val = this.value()
if (!['Null', 'Boolean', 'Number', 'String'].includes(val.type.name))
throw new CompilerError(
`Default value must be null, boolean, number, or string, got ${val.type.name}`,
val.from,
val.to,
)
throw new CompilerError(`Default value must be null, boolean, number, or string, got ${val.type.name}`, val.from, val.to)
const node = new SyntaxNode('NamedParam', prefix.from, val.to)
return node.push(prefix, val)
@ -748,8 +781,7 @@ export class Parser {
op(op?: string): SyntaxNode {
const token = op ? this.expect($T.Operator, op) : this.expect($T.Operator)
const name = operators[token.value!]
if (!name)
throw new CompilerError(`Operator not registered: ${token.value!}`, token.from, token.to)
if (!name) throw new CompilerError(`Operator not registered: ${token.value!}`, 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 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')
@ -808,9 +842,11 @@ export class Parser {
const node = new SyntaxNode('TryExpr', tryNode.from, last!.to)
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)
}
@ -832,7 +868,8 @@ export class Parser {
while (this.is($T.Operator, '.')) {
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)
@ -855,7 +892,8 @@ export class Parser {
let offset = 1
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 (value !== undefined && peek.value !== value) return false
@ -876,7 +914,7 @@ export class Parser {
}
isAny(...type: TokenType[]): boolean {
return type.some((x) => this.is(x))
return type.some(x => this.is(x))
}
nextIs(type: TokenType, value?: string): boolean {
@ -887,58 +925,43 @@ export class Parser {
}
nextIsAny(...type: TokenType[]): boolean {
return type.some((x) => this.nextIs(x))
return type.some(x => this.nextIs(x))
}
isExprEnd(): boolean {
return (
this.isAny($T.Colon, $T.Semicolon, $T.Newline, $T.CloseParen, $T.CloseBracket) ||
return this.isAny($T.Colon, $T.Semicolon, $T.Newline, $T.CloseParen, $T.CloseBracket) ||
this.is($T.Operator, '|') ||
this.isExprEndKeyword() ||
!this.current()
)
this.isExprEndKeyword() || !this.current()
}
nextIsExprEnd(): boolean {
// pipes act like expression end for function arg parsing
if (this.nextIs($T.Operator, '|')) return true
if (this.nextIs($T.Operator, '|'))
return true
return (
this.nextIsAny($T.Colon, $T.Semicolon, $T.Newline, $T.CloseBracket, $T.CloseParen) ||
this.nextIs($T.Keyword, 'end') ||
this.nextIs($T.Keyword, 'else') ||
this.nextIs($T.Keyword, 'catch') ||
this.nextIs($T.Keyword, 'finally') ||
return this.nextIsAny($T.Colon, $T.Semicolon, $T.Newline, $T.CloseBracket, $T.CloseParen) ||
this.nextIs($T.Keyword, 'end') || this.nextIs($T.Keyword, 'else') ||
this.nextIs($T.Keyword, 'catch') || this.nextIs($T.Keyword, 'finally') ||
!this.peek()
)
}
isExprEndKeyword(): boolean {
return (
this.is($T.Keyword, 'end') ||
this.is($T.Keyword, 'else') ||
this.is($T.Keyword, 'catch') ||
this.is($T.Keyword, 'finally')
)
return this.is($T.Keyword, 'end') || this.is($T.Keyword, 'else') ||
this.is($T.Keyword, 'catch') || this.is($T.Keyword, 'finally')
}
isPipe(): boolean {
// inside parens, only look for pipes on same line (don't look past newlines)
const canLookPastNewlines = this.inParens === 0
return (
this.is($T.Operator, '|') || (canLookPastNewlines && this.peekPastNewlines($T.Operator, '|'))
)
return this.is($T.Operator, '|') ||
(canLookPastNewlines && this.peekPastNewlines($T.Operator, '|'))
}
expect(type: TokenType, value?: string): Token | never {
if (!this.is(type, value)) {
const token = this.current()
throw new CompilerError(
`Expected ${TokenType[type]}${value ? ` "${value}"` : ''}, got ${TokenType[token?.type || 0]}${token?.value ? ` "${token.value}"` : ''} at position ${this.pos}`,
token.from,
token.to,
)
throw new CompilerError(`Expected ${TokenType[type]}${value ? ` "${value}"` : ''}, got ${TokenType[token?.type || 0]}${token?.value ? ` "${token.value}"` : ''} at position ${this.pos}`, token.from, token.to)
}
return this.next()
}
@ -958,7 +981,7 @@ function collapseDotGets(origNodes: SyntaxNode[]): SyntaxNode {
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)
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'
* Supports: interpolation ($var, $(expr)), escape sequences (\n, \$, etc)
*/
const parseSingleQuoteString = (
stringNode: SyntaxNode,
input: string,
from: number,
to: number,
parser: any,
) => {
const parseSingleQuoteString = (stringNode: SyntaxNode, input: string, from: number, to: number, parser: any) => {
let pos = from + 1 // Skip opening '
let fragmentStart = pos
while (pos < to - 1) {
// -1 to skip closing '
while (pos < to - 1) { // -1 to skip closing '
const char = input[pos]
// Escape sequence
@ -122,13 +115,7 @@ const parseSingleQuoteString = (
* Supports: interpolation ($var, $(expr)), nested braces
* Does NOT support: escape sequences (raw content)
*/
const parseCurlyString = (
stringNode: SyntaxNode,
input: string,
from: number,
to: number,
parser: any,
) => {
const parseCurlyString = (stringNode: SyntaxNode, input: string, from: number, to: number, parser: any) => {
let pos = from + 1 // Skip opening {
let fragmentStart = from // Include the opening { in the fragment
let depth = 1
@ -201,11 +188,7 @@ const parseCurlyString = (
* Returns the parsed expression node and the position after the closing )
* pos is position of the opening ( in the full input string
*/
const parseInterpolationExpr = (
input: string,
pos: number,
parser: any,
): { node: SyntaxNode; endPos: number } => {
const parseInterpolationExpr = (input: string, pos: number, parser: any): { node: SyntaxNode, endPos: number } => {
// Find matching closing paren
let depth = 1
let start = pos

View File

@ -195,6 +195,7 @@ describe('if/else if/else', () => {
`)
})
test('parses function calls in else-if tests', () => {
expect(`if false: true else if var? 'abc': true end`).toMatchTree(`
IfExpr
@ -285,6 +286,7 @@ describe('while', () => {
keyword end`)
})
test('compound expression', () => {
expect(`while a > 0 and b < 100 and c < 1000: true end`).toMatchTree(`
WhileExpr
@ -342,6 +344,7 @@ describe('while', () => {
keyword end`)
})
test('multiline compound expression', () => {
expect(`
while a > 0 and b < 100 and c < 1000:

View File

@ -14,7 +14,8 @@ describe('single line function blocks', () => {
Identifier bye
PositionalArg
Identifier bye
keyword end`)
keyword end`
)
})
test('work with one arg', () => {
@ -32,7 +33,8 @@ describe('single line function blocks', () => {
Identifier bye
PositionalArg
Identifier bye
keyword end`)
keyword end`
)
})
test('work with named args', () => {
@ -52,9 +54,11 @@ describe('single line function blocks', () => {
Identifier bye
PositionalArg
Identifier bye
keyword end`)
keyword end`
)
})
test('work with dot-get', () => {
expect(`signals = [=]; signals.trap 'EXIT': echo bye bye end`).toMatchTree(`
Assign
@ -77,7 +81,8 @@ describe('single line function blocks', () => {
Identifier bye
PositionalArg
Identifier bye
keyword end`)
keyword end`
)
})
})
@ -99,7 +104,8 @@ end
Identifier bye
PositionalArg
Identifier bye
keyword end`)
keyword end`
)
})
test('work with one arg', () => {
@ -120,7 +126,8 @@ end`).toMatchTree(`
Identifier bye
PositionalArg
Identifier bye
keyword end`)
keyword end`
)
})
test('work with named args', () => {
@ -146,9 +153,11 @@ end`).toMatchTree(`
Identifier bye
PositionalArg
Identifier bye
keyword end`)
keyword end`
)
})
test('work with dot-get', () => {
expect(`
signals = [=]
@ -175,7 +184,8 @@ end`).toMatchTree(`
Identifier bye
PositionalArg
Identifier bye
keyword end`)
keyword end`
)
})
})
@ -252,7 +262,8 @@ end`).toMatchTree(`
p:
h1 class=bright style='font-family: helvetica' Heya
h2 man that is (b wild)!
end`).toMatchTree(`
end`)
.toMatchTree(`
FunctionCallWithBlock
FunctionCallOrIdentifier
Identifier p

View File

@ -92,6 +92,7 @@ describe('calling functions', () => {
`)
})
test('command with arg that is also a command', () => {
expect('tail tail').toMatchTree(`
FunctionCall

View File

@ -161,7 +161,7 @@ describe('curly strings', () => {
})
describe('double quoted strings', () => {
test('work', () => {
test("work", () => {
expect(`"hello world"`).toMatchTree(`
String
DoubleQuote "hello world"`)

View File

@ -15,7 +15,10 @@ describe('numbers', () => {
test('non-numbers', () => {
expect(`1st`).toMatchToken('Word', '1st')
expect(`1_`).toMatchToken('Word', '1_')
expect(`100.`).toMatchTokens({ type: 'Number', value: '100' }, { type: 'Operator', value: '.' })
expect(`100.`).toMatchTokens(
{ type: 'Number', value: '100' },
{ type: 'Operator', value: '.' },
)
})
test('simple numbers', () => {
@ -127,7 +130,10 @@ describe('identifiers', () => {
expect('dog#pound').toMatchToken('Word', 'dog#pound')
expect('http://website.com').toMatchToken('Word', 'http://website.com')
expect('school$cool').toMatchToken('Identifier', 'school$cool')
expect('EXIT:').toMatchTokens({ type: 'Word', value: 'EXIT' }, { type: 'Colon' })
expect('EXIT:').toMatchTokens(
{ type: 'Word', value: 'EXIT' },
{ type: 'Colon' },
)
expect(`if y == 1: 'cool' end`).toMatchTokens(
{ type: 'Keyword', value: 'if' },
{ type: 'Identifier', value: 'y' },
@ -208,24 +214,18 @@ describe('curly strings', () => {
expect(`{
one
two
three }`).toMatchToken(
'String',
`{
three }`).toMatchToken('String', `{
one
two
three }`,
)
three }`)
})
test('can contain other curlies', () => {
expect(`{ { one }
two
{ three } }`).toMatchToken(
'String',
`{ { one }
{ three } }`).toMatchToken('String', `{ { one }
two
{ three } }`,
)
{ three } }`)
})
test('empty curly string', () => {
@ -408,12 +408,12 @@ f
]`).toMatchTokens(
{ type: 'OpenBracket' },
{ type: 'Identifier', value: 'a' },
{ type: 'Identifier', value: 'b' },
{ type: 'Identifier', value: 'c' },
{ type: 'Identifier', value: 'd' },
{ type: 'Identifier', value: 'e' },
{ type: 'Identifier', value: 'f' },
{ type: 'Identifier', value: "a" },
{ type: 'Identifier', value: "b" },
{ type: 'Identifier', value: "c" },
{ type: 'Identifier', value: "d" },
{ type: 'Identifier', value: "e" },
{ type: 'Identifier', value: "f" },
{ type: 'CloseBracket' },
)
})
@ -506,6 +506,7 @@ f
{ type: 'Identifier', value: 'y' },
)
expect(`if (var? 'abc'): y`).toMatchTokens(
{ type: 'Keyword', value: 'if' },
{ type: 'OpenParen' },
@ -551,25 +552,25 @@ end`).toMatchTokens(
test('dot operator beginning word with slash', () => {
expect(`(basename ./cool)`).toMatchTokens(
{ type: 'OpenParen' },
{ type: 'Identifier', value: 'basename' },
{ type: 'Word', value: './cool' },
{ type: 'CloseParen' },
{ 'type': 'OpenParen' },
{ 'type': 'Identifier', 'value': 'basename' },
{ 'type': 'Word', 'value': './cool' },
{ 'type': 'CloseParen' }
)
})
test('dot word after identifier with space', () => {
expect(`expand-path .git`).toMatchTokens(
{ type: 'Identifier', value: 'expand-path' },
{ type: 'Word', value: '.git' },
{ 'type': 'Identifier', 'value': 'expand-path' },
{ 'type': 'Word', 'value': '.git' },
)
})
test('dot operator after identifier without space', () => {
expect(`config.path`).toMatchTokens(
{ type: 'Identifier', value: 'config' },
{ type: 'Operator', value: '.' },
{ type: 'Identifier', value: 'path' },
{ 'type': 'Identifier', 'value': 'config' },
{ 'type': 'Operator', 'value': '.' },
{ 'type': 'Identifier', 'value': 'path' },
)
})
})
@ -648,7 +649,11 @@ describe('empty and whitespace input', () => {
})
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(
{ type: 'NamedArgPrefix', value: 'named=' },
{ type: 'Identifier', value: 'arg' },
)
})
test('can include numbers', () => {
test("can include numbers", () => {
expect(`named123= arg`).toMatchTokens(
{ type: 'NamedArgPrefix', value: 'named123=' },
{ type: 'Identifier', value: 'arg' },

View File

@ -2,9 +2,9 @@ const DEBUG = process.env.DEBUG || false
export type Token = {
type: TokenType
value?: string
from: number
to: number
value?: string,
from: number,
to: number,
}
export enum TokenType {
@ -36,16 +36,10 @@ export enum TokenType {
const valueTokens = new Set([
TokenType.Comment,
TokenType.Keyword,
TokenType.Operator,
TokenType.Identifier,
TokenType.Word,
TokenType.NamedArgPrefix,
TokenType.Boolean,
TokenType.Number,
TokenType.String,
TokenType.Regex,
TokenType.Underscore,
TokenType.Keyword, TokenType.Operator,
TokenType.Identifier, TokenType.Word, TokenType.NamedArgPrefix,
TokenType.Boolean, TokenType.Number, TokenType.String, TokenType.Regex,
TokenType.Underscore
])
const operators = new Set([
@ -115,7 +109,7 @@ const keywords = new Set([
// helper
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 {
@ -161,17 +155,11 @@ export class Scanner {
to ??= this.pos - getCharSize(this.char)
if (to < from) to = from
this.tokens.push(
Object.assign(
{},
{
this.tokens.push(Object.assign({}, {
type,
from,
to,
},
valueTokens.has(type) ? { value: this.input.slice(from, to) } : {},
),
)
}, valueTokens.has(type) ? { value: this.input.slice(from, to) } : {}))
if (DEBUG) {
const tok = this.tokens.at(-1)
@ -250,7 +238,8 @@ export class Scanner {
}
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()
continue
}
@ -277,20 +266,16 @@ export class Scanner {
switch (this.char) {
case c`(`:
this.inParen++
this.pushChar(TokenType.OpenParen)
break
this.pushChar(TokenType.OpenParen); break
case c`)`:
this.inParen--
this.pushChar(TokenType.CloseParen)
break
this.pushChar(TokenType.CloseParen); break
case c`[`:
this.inBracket++
this.pushChar(TokenType.OpenBracket)
break
this.pushChar(TokenType.OpenBracket); break
case c`]`:
this.inBracket--
this.pushChar(TokenType.CloseBracket)
break
this.pushChar(TokenType.CloseBracket); break
}
this.next()
}
@ -354,14 +339,29 @@ export class Scanner {
const word = this.input.slice(this.start, this.pos - getCharSize(this.char))
// classify the token based on what we read
if (word === '_') this.push(TokenType.Underscore)
else if (word === 'null') this.push(TokenType.Null)
else if (word === 'true' || word === 'false') this.push(TokenType.Boolean)
else if (isKeyword(word)) this.push(TokenType.Keyword)
else if (isOperator(word)) this.push(TokenType.Operator)
else if (isIdentifer(word)) this.push(TokenType.Identifier)
else if (word.endsWith('=')) this.push(TokenType.NamedArgPrefix)
else this.push(TokenType.Word)
if (word === '_')
this.push(TokenType.Underscore)
else if (word === 'null')
this.push(TokenType.Null)
else if (word === 'true' || word === 'false')
this.push(TokenType.Boolean)
else if (isKeyword(word))
this.push(TokenType.Keyword)
else if (isOperator(word))
this.push(TokenType.Operator)
else if (isIdentifer(word))
this.push(TokenType.Identifier)
else if (word.endsWith('='))
this.push(TokenType.NamedArgPrefix)
else
this.push(TokenType.Word)
}
readNumber() {
@ -394,7 +394,8 @@ export class Scanner {
this.next() // skip /
// read regex flags
while (this.char > 0 && isIdentStart(this.char)) this.next()
while (this.char > 0 && isIdentStart(this.char))
this.next()
// validate regex
const to = this.pos - getCharSize(this.char)
@ -421,29 +422,30 @@ export class Scanner {
}
canBeDotGet(lastToken?: Token): boolean {
return (
!this.prevIsWhitespace &&
!!lastToken &&
return !this.prevIsWhitespace && !!lastToken &&
(lastToken.type === TokenType.Identifier ||
lastToken.type === TokenType.Number ||
lastToken.type === TokenType.CloseParen ||
lastToken.type === TokenType.CloseBracket)
)
}
}
const isNumber = (word: string): boolean => {
// regular number
if (/^[+-]?\d+(_?\d+)*(\.(\d+(_?\d+)*))?$/.test(word)) return true
if (/^[+-]?\d+(_?\d+)*(\.(\d+(_?\d+)*))?$/.test(word))
return true
// binary
if (/^[+-]?0b[01]+(_?[01]+)*(\.[01](_?[01]*))?$/.test(word)) return true
if (/^[+-]?0b[01]+(_?[01]+)*(\.[01](_?[01]*))?$/.test(word))
return true
// octal
if (/^[+-]?0o[0-7]+(_?[0-7]+)*(\.[0-7](_?[0-7]*))?$/.test(word)) return true
if (/^[+-]?0o[0-7]+(_?[0-7]+)*(\.[0-7](_?[0-7]*))?$/.test(word))
return true
// hex
if (/^[+-]?0x[0-9a-f]+([0-9a-f]_?[0-9a-f]+)*(\.([0-9a-f]_?[0-9a-f]*))?$/i.test(word)) return true
if (/^[+-]?0x[0-9a-f]+([0-9a-f]_?[0-9a-f]+)*(\.([0-9a-f]_?[0-9a-f]*))?$/i.test(word))
return true
return false
}
@ -459,14 +461,14 @@ const isIdentifer = (s: string): boolean => {
chars.push(out)
}
if (chars.length === 1) return isIdentStart(chars[0]!)
else if (chars.length === 2) return isIdentStart(chars[0]!) && isIdentEnd(chars[1]!)
if (chars.length === 1)
return isIdentStart(chars[0]!)
else if (chars.length === 2)
return isIdentStart(chars[0]!) && isIdentEnd(chars[1]!)
else
return (
isIdentStart(chars[0]!) &&
return isIdentStart(chars[0]!) &&
chars.slice(1, chars.length - 1).every(isIdentChar) &&
isIdentEnd(chars.at(-1)!)
)
}
const isStringDelim = (ch: number): boolean => {
@ -496,14 +498,9 @@ const isDigit = (ch: number): boolean => {
}
const isWhitespace = (ch: number): boolean => {
return (
ch === 32 /* space */ ||
ch === 9 /* tab */ ||
ch === 13 /* \r */ ||
ch === 10 /* \n */ ||
ch === -1 ||
ch === 0
) /* EOF */
return ch === 32 /* space */ || ch === 9 /* tab */ ||
ch === 13 /* \r */ || ch === 10 /* \n */ ||
ch === -1 || ch === 0 /* EOF */
}
const isWordChar = (ch: number): boolean => {
@ -530,7 +527,8 @@ const isBracket = (char: number): boolean => {
return char === c`(` || char === c`)` || char === c`[` || char === c`]`
}
const getCharSize = (ch: number) => (ch > 0xffff ? 2 : 1) // emoji takes 2 UTF-16 code units
const getCharSize = (ch: number) =>
(ch > 0xffff ? 2 : 1) // emoji takes 2 UTF-16 code units
const getFullCodePoint = (input: string, pos: number): number => {
const ch = input[pos]?.charCodeAt(0) || 0

View File

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

View File

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

View File

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

View File

@ -2,12 +2,8 @@
import { join, resolve } from 'path'
import {
type Value,
type VM,
toValue,
extractParamInfo,
isWrapped,
getOriginalFunction,
type Value, type VM, toValue,
extractParamInfo, isWrapped, getOriginalFunction,
} from 'reefvm'
import { date } from './date'
@ -39,18 +35,16 @@ export const globals: Record<string, any> = {
cwd: process.env.PWD,
script: {
name: Bun.argv[2] || '(shrimp)',
path: resolve(join('.', Bun.argv[2] ?? '')),
path: resolve(join('.', Bun.argv[2] ?? ''))
},
},
// hello
echo: (...args: any[]) => {
console.log(
...args.map((a) => {
console.log(...args.map(a => {
const v = toValue(a)
return ['array', 'dict'].includes(v.type) ? formatValue(v, true) : v.value
}),
)
}))
return toValue(null)
},
@ -69,10 +63,11 @@ export const globals: Record<string, any> = {
},
ref: (fn: Function) => fn,
import: function (this: VM, atNamed: Record<any, string | string[]> = {}, ...idents: string[]) {
const onlyArray = Array.isArray(atNamed.only) ? atNamed.only : [atNamed.only].filter((a) => a)
const onlyArray = Array.isArray(atNamed.only) ? atNamed.only : [atNamed.only].filter(a => a)
const only = new Set(onlyArray)
const wantsOnly = only.size > 0
for (const ident of idents) {
const module = this.get(ident)
@ -105,13 +100,9 @@ export const globals: Record<string, any> = {
length: (v: any) => {
const value = toValue(v)
switch (value.type) {
case 'string':
case 'array':
return value.value.length
case 'dict':
return value.value.size
default:
throw new Error(`length: expected string, array, or dict, got ${value.type}`)
case 'string': case 'array': return value.value.length
case 'dict': return value.value.size
default: throw new Error(`length: expected string, array, or dict, got ${value.type}`)
}
},
at: (collection: any, index: number | string) => {
@ -119,9 +110,7 @@ export const globals: Record<string, any> = {
if (value.type === 'string' || value.type === 'array') {
const idx = typeof index === 'number' ? index : parseInt(index as string)
if (idx < 0 || idx >= value.value.length) {
throw new Error(
`at: index ${idx} out of bounds for ${value.type} of length ${value.value.length}`,
)
throw new Error(`at: index ${idx} out of bounds for ${value.type} of length ${value.value.length}`)
}
return value.value[idx]
} else if (value.type === 'dict') {
@ -148,8 +137,7 @@ export const globals: Record<string, any> = {
'empty?': (v: any) => {
const value = toValue(v)
switch (value.type) {
case 'string':
case 'array':
case 'string': case 'array':
return value.value.length === 0
case 'dict':
return value.value.size === 0
@ -163,6 +151,7 @@ export const globals: Record<string, any> = {
for (const value of list) await cb(value)
return list
},
}
export const colors = {
@ -175,7 +164,7 @@ export const colors = {
red: '\x1b[31m',
blue: '\x1b[34m',
magenta: '\x1b[35m',
pink: '\x1b[38;2;255;105;180m',
pink: '\x1b[38;2;255;105;180m'
}
export function formatValue(value: Value, inner = false): string {
@ -189,15 +178,15 @@ export function formatValue(value: Value, inner = false): string {
case 'null':
return `${colors.dim}null${colors.reset}`
case 'array': {
const items = value.value.map((x) => formatValue(x, true)).join(' ')
const items = value.value.map(x => formatValue(x, true)).join(' ')
return `${colors.blue}[${colors.reset}${items}${colors.blue}]${colors.reset}`
}
case 'dict': {
const entries = Array.from(value.value.entries())
.reverse()
const entries = Array.from(value.value.entries()).reverse()
.map(([k, v]) => `${k.trim()}${colors.blue}=${colors.reset}${formatValue(v, true)}`)
.join(' ')
if (entries.length === 0) return `${colors.blue}[=]${colors.reset}`
if (entries.length === 0)
return `${colors.blue}[=]${colors.reset}`
return `${colors.blue}[${colors.reset}${entries}${colors.blue}]${colors.reset}`
}
case 'function': {
@ -217,4 +206,5 @@ export function formatValue(value: Value, inner = false): string {
}
// 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),
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) => {
for (const value of list) {
if (!(await cb(value))) return false
if (!await cb(value)) return false
}
return true
},
@ -131,7 +131,7 @@ export const list = {
}
return [truthy, falsy]
},
compact: (list: any[]) => list.filter((x) => x != null),
compact: (list: any[]) => list.filter(x => x != null),
'group-by': async (list: any[], cb: Function) => {
const groups: Record<string, any[]> = {}
for (const value of list) {
@ -143,11 +143,12 @@ export const list = {
},
}
// raw functions deal directly in Value types, meaning we can modify collection
// careful - they MUST return a Value!
;(list.splice as any).raw = true
;(list.push as any).raw = true
;(list.pop as any).raw = true
;(list.shift as any).raw = true
;(list.unshift as any).raw = true
;(list.insert as any).raw = true
// raw functions deal directly in Value types, meaning we can modify collection
// careful - they MUST return a Value!
; (list.splice as any).raw = true
; (list.push as any).raw = true
; (list.pop as any).raw = true
; (list.shift as any).raw = true
; (list.unshift as any).raw = true
; (list.insert as any).raw = true

View File

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

View File

@ -17,23 +17,17 @@ export const str = {
'last-index-of': (str: string, search: string) => String(str ?? '').lastIndexOf(search),
// transformations
replace: (str: string, search: string, replacement: string) =>
String(str ?? '').replace(search, replacement),
'replace-all': (str: string, search: string, replacement: string) =>
String(str ?? '').replaceAll(search, replacement),
slice: (str: string, start: number, end?: number | null) =>
String(str ?? '').slice(start, end ?? undefined),
substring: (str: string, start: number, end?: number | null) =>
String(str ?? '').substring(start, end ?? undefined),
replace: (str: string, search: string, replacement: string) => String(str ?? '').replace(search, replacement),
'replace-all': (str: string, search: string, replacement: string) => String(str ?? '').replaceAll(search, replacement),
slice: (str: string, start: number, end?: number | null) => String(str ?? '').slice(start, end ?? undefined),
substring: (str: string, start: number, end?: number | null) => String(str ?? '').substring(start, end ?? undefined),
repeat: (str: string, count: number) => {
if (count < 0) throw new Error(`repeat: count must be non-negative, got ${count}`)
if (!Number.isInteger(count)) throw new Error(`repeat: count must be an integer, got ${count}`)
return String(str ?? '').repeat(count)
},
'pad-start': (str: string, length: number, pad: string = ' ') =>
String(str ?? '').padStart(length, pad),
'pad-end': (str: string, length: number, pad: string = ' ') =>
String(str ?? '').padEnd(length, pad),
'pad-start': (str: string, length: number, pad: string = ' ') => String(str ?? '').padStart(length, pad),
'pad-end': (str: string, length: number, pad: string = ' ') => String(str ?? '').padEnd(length, pad),
capitalize: (str: string) => {
const s = String(str ?? '')
return s.charAt(0).toUpperCase() + s.slice(1).toLowerCase()

View File

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

View File

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

View File

@ -277,10 +277,7 @@ describe('collections', () => {
})
test('list.zip combines two arrays', async () => {
await expect(`list.zip [1 2] [3 4]`).toEvaluateTo([
[1, 3],
[2, 4],
])
await expect(`list.zip [1 2] [3 4]`).toEvaluateTo([[1, 3], [2, 4]])
})
test('list.first returns first element', async () => {
@ -450,10 +447,7 @@ describe('collections', () => {
await expect(`
gt-two = do x: x > 2 end
list.partition [1 2 3 4 5] gt-two
`).toEvaluateTo([
[3, 4, 5],
[1, 2],
])
`).toEvaluateTo([[3, 4, 5], [1, 2]])
})
test('list.compact removes null values', async () => {

View File

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

View File

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

View File

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

View File

@ -20,7 +20,7 @@ declare module 'bun:test' {
toFailEvaluation(): Promise<T>
toBeToken(expected: 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 {
message: () => `Expected token type to be ${expected}, but got ${TokenType[value.type]}`,
pass: value.type === target,
pass: value.type === target
}
} catch (error) {
return {
@ -166,8 +166,7 @@ expect.extend({
if (!token) {
return {
message: () =>
`Expected token to be ${expectedValue.replaceAll('\n', '\\n')}, got ${token}`,
message: () => `Expected token to be ${expectedValue.replaceAll('\n', '\\n')}, got ${token}`,
pass: false,
}
}
@ -175,14 +174,13 @@ expect.extend({
if (expectedType && TokenType[expectedType as keyof typeof TokenType] !== token.type) {
return {
message: () => `Expected token to be ${expectedType}, but got ${TokenType[token.type]}`,
pass: false,
pass: false
}
}
return {
message: () =>
`Expected token to be ${expectedValue.replaceAll('\n', '\\n')}, but got ${token.value}`,
pass: token.value === expectedValue,
message: () => `Expected token to be ${expectedValue.replaceAll('\n', '\\n')}, but got ${token.value}`,
pass: token.value === expectedValue
}
} catch (error) {
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')
try {
const result = tokenize(received).map((t) => toHumanToken(t))
const result = tokenize(received).map(t => toHumanToken(t))
if (result.length === 0 && tokens.length > 0) {
return {
@ -209,7 +207,7 @@ expect.extend({
return {
message: () => `Tokens don't match: \n\n${diff(actual, expected)}`,
pass: expected == actual,
pass: expected == actual
}
} catch (error) {
return {
@ -217,18 +215,18 @@ expect.extend({
pass: false,
}
}
},
}
})
const tokenize = (code: string): Token[] => {
const scanner = new Scanner()
const scanner = new Scanner
return scanner.tokenize(code)
}
const toHumanToken = (tok: Token): { type: string; value?: string } => {
const toHumanToken = (tok: Token): { type: string, value?: string } => {
return {
type: TokenType[tok.type],
value: tok.value,
value: tok.value
}
}
@ -243,7 +241,7 @@ const trimWhitespace = (str: string): string => {
if (!line.startsWith(leadingWhitespace)) {
let foundWhitespace = line.match(/^(\s*)/)?.[1] || ''
throw new Error(
`Line has inconsistent leading whitespace: "${line}"(found "${foundWhitespace}", expected "${leadingWhitespace}")`,
`Line has inconsistent leading whitespace: "${line}"(found "${foundWhitespace}", expected "${leadingWhitespace}")`
)
}
return line.slice(leadingWhitespace.length)
@ -259,7 +257,7 @@ const diff = (a: string, b: string): string => {
if (expected !== actual) {
const changes = diffLines(actual, expected)
for (const part of changes) {
const sign = part.added ? '+' : part.removed ? '-' : ' '
const sign = part.added ? "+" : part.removed ? "-" : " "
let line = sign + part.value
if (part.added) {
line = color.green(line)
@ -267,7 +265,7 @@ const diff = (a: string, b: string): string => {
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",
"type": "extensionHost",
"request": "launch",
"args": ["--extensionDevelopmentPath=${workspaceFolder}", "--profile=Shrimp Dev"],
"args": [
"--extensionDevelopmentPath=${workspaceFolder}",
"--profile=Shrimp Dev"
],
"outFiles": [
"${workspaceFolder}/client/dist/**/*.js",
"${workspaceFolder}/server/dist/**/*.js"

View File

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

View File

@ -113,5 +113,5 @@ console.log(`✓ Generated ${names.length} prelude names to server/src/metadata/
console.log(
`✓ Generated completions for ${
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 = (
document: TextDocument,
position: { line: number; character: number },
position: { line: number; character: number }
): CompletionItem[] => {
const context = analyzeCompletionContext(document, position)

View File

@ -14,7 +14,7 @@ export type CompletionContext =
*/
export const analyzeCompletionContext = (
document: TextDocument,
position: { line: number; character: number },
position: { line: number; character: number }
): CompletionContext => {
const offset = document.offsetAt(position)
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
export const PRELUDE_NAMES = [
'$',
'array?',
'at',
'bnot',
'boolean',
'boolean?',
'date',
'dec',
'describe',
'dict',
'dict?',
'each',
'echo',
'empty?',
'exit',
'fs',
'function?',
'identity',
'import',
'inc',
'inspect',
'json',
'length',
'list',
'list?',
'load',
'math',
'not',
'null?',
'number',
'number?',
'range',
'ref',
'some?',
'str',
'string',
'string?',
'type',
'var',
'var?',
"$",
"array?",
"at",
"bnot",
"boolean",
"boolean?",
"date",
"dec",
"describe",
"dict",
"dict?",
"each",
"echo",
"empty?",
"exit",
"fs",
"function?",
"identity",
"import",
"inc",
"inspect",
"json",
"length",
"list",
"list?",
"load",
"math",
"not",
"null?",
"number",
"number?",
"range",
"ref",
"some?",
"str",
"string",
"string?",
"type",
"var",
"var?"
] as const

View File

@ -41,7 +41,7 @@ export function buildSemanticTokens(document: TextDocument, tree: Tree): number[
function emitNamedArgPrefix(
node: SyntaxNode,
document: TextDocument,
builder: SemanticTokensBuilder,
builder: SemanticTokensBuilder
) {
const text = document.getText({
start: document.positionAt(node.from),
@ -57,7 +57,7 @@ function emitNamedArgPrefix(
start.character,
nameLength,
TOKEN_TYPES.indexOf(SemanticTokenTypes.property),
0,
0
)
// Emit token for the "=" part
@ -66,7 +66,7 @@ function emitNamedArgPrefix(
start.character + nameLength,
1, // Just the = character
TOKEN_TYPES.indexOf(SemanticTokenTypes.operator),
0,
0
)
}
@ -75,7 +75,7 @@ function walkTree(
node: SyntaxNode,
document: TextDocument,
builder: SemanticTokensBuilder,
scopeTracker: EditorScopeAnalyzer,
scopeTracker: EditorScopeAnalyzer
) {
// Special handling for NamedArgPrefix to split "name=" into two tokens
if (node.type.id === Terms.NamedArgPrefix) {
@ -102,7 +102,7 @@ type TokenInfo = { type: number; modifiers: number } | undefined
function getTokenType(
node: SyntaxNode,
document: TextDocument,
scopeTracker: EditorScopeAnalyzer,
scopeTracker: EditorScopeAnalyzer
): TokenInfo {
const nodeTypeId = node.type.id
const parentTypeId = node.parent?.type.id

View File

@ -124,7 +124,7 @@ function handleCompletion(params: any) {
if (contextCompletions.length > 0) {
console.log(
`✅ Returning ${contextCompletions.length} completions:`,
contextCompletions.map((c) => c.label).join(', '),
contextCompletions.map((c) => c.label).join(', ')
)
return contextCompletions
}

View File

@ -1,8 +1,4 @@
import {
SignatureHelp,
SignatureInformation,
ParameterInformation,
} from 'vscode-languageserver/node'
import { SignatureHelp, SignatureInformation, ParameterInformation } from 'vscode-languageserver/node'
import { TextDocument } from 'vscode-languageserver-textdocument'
import { Tree, SyntaxNode } from '@lezer/common'
import { parser } from '../../../src/parser/shrimp'
@ -10,7 +6,7 @@ import { completions } from './metadata/prelude-completions'
export const provideSignatureHelp = (
document: TextDocument,
position: { line: number; character: number },
position: { line: number; character: number }
): SignatureHelp | undefined => {
const text = document.getText()
const tree = parser.parse(text)
@ -104,6 +100,6 @@ const lookupFunctionParams = (funcName: string): string[] | undefined => {
const buildSignature = (funcName: string, params: string[]): SignatureInformation => {
const label = `${funcName}(${params.join(', ')})`
const parameters: ParameterInformation[] = params.map((p) => ({ label: p }))
const parameters: ParameterInformation[] = params.map(p => ({ label: p }))
return { label, parameters }
}