This commit is contained in:
Corey Johnson 2025-10-30 09:54:37 -07:00
parent a1693078f9
commit 9b57304b87
7 changed files with 527 additions and 6 deletions

5
.quokka Normal file
View File

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

View File

@ -0,0 +1,169 @@
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 function 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([])
})
})
// 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)
}
const getArgItems = (codeWithCursor: string): CompletionItem[] => {
return getVarsInScope(codeWithCursor).filter((v) => v.kind === 'arg')
}

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

@ -0,0 +1,305 @@
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 []
}
// Count how many positional args have already been used
const usedPositionalArgs = countPositionalArgs(context.fnCallNode, cursor)
if (fn.kind === 'shrimp-fn') {
// Skip params that have already been filled positionally
const remainingParams = fn.params.slice(usedPositionalArgs)
return remainingParams.map((paramName) => ({ kind: 'arg', name: paramName }))
} else if (fn.kind === 'nose-command') {
// Skip params that have already been filled positionally
const remainingParams = fn.def.signature.params.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
}
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

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