diff --git a/.quokka b/.quokka new file mode 100644 index 0000000..275c1ef --- /dev/null +++ b/.quokka @@ -0,0 +1,5 @@ +{ + "bun": { + "usageMode": "alwaysUse" + } +} \ No newline at end of file diff --git a/src/editor/autocomplete.test.ts b/src/editor/autocomplete.test.ts new file mode 100644 index 0000000..ac1cca6 --- /dev/null +++ b/src/editor/autocomplete.test.ts @@ -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 + + y = 10 + `) + expect(names).toEqual(['x']) + }) + + test('collects matches for identifier', () => { + const names = getNamesInScope(` + alpha = 5 + bravo = 10 + a + `) + expect(names).toEqual(['alpha']) + }) + + test('function parameters and local assignments are visible inside function', () => { + const names = getNamesInScope(` + do x y: + z = 10 + + `) + expect(names).toEqual(['x', 'y', 'z']) + }) + + test('function parameters and locals not visible outside function', () => { + const names = getNamesInScope(` + do x: + y = 1 + end + + `) + expect(names).toEqual([]) + }) + + test('inner functions see outer scope variables', () => { + const names = getNamesInScope(` + x = 1 + do: + y = 2 + do: + + `) + expect(names).toEqual(['y', 'x']) + }) + + test('if expressions do not create scope boundaries', () => { + const names = getNamesInScope(` + if true: + x = 5 + end + + `) + expect(names).toEqual(['x']) + }) + + test('assigned function names are visible in scope', () => { + const names = getNamesInScope(` + add = do x: x + 1 end + + `) + expect(names).toEqual(['add']) + }) + + test('identifiers are visible in conditional scope', () => { + const names = getNamesInScope(` + add = if true: + x = 1 + + end + `) + expect(names).toEqual(['add', 'x']) + }) + + test('multiple assignments', () => { + const names = getNamesInScope(` + alpha = alamo = 4 + + 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 + `) + expect(args).toEqual(['x', 'y']) + }) + + test('does not include args already used', () => { + const args = getArgsInScope(` + add = do x y: x + y end + add 5 + `) + expect(args).toEqual(['y']) + }) + + test('no args when cursor in function name', () => { + const items = getVarsInScope(` + add = do x y: x + y end + add + `) + expect(items.every((i) => i.kind !== 'arg')).toBe(true) + }) + + test('no args when function not found', () => { + const args = getArgsInScope(` + unknown + `) + expect(args).toEqual([]) + }) +}) + +// Helper functions + +const getVarsInScope = (codeWithCursor: string): CompletionItem[] => { + const cursorPos = codeWithCursor.indexOf('') + if (cursorPos === -1) { + throw new Error('Test code must contain marker') + } + + const code = codeWithCursor.replace('', '') + 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') +} diff --git a/src/editor/autocomplete.ts b/src/editor/autocomplete.ts new file mode 100644 index 0000000..a4d19bc --- /dev/null +++ b/src/editor/autocomplete.ts @@ -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() + 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, + })) +}) diff --git a/src/editor/noseClient.ts b/src/editor/noseClient.ts index a581ae7..653e3de 100644 --- a/src/editor/noseClient.ts +++ b/src/editor/noseClient.ts @@ -25,6 +25,19 @@ type IncomingMessage = } | { type: 'reef-output'; data: Value } | { 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() diff --git a/src/editor/plugins/debugTags.ts b/src/editor/plugins/debugTags.ts index d959d64..896410b 100644 --- a/src/editor/plugins/debugTags.ts +++ b/src/editor/plugins/debugTags.ts @@ -11,19 +11,29 @@ export const debugTags = ViewPlugin.fromClass( } updateStatusBar(view: EditorView) { - const pos = view.state.selection.main.head + 1 + const pos = view.state.selection.main.head const tree = syntaxTree(view.state) let tags: string[] = [] let node = tree.resolveInner(pos, -1) + let nodes = [] + let lastNode = null while (node) { + nodes.push(node) tags.push(node.type.name) node = node.parent! 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({ side: 'right', message: debugText, diff --git a/src/editor/plugins/keymap.tsx b/src/editor/plugins/keymap.tsx index 6c77f14..d2fbe38 100644 --- a/src/editor/plugins/keymap.tsx +++ b/src/editor/plugins/keymap.tsx @@ -1,3 +1,4 @@ +import { autocomplete } from '#editor/autocomplete' import { multilineModeSignal, outputSignal } from '#editor/editor' import { printBytecodeOutput, printParserOutput, runCode } from '#editor/runCode' import { EditorState } from '@codemirror/state' @@ -49,10 +50,26 @@ const customKeymap = keymap.of([ key: 'Tab', preventDefault: true, run: (view) => { - view.dispatch({ - changes: { from: view.state.selection.main.from, insert: ' ' }, - selection: { anchor: view.state.selection.main.from + 2 }, - }) + // 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({ + changes: { from: view.state.selection.main.from, insert: ' ' }, + 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 }, }, diff --git a/src/editor/plugins/shrimpSetup.ts b/src/editor/plugins/shrimpSetup.ts index 9adaea2..711688f 100644 --- a/src/editor/plugins/shrimpSetup.ts +++ b/src/editor/plugins/shrimpSetup.ts @@ -10,6 +10,7 @@ import { shrimpLanguage } from './shrimpLanguage' import { shrimpErrors } from './errors' import { persistencePlugin } from './persistence' import { catchErrors } from './catchErrors' +import { debugTags } from '#editor/plugins/debugTags' export const shrimpSetup = (lineNumbersCompartment: Compartment) => { return [ @@ -31,5 +32,6 @@ export const shrimpSetup = (lineNumbersCompartment: Compartment) => { shrimpHighlighting, shrimpErrors, persistencePlugin, + debugTags, ] }