Compare commits

...

3 Commits

Author SHA1 Message Date
d195c5321c wip 2025-10-31 10:00:06 -07:00
9b57304b87 wip 2025-10-30 09:54:37 -07:00
a1693078f9 Make dot-get work in the compiler AND with parens exprs 2025-10-28 10:11:52 -07:00
15 changed files with 664 additions and 38 deletions

5
.quokka Normal file
View File

@ -0,0 +1,5 @@
{
"bun": {
"usageMode": "alwaysUse"
}
}

View File

@ -94,7 +94,6 @@ export class Compiler {
#compileNode(node: SyntaxNode, input: string): ProgramItem[] { #compileNode(node: SyntaxNode, input: string): ProgramItem[] {
const value = input.slice(node.from, node.to) const value = input.slice(node.from, node.to)
if (DEBUG) console.log(`🫦 ${node.name}: ${value}`) if (DEBUG) console.log(`🫦 ${node.name}: ${value}`)
switch (node.type.id) { switch (node.type.id) {
@ -190,10 +189,15 @@ export class Compiler {
} }
case terms.DotGet: { case terms.DotGet: {
const { objectName, propertyName } = getDotGetParts(node, input) const { objectName, property } = getDotGetParts(node, input)
const instructions: ProgramItem[] = [] const instructions: ProgramItem[] = []
instructions.push(['TRY_LOAD', objectName]) instructions.push(['TRY_LOAD', objectName])
instructions.push(['PUSH', propertyName]) if (property.type.id === terms.ParenExpr) {
instructions.push(...this.#compileNode(property, input))
} else {
const propertyValue = input.slice(property.from, property.to)
instructions.push(['PUSH', propertyValue])
}
instructions.push(['DOT_GET']) instructions.push(['DOT_GET'])
return instructions return instructions
} }
@ -265,6 +269,10 @@ export class Compiler {
} }
case terms.FunctionCallOrIdentifier: { case terms.FunctionCallOrIdentifier: {
if (node.firstChild?.type.id === terms.DotGet) {
return this.#compileNode(node.firstChild, input)
}
return [['TRY_CALL', value]] return [['TRY_CALL', value]]
} }

View File

@ -96,8 +96,7 @@ describe('compiler', () => {
end end
abc abc
`) `).toEvaluateTo(true)
.toEvaluateTo(true)
}) })
test('simple conditionals', () => { test('simple conditionals', () => {
@ -238,3 +237,20 @@ describe('native functions', () => {
expect(`add 5 9`).toEvaluateTo(14, { add }) expect(`add 5 9`).toEvaluateTo(14, { add })
}) })
}) })
describe('dot get', () => {
const array = (...items: any) => items
const dict = (atNamed: any) => atNamed
test('access array element', () => {
expect(`arr = array 'a' 'b' 'c'; arr.1`).toEvaluateTo('b', { array })
})
test('access dict element', () => {
expect(`dict = dict a=1 b=2; dict.a`).toEvaluateTo(1, { dict })
})
test('use parens expr with dot-get', () => {
expect(`a = 1; arr = array 'a' 'b' 'c'; arr.(1 + a)`).toEvaluateTo('c', { array })
})
})

View File

@ -203,7 +203,7 @@ export const getDotGetParts = (node: SyntaxNode, input: string) => {
const children = getAllChildren(node) const children = getAllChildren(node)
const [object, property] = children const [object, property] = children
if (children.length !== 2) { if (!object || !property) {
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,
@ -219,7 +219,7 @@ export const getDotGetParts = (node: SyntaxNode, input: string) => {
) )
} }
if (property.type.id !== terms.Identifier && property.type.id !== terms.Number) { if (![terms.Identifier, terms.Number, terms.ParenExpr].includes(property.type.id)) {
throw new CompilerError( throw new CompilerError(
`DotGet property must be an Identifier or Number, got ${property.type.name}`, `DotGet property must be an Identifier or Number, got ${property.type.name}`,
property.from, property.from,
@ -228,7 +228,6 @@ export const getDotGetParts = (node: SyntaxNode, input: string) => {
} }
const objectName = input.slice(object.from, object.to) const objectName = input.slice(object.from, object.to)
const propertyName = input.slice(property.from, property.to)
return { objectName, propertyName } return { objectName, property }
} }

View File

@ -0,0 +1,203 @@
import { expect, describe, test } from 'bun:test'
import { EditorState } from '@codemirror/state'
import { autocomplete, type CompletionItem } from './autocomplete'
import { shrimpLanguage } from './plugins/shrimpLanguage'
describe('autocomplete function names', () => {
test('collects top-level assignments before cursor, excludes after', () => {
const names = getNamesInScope(`
x = 5
<cursor>
y = 10
`)
expect(names).toEqual(['x'])
})
test('collects matches for identifier', () => {
const names = getNamesInScope(`
alpha = 5
bravo = 10
a<cursor>
`)
expect(names).toEqual(['alpha'])
})
test('function parameters and local assignments are visible inside function', () => {
const names = getNamesInScope(`
do x y:
z = 10
<cursor>
`)
expect(names).toEqual(['x', 'y', 'z'])
})
test('function parameters and locals not visible outside function', () => {
const names = getNamesInScope(`
do x:
y = 1
end
<cursor>
`)
expect(names).toEqual([])
})
test('inner functions see outer scope variables', () => {
const names = getNamesInScope(`
x = 1
do:
y = 2
do:
<cursor>
`)
expect(names).toEqual(['y', 'x'])
})
test('if expressions do not create scope boundaries', () => {
const names = getNamesInScope(`
if true:
x = 5
end
<cursor>
`)
expect(names).toEqual(['x'])
})
test('assigned function names are visible in scope', () => {
const names = getNamesInScope(`
add = do x: x + 1 end
<cursor>
`)
expect(names).toEqual(['add'])
})
test('identifiers are visible in conditional scope', () => {
const names = getNamesInScope(`
add = if true:
x = 1
<cursor>
end
`)
expect(names).toEqual(['add', 'x'])
})
test('multiple assignments', () => {
const names = getNamesInScope(`
alpha = alamo = 4
<cursor>
end
`)
expect(names).toEqual(['alpha', 'alamo'])
})
})
describe('autocomplete positional arguments', () => {
test('shows args for shrimp function', () => {
const args = getArgsInScope(`
add = do x y: x + y end
add <cursor>
`)
expect(args).toEqual(['x', 'y'])
})
test('does not include args already used', () => {
const args = getArgsInScope(`
add = do x y: x + y end
add 5 <cursor>
`)
expect(args).toEqual(['y'])
})
test('no args when cursor in function name', () => {
const items = getVarsInScope(`
add = do x y: x + y end
ad<cursor>d
`)
expect(items.every((i) => i.kind !== 'arg')).toBe(true)
})
test('no args when function not found', () => {
const args = getArgsInScope(`
unknown <cursor>
`)
expect(args).toEqual([])
})
})
describe('autocomplete named arguments', () => {
test('shows remaining args after positional arg used', () => {
const args = getArgsInScope(`
add = do alpha bravo charlie: alpha + bravo + charlie end
add 5 <cursor>
`)
// alpha is used positionally
expect(args).toEqual(['bravo', 'charlie'])
})
test('filters args by prefix when typing', () => {
const args = getArgsInScope(`
add = do alpha bravo charlie: alpha + bravo + charlie end
add 5 b<cursor>
`)
// alpha is used, typing 'b' filters to bravo
expect(args).toEqual(['bravo'])
})
test('excludes named arg already used', () => {
const args = getArgsInScope(`
add = do alpha bravo charlie: alpha + bravo + charlie end
add bravo=10 <cursor>
`)
// bravo is used as named arg
expect(args).toEqual(['alpha', 'charlie'])
})
test('positional fills first slot, skips named args', () => {
const args = getArgsInScope(`
add = do alpha bravo charlie: alpha + bravo + charlie end
add bravo=5 <cursor>
`)
// bravo is named, 10 fills alpha (first positional slot)
expect(args).toEqual(['alpha', 'charlie'])
})
})
// Helper functions
const getVarsInScope = (codeWithCursor: string): CompletionItem[] => {
const cursorPos = codeWithCursor.indexOf('<cursor>')
if (cursorPos === -1) {
throw new Error('Test code must contain <cursor> marker')
}
const code = codeWithCursor.replace('<cursor>', '')
const view = createMockView(code, cursorPos)
const items = autocomplete(view)
return items
}
const createMockView = (code: string, cursorPos: number) => {
const state = EditorState.create({
doc: code,
selection: { anchor: cursorPos },
extensions: [shrimpLanguage],
})
return { state, dispatch: () => {} } as any
}
const getNamesInScope = (codeWithCursor: string): string[] => {
return getVarsInScope(codeWithCursor).map((v) => v.name)
}
const getArgsInScope = (codeWithCursor: string): string[] => {
const items = getVarsInScope(codeWithCursor)
for (const item of items) {
if (item.kind !== 'arg') {
throw new Error(`Expected only arg items, but got kind=${item.kind}`)
}
}
return items.map((v) => v.name)
}

334
src/editor/autocomplete.ts Normal file
View File

@ -0,0 +1,334 @@
import type { EditorView } from '@codemirror/view'
import { syntaxTree } from '@codemirror/language'
import type { SyntaxNode, Tree } from '@lezer/common'
import * as Terms from '../parser/shrimp.terms'
import { noseSignals, type CommandDef } from '#editor/noseClient'
export const autocomplete = (view: EditorView): CompletionItem[] => {
const fnCallContext = findFunctionCallContext(view)
if (fnCallContext) {
return autocompleteArg(view, fnCallContext)
} else {
return autocompleteIdentifier(view)
}
}
const autocompleteIdentifier = (view: EditorView): CompletionItem[] => {
const cursor = view.state.selection.main.head
const tree = syntaxTree(view.state)
const node = tree.resolveInner(cursor, -1)
const textBeforeCursor = view.state.doc.sliceString(node.from, cursor)
const wordToComplete = /\W$/.test(textBeforeCursor) ? '' : textBeforeCursor
if (wordToComplete !== '' && node.type.id !== Terms.Identifier) return []
const scope = [...completionItemsInScope(view, tree, cursor), ...noseCompletionItems]
scope.push(...noseCompletionItems)
const matchedItems = scope.filter((scopeItem) => scopeItem.name.startsWith(wordToComplete))
return matchedItems
}
const autocompleteArg = (view: EditorView, context: FunctionCallContext): CompletionItem[] => {
const fnNameText = view.state.doc.sliceString(context.fnName.from, context.fnName.to)
const cursor = view.state.selection.main.head
const tree = syntaxTree(view.state)
const scope = [...completionItemsInScope(view, tree, cursor), ...noseCompletionItems]
const fn = scope.find((item) => item.name === fnNameText)
if (!fn) {
return []
}
// Collect used args
const usedPositionalArgs = countPositionalArgs(context.fnCallNode, cursor)
const usedNamedArgs = collectUsedNamedArgs(context.fnCallNode, cursor, view)
if (fn.kind === 'shrimp-fn') {
// Filter out named args, then skip positional slots
const availableParams = fn.params.filter((p) => !usedNamedArgs.has(p))
const remainingParams = availableParams.slice(usedPositionalArgs)
return remainingParams.map((paramName) => ({ kind: 'arg', name: paramName }))
} else if (fn.kind === 'nose-command') {
// Filter out named args, then skip positional slots
const availableParams = fn.def.signature.params.filter((p) => !usedNamedArgs.has(p.name))
const remainingParams = availableParams.slice(usedPositionalArgs)
return remainingParams.map((param) => ({
kind: 'arg',
name: param.name,
type: param.type,
optional: param.optional,
default: param.default,
}))
}
return []
}
const completionItemsInScope = (view: EditorView, tree: Tree, cursor: number): CompletionItem[] => {
const items: CompletionItem[] = []
const containingFunctions = [...findContainingFunctions(tree, cursor), tree.topNode]
for (const fn of containingFunctions) {
const params = collectParams(view, fn)
items.push(...params)
const assignments = collectAssignments(view, fn, cursor)
items.push(...assignments)
}
// Remove duplicates by name, keeping first occurrence
const seen = new Set<string>()
return items.filter((item) => {
if (seen.has(item.name)) return false
seen.add(item.name)
return true
})
}
const findContainingFunctions = (tree: Tree, cursor: number): SyntaxNode[] => {
const functions: SyntaxNode[] = []
tree.topNode.cursor().iterate((node) => {
if (node.type.id === Terms.FunctionDef) {
const fn = node.node
if (isCursorInNode(cursor, fn)) {
functions.push(fn)
}
}
})
return functions
}
const collectParams = (view: EditorView, fnNode: SyntaxNode): CompletionItem[] => {
const params: CompletionItem[] = []
// Find Params child
let child = fnNode.firstChild
while (child) {
if (child.type.id === Terms.Params) {
let param = child.firstChild
while (param) {
if (param.type.id === Terms.Identifier) {
const text = view.state.doc.sliceString(param.from, param.to)
params.push({ kind: 'var', name: text })
}
param = param.nextSibling
}
break
}
child = child.nextSibling
}
return params
}
const collectAssignments = (
view: EditorView,
scopeNode: SyntaxNode,
cursor: number
): CompletionItem[] => {
const assignments: CompletionItem[] = []
scopeNode.cursor().iterate((node) => {
if (node.from >= cursor) return false
if (node.type.id === Terms.Assign) {
const assignNode = node.node
let child = assignNode.firstChild
if (child?.type.id === Terms.AssignableIdentifier) {
const name = view.state.doc.sliceString(child.from, child.to)
// Check if RHS is a FunctionDef
const rhs = child.nextSibling?.nextSibling // Skip the '=' token
if (rhs?.type.id === Terms.FunctionDef) {
const params = extractParamsFromFunctionDef(view, rhs)
assignments.push({ kind: 'shrimp-fn', name, params })
} else {
assignments.push({ kind: 'var', name })
}
}
}
// Don't go into nested functions unless it's the current scope
if (node.type.id === Terms.FunctionDef && node.node !== scopeNode) {
return false
}
})
return assignments
}
const extractParamsFromFunctionDef = (view: EditorView, fnNode: SyntaxNode): string[] => {
const params: string[] = []
let child = fnNode.firstChild
while (child) {
if (child.type.id === Terms.Params) {
let param = child.firstChild
while (param) {
if (param.type.id === Terms.Identifier) {
const text = view.state.doc.sliceString(param.from, param.to)
params.push(text)
}
param = param.nextSibling
}
break
}
child = child.nextSibling
}
return params
}
const isCursorInNode = (cursor: number, node: SyntaxNode): boolean => {
return cursor >= node.from && cursor <= node.to
}
// Helper: Find first ancestor matching a condition
const findAncestor = (
node: SyntaxNode | null,
predicate: (node: SyntaxNode) => boolean
): SyntaxNode | null => {
let current = node
while (current) {
if (predicate(current)) return current
current = current.parent
}
return null
}
const findFunctionCallContext = (view: EditorView) => {
const cursor = view.state.selection.main.head
const tree = syntaxTree(view.state)
const node = tree.resolveInner(cursor, -1)
// Don't provide arg completion if we're typing an identifier
if (node.type.id === Terms.Identifier) return null
// Only show arg completion after whitespace
if (cursor > 0) {
const charBefore = view.state.doc.sliceString(cursor - 1, cursor)
if (!/\s/.test(charBefore)) return null
}
// Check if we're inside an existing arg (editing it)
const inArg = findAncestor(
node,
(n) => n.type.id === Terms.PositionalArg || n.type.id === Terms.NamedArg
)
if (inArg) return null
// Scan back until we find a different node
const startNode = node
const maxScanBack = 10
for (let pos = cursor - 1; pos >= Math.max(0, cursor - maxScanBack); pos--) {
const nodeAtPos = tree.resolveInner(pos, -1)
// Found a different node - now walk UP from it
if (nodeAtPos !== startNode) {
const fnCall = findAncestor(
nodeAtPos,
(n) => n.type.id === Terms.FunctionCall || n.type.id === Terms.FunctionCallOrIdentifier
)
if (fnCall) {
const fnName = fnCall.firstChild
if (fnName?.type.id === Terms.Identifier) {
return { fnCallNode: fnCall, fnName, currentArgNode: node }
}
}
break // Stop after checking first different node
}
}
return null
}
type FunctionCallContext = {
fnCallNode: SyntaxNode
fnName: SyntaxNode
currentArgNode: SyntaxNode
}
const countPositionalArgs = (fnCallNode: SyntaxNode, cursor: number): number => {
let count = 0
let child = fnCallNode.firstChild
while (child) {
// Only count PositionalArg nodes that end before cursor
if (child.type.id === Terms.PositionalArg && child.to <= cursor) {
count++
}
child = child.nextSibling
}
return count
}
const collectUsedNamedArgs = (
fnCallNode: SyntaxNode,
cursor: number,
view: EditorView
): Set<string> => {
const usedNames = new Set<string>()
let child = fnCallNode.firstChild
while (child) {
// Only collect NamedArg nodes that end before cursor
if (child.type.id === Terms.NamedArg && child.to <= cursor) {
// Find the NamedArgPrefix child which contains "paramname="
const prefixNode = child.firstChild
if (prefixNode?.type.id === Terms.NamedArgPrefix) {
const prefixText = view.state.doc.sliceString(prefixNode.from, prefixNode.to)
// Remove the trailing '=' to get the param name
const paramName = prefixText.slice(0, -1)
usedNames.add(paramName)
}
}
child = child.nextSibling
}
return usedNames
}
type ShrimpFn = {
kind: 'shrimp-fn'
name: string
params: string[]
}
type NoseCommand = {
kind: 'nose-command'
name: string
def: CommandDef
}
type Var = {
kind: 'var'
name: string
}
type ArgCompletion = {
kind: 'arg'
name: string
type?: string // For NoseCommand
optional?: boolean // For NoseCommand
default?: any // For NoseCommand
}
export type CompletionItem = ShrimpFn | NoseCommand | Var | ArgCompletion
// Nose Command Signal
let noseCompletionItems: CompletionItem[] = []
noseSignals.connect((data) => {
if (data.type !== 'command-defs') return
noseCompletionItems = data.data.map((def) => ({
kind: 'nose-command',
name: def.name,
def,
}))
})

View File

@ -32,7 +32,6 @@ export const Editor = () => {
}) })
multilineModeSignal.connect((isMultiline) => { multilineModeSignal.connect((isMultiline) => {
console.log(`🌭 hey babe`, isMultiline)
view.dispatch({ view.dispatch({
effects: lineNumbersCompartment.reconfigure(isMultiline ? lineNumbers() : []), effects: lineNumbersCompartment.reconfigure(isMultiline ? lineNumbers() : []),
}) })

View File

@ -25,6 +25,19 @@ type IncomingMessage =
} }
| { type: 'reef-output'; data: Value } | { type: 'reef-output'; data: Value }
| { type: 'error'; data: string } | { type: 'error'; data: string }
| {
type: 'command-defs'
data: CommandDef[]
}
export type CommandDef = {
name: string
signature: {
params: { default: any; name: string; optional: boolean; rest: boolean; type: string }[]
returnType: string
type: string
}
}
export const noseSignals = new Signal<IncomingMessage>() export const noseSignals = new Signal<IncomingMessage>()

View File

@ -11,19 +11,29 @@ export const debugTags = ViewPlugin.fromClass(
} }
updateStatusBar(view: EditorView) { updateStatusBar(view: EditorView) {
const pos = view.state.selection.main.head + 1 const pos = view.state.selection.main.head
const tree = syntaxTree(view.state) const tree = syntaxTree(view.state)
let tags: string[] = [] let tags: string[] = []
let node = tree.resolveInner(pos, -1) let node = tree.resolveInner(pos, -1)
let nodes = []
let lastNode = null
while (node) { while (node) {
nodes.push(node)
tags.push(node.type.name) tags.push(node.type.name)
node = node.parent! node = node.parent!
if (!node) break if (!node) break
} }
const debugText = tags.length ? tags.reverse().slice(1).join(' > ') : 'No nodes' ;(window as any).l = nodes
tags = tags.length ? tags.reverse().slice(1) : ['∅'] // remove Program and reverse
if (tags.length > 5) {
tags = ['...', ...tags.slice(-5)]
}
const debugText = tags.join(' > ')
statusBarSignal.emit({ statusBarSignal.emit({
side: 'right', side: 'right',
message: debugText, message: debugText,

View File

@ -1,3 +1,4 @@
import { autocomplete } from '#editor/autocomplete'
import { multilineModeSignal, outputSignal } from '#editor/editor' import { multilineModeSignal, outputSignal } from '#editor/editor'
import { printBytecodeOutput, printParserOutput, runCode } from '#editor/runCode' import { printBytecodeOutput, printParserOutput, runCode } from '#editor/runCode'
import { EditorState } from '@codemirror/state' import { EditorState } from '@codemirror/state'
@ -49,10 +50,26 @@ const customKeymap = keymap.of([
key: 'Tab', key: 'Tab',
preventDefault: true, preventDefault: true,
run: (view) => { run: (view) => {
// if only whitespace is before the cursor on the line, insert two spaces
const line = view.state.doc.lineAt(view.state.selection.main.from)
const textBeforeCursor = line.text.slice(0, view.state.selection.main.from - line.from)
if (textBeforeCursor.trim() === '') {
view.dispatch({ view.dispatch({
changes: { from: view.state.selection.main.from, insert: ' ' }, changes: { from: view.state.selection.main.from, insert: ' ' },
selection: { anchor: view.state.selection.main.from + 2 }, selection: { anchor: view.state.selection.main.from + 2 },
}) })
} else {
const matchedItems = autocomplete(view)
const colors = {
'shrimp-fn': 'black',
'nose-command': 'green',
var: 'gray',
}
for (const item of matchedItems) {
// print out item with different colors using console.log
console.log(`%c${item.name}`, `color: ${colors[item.kind]}`)
}
}
return true return true
}, },
}, },

View File

@ -10,6 +10,7 @@ import { shrimpLanguage } from './shrimpLanguage'
import { shrimpErrors } from './errors' import { shrimpErrors } from './errors'
import { persistencePlugin } from './persistence' import { persistencePlugin } from './persistence'
import { catchErrors } from './catchErrors' import { catchErrors } from './catchErrors'
import { debugTags } from '#editor/plugins/debugTags'
export const shrimpSetup = (lineNumbersCompartment: Compartment) => { export const shrimpSetup = (lineNumbersCompartment: Compartment) => {
return [ return [
@ -31,5 +32,6 @@ export const shrimpSetup = (lineNumbersCompartment: Compartment) => {
shrimpHighlighting, shrimpHighlighting,
shrimpErrors, shrimpErrors,
persistencePlugin, persistencePlugin,
debugTags,
] ]
} }

View File

@ -169,7 +169,7 @@ expression {
@skip {} { @skip {} {
DotGet { DotGet {
IdentifierBeforeDot dot (Number | Identifier) IdentifierBeforeDot dot (Number | Identifier | ParenExpr)
} }
String { "'" stringContent* "'" } String { "'" stringContent* "'" }

View File

@ -7,9 +7,9 @@ import {highlighting} from "./highlight"
const spec_Identifier = {__proto__:null,null:64, end:74, if:88, elseif:96, else:100} const spec_Identifier = {__proto__:null,null:64, end:74, if:88, elseif:96, else:100}
export const parser = LRParser.deserialize({ export const parser = LRParser.deserialize({
version: 14, version: 14,
states: "/SQYQbOOO!TOpO'#CqO#aQcO'#CtO$ZOSO'#CvO%aQcO'#DsOOQa'#Ds'#DsO&gQcO'#DrO'OQRO'#CuO'^QcO'#DnO'uQbO'#D{OOQ`'#DO'#DOO'}QbO'#CsOOQ`'#Do'#DoO(oQbO'#DnO(}QbO'#EROOQ`'#DX'#DXO)lQRO'#DaOOQ`'#Dn'#DnO)qQQO'#DmOOQ`'#Dm'#DmOOQ`'#Db'#DbQYQbOOO)yObO,59]OOQa'#Dr'#DrOOQ`'#DS'#DSO*RQbO'#DUOOQ`'#EQ'#EQOOQ`'#Df'#DfO*]QbO,59[O*pQbO'#CxO*xQWO'#CyOOOO'#Du'#DuOOOO'#Dc'#DcO+^OSO,59bOOQa,59b,59bO(}QbO,59aO(}QbO,59aOOQ`'#Dd'#DdO+lQbO'#DPO+tQQO,5:gO+yQRO,59_O-`QRO'#CuO-pQRO,59_O-|QQO,59_O.RQQO,59_O.ZQbO'#DgO.fQbO,59ZO.wQRO,5:mO/OQQO,5:mO/TQbO,59{OOQ`,5:X,5:XOOQ`-E7`-E7`OOQa1G.w1G.wOOQ`,59p,59pOOQ`-E7d-E7dOOOO,59d,59dOOOO,59e,59eOOOO-E7a-E7aOOQa1G.|1G.|OOQa1G.{1G.{O/_QcO1G.{OOQ`-E7b-E7bO/yQbO1G0ROOQa1G.y1G.yO(}QbO,59iO(}QbO,59iO!YQbO'#CtO$iQbO'#CpOOQ`,5:R,5:ROOQ`-E7e-E7eO0WQbO1G0XOOQ`1G/g1G/gO0eQbO7+%mO0jQbO7+%nOOQO1G/T1G/TO0zQRO1G/TOOQ`'#DZ'#DZO1UQbO7+%sO1ZQbO7+%tOOQ`<<IX<<IXOOQ`'#De'#DeO1qQQO'#DeO1vQbO'#EOO2^QbO<<IYOOQ`<<I_<<I_OOQ`'#D['#D[O2cQbO<<I`OOQ`,5:P,5:POOQ`-E7c-E7cOOQ`AN>tAN>tO(}QbO'#D]OOQ`'#Dh'#DhO2nQbOAN>zO2yQQO'#D_OOQ`AN>zAN>zO3OQbOAN>zO3TQRO,59wO3[QQO,59wOOQ`-E7f-E7fOOQ`G24fG24fO3aQbOG24fO3fQQO,59yO3kQQO1G/cOOQ`LD*QLD*QO0jQbO1G/eO1ZQbO7+$}OOQ`7+%P7+%POOQ`<<Hi<<Hi", states: "/SQYQbOOO#[QcO'#CtO$UOSO'#CvO%[QcO'#DsOOQa'#Ds'#DsO&bQcO'#DrO&yQRO'#CuO'XQcO'#DnO'pQbO'#D{OOQ`'#DO'#DOO'xQbO'#CsO(jOpO'#CqOOQ`'#Do'#DoO(oQbO'#DnO(}QbO'#EROOQ`'#DX'#DXO)lQRO'#DaOOQ`'#Dn'#DnO)qQQO'#DmOOQ`'#Dm'#DmOOQ`'#Db'#DbQYQbOOOOQa'#Dr'#DrOOQ`'#DS'#DSO)yQbO'#DUOOQ`'#EQ'#EQOOQ`'#Df'#DfO*TQbO,59[O*hQbO'#CxO*pQWO'#CyOOOO'#Du'#DuOOOO'#Dc'#DcO+UOSO,59bOOQa,59b,59bO(}QbO,59aO(}QbO,59aOOQ`'#Dd'#DdO+dQbO'#DPO+lQQO,5:gO+qQRO,59_O-WQRO'#CuO-hQRO,59_O-tQQO,59_O-yQQO,59_O.RObO,59]O.^QbO'#DgO.iQbO,59ZO.zQRO,5:mO/RQQO,5:mO/WQbO,59{OOQ`,5:X,5:XOOQ`-E7`-E7`OOQ`,59p,59pOOQ`-E7d-E7dOOOO,59d,59dOOOO,59e,59eOOOO-E7a-E7aOOQa1G.|1G.|OOQa1G.{1G.{O/bQcO1G.{OOQ`-E7b-E7bO/|QbO1G0ROOQa1G.y1G.yO(}QbO,59iO(}QbO,59iOOQa1G.w1G.wO!TQbO'#CtO$dQbO'#CpOOQ`,5:R,5:ROOQ`-E7e-E7eO0ZQbO1G0XOOQ`1G/g1G/gO0hQbO7+%mO0mQbO7+%nOOQO1G/T1G/TO0}QRO1G/TOOQ`'#DZ'#DZO1XQbO7+%sO1^QbO7+%tOOQ`<<IX<<IXOOQ`'#De'#DeO1tQQO'#DeO1yQbO'#EOO2aQbO<<IYOOQ`<<I_<<I_OOQ`'#D['#D[O2fQbO<<I`OOQ`,5:P,5:POOQ`-E7c-E7cOOQ`AN>tAN>tO(}QbO'#D]OOQ`'#Dh'#DhO2qQbOAN>zO2|QQO'#D_OOQ`AN>zAN>zO3RQbOAN>zO3WQRO,59wO3_QQO,59wOOQ`-E7f-E7fOOQ`G24fG24fO3dQbOG24fO3iQQO,59yO3nQQO1G/cOOQ`LD*QLD*QO0mQbO1G/eO1^QbO7+$}OOQ`7+%P7+%POOQ`<<Hi<<Hi",
stateData: "3s~O!_OS!`OS~O]QO^`O_TO`POaXOfTOnTOoTOpTO|^O!eZO!hRO!qcO~O!dfO~O]gO_TO`POaXOfTOnTOoTOpTOwhOyiO!eZO!hROzhX!qhX!whX!shXuhX~OP!fXQ!fXR!fXS!fXT!fXU!fXV!fXW!fXX!fXY!fXZ!fX[!fX~P!YOkoO!hrO!jmO!knO~O]gO_TO`POaXOfTOnTOoTOpTOwhOyiO!eZO!hRO~OP!gXQ!gXR!gXS!gX!q!gX!w!gXT!gXU!gXV!gXW!gXX!gXY!gXZ!gX[!gX!s!gXu!gX~P$iOP!fXQ!fXR!fXS!fX!q!bX!w!bXu!bX~OPsOQsORtOStO~OPsOQsORtOStO!q!bX!w!bXu!bX~O]uOtsP~O]QO_TO`POaXOfTOnTOoTOpTO!eZO!hRO~Oz}O!q!bX!w!bXu!bX~O]gO_TO`POfTOnTOoTOpTO!eZO!hRO~OV!RO~O!q!SO!w!SO~O]!UOf!UO~OaXOw!VO~P(}Ozda!qda!wda!sdauda~P$iO]!XO!eZO~O!h!YO!j!YO!k!YO!l!YO!m!YO!n!YO~OkoO!h![O!jmO!knO~O]uOtsX~Ot!`O~O!s!aOP!fXQ!fXR!fXS!fXT!fXU!fXV!fXW!fXX!fXY!fXZ!fX[!fX~OT!cOU!cOV!bOW!bOX!bOY!bOZ!bO[!bO~OPsOQsORtOStO~P,tOPsOQsORtOStO!s!aO~Oz}O!s!aO~O]!dO`PO!eZO~Oz}O!qca!wca!scauca~Ot!hO~P,tOt!hO~O^`O|^O~P'}OPsOQsORiiSii!qii!wii!siiuii~O^`O|^O!q!kO~P'}O^`O|^O!q!pO~P'}Ou!qO~O^`O|^O!q!rOu!rP~P'}O!sqitqi~P,tOu!vO~O^`O|^O!q!rOu!rP!Q!rP!S!rP~P'}O!q!yO~O^`O|^O!q!rOu!rX!Q!rX!S!rX~P'}Ou!{O~Ou#QO!Q!|O!S#PO~Ou#VO!Q!|O!S#PO~Ot#XO~Ou#VO~Ot#YO~P,tOt#YO~Ou#ZO~O!q#[O~O!q#]O~Ofo~", stateData: "3v~O!_OS!`OS~O]PO^`O_SO`ZOaWOfSOnSOoSOpSO|^O!eYO!hQO!qcO~O]fO_SO`ZOaWOfSOnSOoSOpSOwgOyhO!eYO!hQOzhX!qhX!whX!shXuhX~OP!fXQ!fXR!fXS!fXT!fXU!fXV!fXW!fXX!fXY!fXZ!fX[!fX~P!TOknO!hqO!jlO!kmO~O]fO_SO`ZOaWOfSOnSOoSOpSOwgOyhO!eYO!hQO~OP!gXQ!gXR!gXS!gX!q!gX!w!gXT!gXU!gXV!gXW!gXX!gXY!gXZ!gX[!gX!s!gXu!gX~P$dOP!fXQ!fXR!fXS!fX!q!bX!w!bXu!bX~OPrOQrORsOSsO~OPrOQrORsOSsO!q!bX!w!bXu!bX~O]tOtsP~O]PO_SO`ZOaWOfSOnSOoSOpSO!eYO!hQO~O!d|O~Oz}O!q!bX!w!bXu!bX~O]fO_SO`ZOfSOnSOoSOpSO!eYO!hQO~OV!RO~O!q!SO!w!SO~OaWOw!UO~P(}Ozda!qda!wda!sdauda~P$dO]!WO!eYO~O!h!XO!j!XO!k!XO!l!XO!m!XO!n!XO~OknO!h!ZO!jlO!kmO~O]tOtsX~Ot!_O~O!s!`OP!fXQ!fXR!fXS!fXT!fXU!fXV!fXW!fXX!fXY!fXZ!fX[!fX~OT!bOU!bOV!aOW!aOX!aOY!aOZ!aO[!aO~OPrOQrORsOSsO~P,lOPrOQrORsOSsO!s!`O~Oz}O!s!`O~O]!cOf!cO!eYO~O]!dO`ZO!eYO~Oz}O!qca!wca!scauca~Ot!hO~P,lOt!hO~O^`O|^O~P'xOPrOQrORiiSii!qii!wii!siiuii~O^`O|^O!q!kO~P'xO^`O|^O!q!pO~P'xOu!qO~O^`O|^O!q!rOu!rP~P'xO!sqitqi~P,lOu!vO~O^`O|^O!q!rOu!rP!Q!rP!S!rP~P'xO!q!yO~O^`O|^O!q!rOu!rX!Q!rX!S!rX~P'xOu!{O~Ou#QO!Q!|O!S#PO~Ou#VO!Q!|O!S#PO~Ot#XO~Ou#VO~Ot#YO~P,lOt#YO~Ou#ZO~O!q#[O~O!q#]O~Ofo~",
goto: ",`!wPPPPPPPPPPPPPPPPPPP!x#X#gP$V#X$x%_P%x%xPPP%|&Y&sPP&vP&vPP&}P'Z'^'gP'kP&}'q'w'}(T(^(g(nPPPP(t(x)^PP)p*mP+[PPPPP+`+`P+sP+{,S,SdaOe!R!`!h!k!p!t#[#]R{Zi[OZe}!R!`!h!k!p!t#[#]fQOZe!R!`!h!k!p!t#[#]hgQS^ilst!b!c!d!e!|R!d}fSOZe!R!`!h!k!p!t#[#]hTQS^ilst!b!c!d!e!|Q!XmR!e}dWOe!R!`!h!k!p!t#[#]QzZQ!]sR!^t!PTOQSZ^eilst!R!`!b!c!d!e!h!k!p!t!|#[#]ToRqQ{ZQ!Q^Q!l!cR#T!|daOe!R!`!h!k!p!t#[#]YhQSl!d!eQ{ZR!ViRwXZjQSl!d!eeaOe!R!`!h!k!p!t#[#]R!o!hQ!x!pQ#^#[R#_#]T!}!x#OQ#R!xR#W#OQeOR!TeQqRR!ZqQvXR!_vW!t!k!p#[#]R!z!tWlQS!d!eR!WlS!O]|R!g!OQ#O!xR#U#OTdOeSbOeQ!i!RQ!j!`Q!n!hZ!s!k!p!t#[#]d]Oe!R!`!h!k!p!t#[#]Q|ZR!f}dVOe!R!`!h!k!p!t#[#]YhQSl!d!eQyZQ!P^Q!ViQ!]sQ!^tQ!l!bQ!m!cR#S!|dUOe!R!`!h!k!p!t#[#]hgQS^ilst!b!c!d!e!|RxZTpRqsYOQSZeil!R!`!d!e!h!k!p!t#[#]Q!u!kV!w!p#[#]ZkQSl!d!ee_Oe!R!`!h!k!p!t#[#]", goto: ",c!wPPPPPPPPPPPPPPPPPPP!x#X#gP$V#X${%bP%{%{PPP&P&]&vPP&yP&yPP'QP'^'a'jP'nP'Q't'z(Q(W(a(j(qPPPP(w({)aPP)s*pP+_PPPPP+c+cP+vP,O,V,VdaOe!R!_!h!k!p!t#[#]RzYi[OYe}!R!_!h!k!p!t#[#]fPOYe!R!_!h!k!p!t#[#]hfPR^hkrs!a!b!d!e!|R!d}fROYe!R!_!h!k!p!t#[#]hSPR^hkrs!a!b!d!e!|Q!WlQ!c|R!e}dVOe!R!_!h!k!p!t#[#]QyYQ![rR!]s!PSOPRY^ehkrs!R!_!a!b!d!e!h!k!p!t!|#[#]TnQpQzYQ!Q^Q!l!bR#T!|daOe!R!_!h!k!p!t#[#]YgPRk!d!eQzYR!UhRvWZiPRk!d!eeaOe!R!_!h!k!p!t#[#]R!o!hQ!x!pQ#^#[R#_#]T!}!x#OQ#R!xR#W#OQeOR!TeQpQR!YpQuWR!^uW!t!k!p#[#]R!z!tWkPR!d!eR!VkS!O]{R!g!OQ#O!xR#U#OTdOeSbOeQ!i!RQ!j!_Q!n!hZ!s!k!p!t#[#]d]Oe!R!_!h!k!p!t#[#]Q{YR!f}dUOe!R!_!h!k!p!t#[#]YgPRk!d!eQxYQ!P^Q!UhQ![rQ!]sQ!l!aQ!m!bR#S!|dTOe!R!_!h!k!p!t#[#]hfPR^hkrs!a!b!d!e!|RwYToQpsXOPRYehk!R!_!d!e!h!k!p!t#[#]Q!u!kV!w!p#[#]ZjPRk!d!ee_Oe!R!_!h!k!p!t#[#]",
nodeNames: "⚠ Star Slash Plus Minus And Or Eq Neq Lt Lte Gt Gte Identifier AssignableIdentifier Word IdentifierBeforeDot Do Program PipeExpr FunctionCall DotGet Number ParenExpr FunctionCallOrIdentifier BinOp String StringFragment Interpolation EscapeSeq Boolean Regex Null ConditionalOp FunctionDef Params colon keyword PositionalArg Underscore NamedArg NamedArgPrefix operator IfExpr keyword SingleLineThenBlock ThenBlock ElseIfExpr keyword ElseExpr keyword Assign", nodeNames: "⚠ Star Slash Plus Minus And Or Eq Neq Lt Lte Gt Gte Identifier AssignableIdentifier Word IdentifierBeforeDot Do Program PipeExpr FunctionCall DotGet Number ParenExpr FunctionCallOrIdentifier BinOp String StringFragment Interpolation EscapeSeq Boolean Regex Null ConditionalOp FunctionDef Params colon keyword PositionalArg Underscore NamedArg NamedArgPrefix operator IfExpr keyword SingleLineThenBlock ThenBlock ElseIfExpr keyword ElseExpr keyword Assign",
maxTerm: 85, maxTerm: 85,
context: trackScope, context: trackScope,
@ -23,5 +23,5 @@ export const parser = LRParser.deserialize({
tokenizers: [operatorTokenizer, 1, 2, 3, tokenizer, new LocalTokenGroup("[~RP!O!PU~ZO!d~~", 11)], tokenizers: [operatorTokenizer, 1, 2, 3, tokenizer, new LocalTokenGroup("[~RP!O!PU~ZO!d~~", 11)],
topRules: {"Program":[0,18]}, topRules: {"Program":[0,18]},
specialized: [{term: 13, get: (value: any, stack: any) => (specializeKeyword(value, stack) << 1), external: specializeKeyword},{term: 13, get: (value: keyof typeof spec_Identifier) => spec_Identifier[value] || -1}], specialized: [{term: 13, get: (value: any, stack: any) => (specializeKeyword(value, stack) << 1), external: specializeKeyword},{term: 13, get: (value: keyof typeof spec_Identifier) => spec_Identifier[value] || -1}],
tokenPrec: 860 tokenPrec: 863
}) })

View File

@ -274,4 +274,28 @@ end`).toMatchTree(`
Identifier heya Identifier heya
`) `)
}) })
test('can use the result of a parens expression as the property of dot get', () => {
expect('obj = list 1 2 3; obj.(1 + 2)').toMatchTree(`
Assign
AssignableIdentifier obj
Eq =
FunctionCall
Identifier list
PositionalArg
Number 1
PositionalArg
Number 2
PositionalArg
Number 3
FunctionCallOrIdentifier
DotGet
IdentifierBeforeDot obj
ParenExpr
BinOp
Number 1
Plus +
Number 2
`)
})
}) })

View File

@ -3,7 +3,7 @@ import { parser } from '#parser/shrimp'
import { $ } from 'bun' import { $ } from 'bun'
import { assert, errorMessage } from '#utils/utils' import { assert, errorMessage } from '#utils/utils'
import { Compiler } from '#compiler/compiler' import { Compiler } from '#compiler/compiler'
import { run, VM } from 'reefvm' import { run, VM, type TypeScriptFunction } from 'reefvm'
import { treeToString, VMResultToValue } from '#utils/tree' import { treeToString, VMResultToValue } from '#utils/tree'
const regenerateParser = async () => { const regenerateParser = async () => {
@ -33,13 +33,16 @@ declare module 'bun:test' {
toMatchTree(expected: string): T toMatchTree(expected: string): T
toMatchExpression(expected: string): T toMatchExpression(expected: string): T
toFailParse(): T toFailParse(): T
toEvaluateTo(expected: unknown, nativeFunctions?: Record<string, Function>): Promise<T> toEvaluateTo(
expected: unknown,
nativeFunctions?: Record<string, TypeScriptFunction>
): Promise<T>
toFailEvaluation(): Promise<T> toFailEvaluation(): Promise<T>
} }
} }
expect.extend({ expect.extend({
toMatchTree(received: unknown, expected: string) { toMatchTree(received, expected) {
assert(typeof received === 'string', 'toMatchTree can only be used with string values') assert(typeof received === 'string', 'toMatchTree can only be used with string values')
const tree = parser.parse(received) const tree = parser.parse(received)
@ -58,7 +61,7 @@ expect.extend({
} }
}, },
toFailParse(received: unknown) { toFailParse(received) {
assert(typeof received === 'string', 'toFailParse can only be used with string values') assert(typeof received === 'string', 'toFailParse can only be used with string values')
try { try {
@ -93,11 +96,7 @@ expect.extend({
} }
}, },
async toEvaluateTo( async toEvaluateTo(received, expected, nativeFunctions = {}) {
received: unknown,
expected: unknown,
nativeFunctions: Record<string, Function> = {}
) {
assert(typeof received === 'string', 'toEvaluateTo can only be used with string values') assert(typeof received === 'string', 'toEvaluateTo can only be used with string values')
try { try {
@ -109,13 +108,10 @@ expect.extend({
if (expected instanceof RegExp) expected = String(expected) if (expected instanceof RegExp) expected = String(expected)
if (value instanceof RegExp) value = String(value) if (value instanceof RegExp) value = String(value)
if (value === expected) { expect(value).toEqual(expected)
return { pass: true }
} else {
return { return {
message: () => `Expected evaluation to be ${expected}, but got ${value}`, message: () => `Expected evaluation to be ${expected}, but got ${value}`,
pass: false, pass: true,
}
} }
} catch (error) { } catch (error) {
return { return {
@ -125,7 +121,7 @@ expect.extend({
} }
}, },
async toFailEvaluation(received: unknown) { async toFailEvaluation(received) {
assert(typeof received === 'string', 'toFailEvaluation can only be used with string values') assert(typeof received === 'string', 'toFailEvaluation can only be used with string values')
try { try {