wip
This commit is contained in:
parent
a1693078f9
commit
9b57304b87
169
src/editor/autocomplete.test.ts
Normal file
169
src/editor/autocomplete.test.ts
Normal 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
305
src/editor/autocomplete.ts
Normal 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,
|
||||
}))
|
||||
})
|
||||
|
|
@ -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<IncomingMessage>()
|
||||
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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
|
||||
},
|
||||
},
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
]
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in New Issue
Block a user