diff --git a/src/editor/commands.ts b/src/editor/commands.ts new file mode 100644 index 0000000..b3407f7 --- /dev/null +++ b/src/editor/commands.ts @@ -0,0 +1,247 @@ +export type CommandShape = { + command: string + description?: string + args: ArgShape[] +} + +type ArgShape = + | { + name: string + type: T + description?: string + named?: false + } + | { + name: string + type: T + description?: string + named: true + default: ArgTypeMap[T] + } + +type ArgTypeMap = { + string: string + number: number + boolean: boolean +} + +const commandShapes: CommandShape[] = [ + { + command: 'ls', + description: 'List the contents of a directory', + args: [ + { name: 'path', type: 'string', description: 'The path to list' }, + { name: 'all', type: 'boolean', description: 'Show hidden files', default: false }, + { name: 'long', type: 'boolean', description: 'List in long format', default: false }, + { + name: 'short-names', + type: 'boolean', + description: 'Only print file names', + default: false, + }, + { name: 'full-paths', type: 'boolean', description: 'Display full paths', default: false }, + ], + }, + + { + command: 'cd', + description: 'Change the current working directory', + args: [{ name: 'path', type: 'string', description: 'The path to change to' }], + }, + + { + command: 'cp', + description: 'Copy files or directories', + args: [ + { name: 'source', type: 'string', description: 'Source file or directory' }, + { name: 'destination', type: 'string', description: 'Destination path' }, + { name: 'recursive', type: 'boolean', description: 'Copy recursively', default: false }, + { name: 'verbose', type: 'boolean', description: 'Verbose output', default: false }, + ], + }, + + { + command: 'mv', + description: 'Move files or directories', + args: [ + { name: 'source', type: 'string', description: 'Source file or directory' }, + { name: 'destination', type: 'string', description: 'Destination path' }, + { name: 'verbose', type: 'boolean', description: 'Verbose output', default: false }, + ], + }, + + { + command: 'rm', + description: 'Remove files or directories', + args: [ + { name: 'path', type: 'string', description: 'Path to remove' }, + { name: 'recursive', type: 'boolean', description: 'Remove recursively', default: false }, + { name: 'force', type: 'boolean', description: 'Force removal', default: false }, + { name: 'verbose', type: 'boolean', description: 'Verbose output', default: false }, + ], + }, + + { + command: 'mkdir', + description: 'Create directories', + args: [ + { name: 'path', type: 'string', description: 'Directory path to create' }, + { name: 'verbose', type: 'boolean', description: 'Verbose output', default: false }, + ], + }, + + { + command: 'touch', + description: 'Create empty files or update timestamps', + args: [ + { name: 'path', type: 'string', description: 'File path to touch' }, + { name: 'access', type: 'boolean', description: 'Update access time only', default: false }, + { + name: 'modified', + type: 'boolean', + description: 'Update modified time only', + default: false, + }, + ], + }, + + { + command: 'echo', + description: 'Display a string', + args: [ + { name: 'text', type: 'string', description: 'Text to display' }, + { name: 'no-newline', type: 'boolean', description: "Don't append newline", default: false }, + ], + }, + + { + command: 'cat', + description: 'Display file contents', + args: [ + { name: 'path', type: 'string', description: 'File to display' }, + { name: 'numbered', type: 'boolean', description: 'Show line numbers', default: false }, + ], + }, + + { + command: 'head', + description: 'Show first lines of input', + args: [ + { name: 'path', type: 'string', description: 'File to read from' }, + { name: 'lines', type: 'number', description: 'Number of lines', default: 10 }, + ], + }, + + { + command: 'tail', + description: 'Show last lines of input', + args: [ + { name: 'path', type: 'string', description: 'File to read from' }, + { name: 'lines', type: 'number', description: 'Number of lines', default: 10 }, + { name: 'follow', type: 'boolean', description: 'Follow file changes', default: false }, + ], + }, + + { + command: 'grep', + description: 'Search for patterns in text', + args: [ + { name: 'pattern', type: 'string', description: 'Pattern to search for' }, + { + name: 'ignore-case', + type: 'boolean', + description: 'Case insensitive search', + default: false, + }, + { name: 'invert-match', type: 'boolean', description: 'Invert match', default: false }, + { name: 'line-number', type: 'boolean', description: 'Show line numbers', default: false }, + ], + }, + + { + command: 'sort', + description: 'Sort input', + args: [ + { name: 'reverse', type: 'boolean', description: 'Sort in reverse order', default: false }, + { + name: 'ignore-case', + type: 'boolean', + description: 'Case insensitive sort', + default: false, + }, + { name: 'numeric', type: 'boolean', description: 'Numeric sort', default: false }, + ], + }, + + { + command: 'uniq', + description: 'Filter out repeated lines', + args: [ + { name: 'count', type: 'boolean', description: 'Show count of occurrences', default: false }, + { + name: 'repeated', + type: 'boolean', + description: 'Show only repeated lines', + default: false, + }, + { name: 'unique', type: 'boolean', description: 'Show only unique lines', default: false }, + ], + }, + + { + command: 'select', + description: 'Select specific columns from data', + args: [{ name: 'columns', type: 'string', description: 'Columns to select' }], + }, + + { + command: 'where', + description: 'Filter data based on conditions', + args: [{ name: 'condition', type: 'string', description: 'Filter condition' }], + }, + + { + command: 'group-by', + description: 'Group data by column values', + args: [{ name: 'column', type: 'string', description: 'Column to group by' }], + }, + + { + command: 'ps', + description: 'List running processes', + args: [ + { name: 'long', type: 'boolean', description: 'Show detailed information', default: false }, + ], + }, + + { + command: 'sys', + description: 'Show system information', + args: [], + }, + + { + command: 'which', + description: 'Find the location of a command', + args: [ + { name: 'command', type: 'string', description: 'Command to locate' }, + { name: 'all', type: 'boolean', description: 'Show all matches', default: false }, + ], + }, +] as const + +let commandSource = () => commandShapes +export const setCommandSource = (fn: () => CommandShape[]) => { + commandSource = fn +} + +export const resetCommandSource = () => { + commandSource = () => commandShapes +} + +export const matchingCommands = (prefix: string) => { + const match = commandSource().find((cmd) => cmd.command === prefix) + const partialMatches = commandSource().filter((cmd) => cmd.command.startsWith(prefix)) + + return { match, partialMatches } +} diff --git a/src/editor/editor.tsx b/src/editor/editor.tsx new file mode 100644 index 0000000..a1bf1b8 --- /dev/null +++ b/src/editor/editor.tsx @@ -0,0 +1,58 @@ +import { basicSetup } from 'codemirror' +import { EditorView } from '@codemirror/view' +import { shrimpTheme } from '#editor/theme' +import { shrimpLanguage } from '#/editor/shrimpLanguage' +import { shrimpHighlighting } from '#editor/theme' +import { shrimpKeymap } from '#editor/keymap' +import { log } from '#utils/utils' +import { Signal } from '#utils/signal' +import { shrimpErrors } from '#editor/plugins/errors' +import { inlineHints } from '#editor/plugins/inlineHints' +import { debugTags } from '#editor/plugins/debugTags' + +export const outputSignal = new Signal<{ output: string } | { error: string }>() +outputSignal.connect((output) => { + const outputEl = document.querySelector('#output') + if (!outputEl) { + log.error('Output element not found') + return + } + + if ('error' in output) { + outputEl.innerHTML = `
${output.error}
` + } else { + outputEl.textContent = output.output + } +}) + +export const Editor = () => { + return ( + <> +
{ + if (ref?.querySelector('.cm-editor')) return + const view = new EditorView({ + doc: defaultCode, + parent: ref, + extensions: [ + shrimpKeymap, + basicSetup, + shrimpTheme, + shrimpLanguage(), + shrimpHighlighting, + shrimpErrors, + inlineHints, + debugTags, + ], + }) + + requestAnimationFrame(() => view.focus()) + }} + /> +
+
+ + ) +} + +const defaultCode = `` diff --git a/src/editor/keymap.ts b/src/editor/keymap.ts new file mode 100644 index 0000000..7c22e04 --- /dev/null +++ b/src/editor/keymap.ts @@ -0,0 +1,24 @@ +import { outputSignal } from '#editor/editor' +import { evaluate } from '#evaluator/evaluator' +import { parser } from '#parser/shrimp' +import { errorMessage, log } from '#utils/utils' +import { keymap } from '@codemirror/view' + +export const shrimpKeymap = keymap.of([ + { + key: 'Cmd-Enter', + run: (view) => { + const input = view.state.doc.toString() + const context = new Map() + try { + const tree = parser.parse(input) + const output = evaluate(input, tree, context) + outputSignal.emit({ output: String(output) }) + } catch (error) { + log.error(error) + outputSignal.emit({ error: `${errorMessage(error)}` }) + } + return true + }, + }, +]) diff --git a/src/editor/plugins/debugTags.ts b/src/editor/plugins/debugTags.ts new file mode 100644 index 0000000..a04e437 --- /dev/null +++ b/src/editor/plugins/debugTags.ts @@ -0,0 +1,32 @@ +import { EditorView, ViewPlugin, ViewUpdate } from '@codemirror/view' +import { syntaxTree } from '@codemirror/language' + +export const debugTags = ViewPlugin.fromClass( + class { + update(update: ViewUpdate) { + if (update.docChanged || update.selectionSet || update.geometryChanged) { + this.updateStatusBar(update.view) + } + } + + updateStatusBar(view: EditorView) { + const pos = view.state.selection.main.head + const tree = syntaxTree(view.state) + + let tags: string[] = [] + let node = tree.resolveInner(pos, -1) + + while (node) { + tags.push(node.type.name) + node = node.parent! + if (!node) break + } + + const debugText = tags.length ? tags.reverse().slice(1).join(' > ') : 'No nodes' + const statusBar = document.querySelector('#status-bar') + if (statusBar) { + statusBar.textContent = debugText + } + } + } +) diff --git a/src/editor/plugins/errors.ts b/src/editor/plugins/errors.ts new file mode 100644 index 0000000..22b58d8 --- /dev/null +++ b/src/editor/plugins/errors.ts @@ -0,0 +1,62 @@ +import { parser } from '#parser/shrimp' +import type { Timeout } from '#utils/utils' +import { Range } from '@codemirror/state' +import { + Decoration, + EditorView, + ViewPlugin, + ViewUpdate, + type DecorationSet, +} from '@codemirror/view' + +export const shrimpErrors = ViewPlugin.fromClass( + class { + timeout?: Timeout + decorations: DecorationSet = Decoration.none + + constructor(view: EditorView) { + this.updateErrors(view) + } + + update(update: ViewUpdate) { + if (update.docChanged) { + this.debounceUpdate(update.view) + } + } + + updateErrors(view: EditorView) { + this.decorations = Decoration.none + try { + const decorations: Range[] = [] + const tree = parser.parse(view.state.doc.toString()) + tree.iterate({ + enter: (node) => { + if (!node.type.isError) return + + // Skip empty error nodes + if (node.from === node.to) return + + const decoration = Decoration.mark({ + class: 'syntax-error', + attributes: { title: 'COREY REPLACE THIS' }, + }).range(node.from, node.to) + decorations.push(decoration) + }, + }) + + this.decorations = Decoration.set(decorations) + requestAnimationFrame(() => view.dispatch({})) + } catch (e) { + console.error('🙈 Error parsing document', e) + } + } + + debounceUpdate = (view: EditorView) => { + clearTimeout(this.timeout) + this.timeout = setTimeout(() => this.updateErrors(view), 250) + } + }, + { + decorations: (v) => v.decorations, + } +) diff --git a/src/editor/plugins/inlineHints.tsx b/src/editor/plugins/inlineHints.tsx new file mode 100644 index 0000000..5128cab --- /dev/null +++ b/src/editor/plugins/inlineHints.tsx @@ -0,0 +1,240 @@ +import { + ViewPlugin, + ViewUpdate, + EditorView, + Decoration, + type DecorationSet, +} from '@codemirror/view' +import { syntaxTree } from '@codemirror/language' +import { type SyntaxNode } from '@lezer/common' +import { WidgetType } from '@codemirror/view' +import { toElement } from '#utils/utils' +import { matchingCommands } from '#editor/commands' +import * as Terms from '#parser/shrimp.terms' + +const ghostTextTheme = EditorView.theme({ + '.ghost-text': { + color: '#666', + opacity: '0.6', + fontStyle: 'italic', + }, +}) + +type Hint = { cursor: number; hintText?: string; completionText?: string } + +export const inlineHints = [ + ViewPlugin.fromClass( + class { + decorations: DecorationSet = Decoration.none + currentHint?: Hint + + update(update: ViewUpdate) { + if (!update.docChanged && !update.selectionSet) return + + this.clearHints() + let hint = this.getContext(update.view) + this.currentHint = hint + this.showHint(hint) + } + + handleTab(view: EditorView) { + if (!this.currentHint?.completionText) return false + + this.decorations = Decoration.none + view.dispatch({ + changes: { + from: this.currentHint.cursor, + insert: this.currentHint.completionText, + }, + selection: { + anchor: this.currentHint.cursor + this.currentHint.completionText.length, + }, + }) + this.currentHint = undefined + return true + } + + clearHints() { + this.currentHint = undefined + this.decorations = Decoration.none + } + + getContext(view: EditorView): Hint { + const cursor = view.state.selection.main.head + + const isCursorAtEnd = cursor === view.state.doc.length + if (!isCursorAtEnd) return { cursor } + + const token = this.getCommandContextToken(view, cursor) + if (!token) return { cursor } + + const text = view.state.doc.sliceString(token.from, token.to) + const tokenId = token.type.id + + let completionText = '' + let hintText = '' + const justSpaces = view.state.doc.sliceString(cursor - 1, cursor) === ' ' + + if (tokenId === Terms.CommandPartial) { + const { partialMatches } = matchingCommands(text) + const match = partialMatches[0] + if (match) { + completionText = match.command.slice(text.length) + ' ' + hintText = completionText + } + } else if ( + tokenId === Terms.Identifier && + token.parent?.type.id === Terms.Arg && + !justSpaces + ) { + const { availableArgs } = this.getCommandContext(view, token) + const matchingArgs = availableArgs.filter((arg) => arg.name.startsWith(text)) + const match = matchingArgs[0] + if (match) { + hintText = `${match.name.slice(text.length)}=<${match.type}>` + completionText = `${match.name.slice(text.length)}=` + } + } else if (this.containedBy(token, Terms.PartialNamedArg)) { + const { availableArgs } = this.getCommandContext(view, token) + const textWithoutEquals = text.slice(0, -1) + const matchingArgs = availableArgs.filter((arg) => arg.name == textWithoutEquals) + const match = matchingArgs[0] + if (match) { + hintText = `<${match.type}>` + completionText = 'default' in match ? `${match.default}` : '' + } + } else { + const { availableArgs } = this.getCommandContext(view, token) + const nextArg = Array.from(availableArgs)[0] + const space = justSpaces ? '' : ' ' + if (nextArg) { + hintText = `${space}${nextArg.name}=<${nextArg.type}>` + if (nextArg) { + completionText = `${space}${nextArg.name}=` + } + } + } + + return { completionText, hintText, cursor } + } + + getCommandContextToken(view: EditorView, cursor: number) { + const tree = syntaxTree(view.state) + let node = tree.resolveInner(cursor, -1) + + // If we're in a CommandCall, return the token before cursor + if (this.containedBy(node, Terms.CommandCall)) { + return tree.resolveInner(cursor, -1) + } + + // If we're in Program, look backward + while (node.name === 'Program' && cursor > 0) { + cursor -= 1 + node = tree.resolveInner(cursor, -1) + if (this.containedBy(node, Terms.CommandCall)) { + return tree.resolveInner(cursor, -1) + } + } + } + + containedBy(node: SyntaxNode, nodeId: number): SyntaxNode | undefined { + let current: SyntaxNode | undefined = node + + while (current) { + if (current.type.id === nodeId) { + return current + } + + current = current.parent ?? undefined + } + } + + showHint(hint: Hint) { + if (!hint.hintText) return + + const widget = new GhostTextWidget(hint.hintText) + const afterCursor = 1 + const decoration = Decoration.widget({ widget, side: afterCursor }).range(hint.cursor) + this.decorations = Decoration.set([decoration]) + } + + getCommandContext(view: EditorView, currentToken: SyntaxNode) { + let commandCallNode = currentToken.parent + while (commandCallNode?.type.name !== 'CommandCall') { + if (!commandCallNode) { + throw new Error('No CommandCall parent found, must be an error in the grammar') + } + commandCallNode = commandCallNode.parent + } + + const commandToken = commandCallNode.firstChild + if (!commandToken) { + throw new Error('CommandCall has no children, must be an error in the grammar') + } + + const commandText = view.state.doc.sliceString(commandToken.from, commandToken.to) + const { match: commandShape } = matchingCommands(commandText) + if (!commandShape) { + throw new Error(`No command shape found for command "${commandText}"`) + } + + let availableArgs = [...commandShape.args] + + // Walk through all NamedArg children + let child = commandToken.nextSibling + + while (child) { + console.log('child', child.type.name, child.to - child.from) + if (child.type.id === Terms.NamedArg) { + const argName = child.firstChild // Should be the Identifier + if (argName) { + const argText = view.state.doc.sliceString(argName.from, argName.to - 1) + availableArgs = availableArgs.filter((arg) => arg.name !== argText) + } + } else if (child.type.id == Terms.Arg) { + const hasSpaceAfter = view.state.doc.sliceString(child.to, child.to + 1) === ' ' + if (hasSpaceAfter) { + availableArgs.shift() + } + } + + child = child.nextSibling + } + + console.log( + `🌭`, + availableArgs.map((a) => a.name) + ) + + return { + commandShape, + availableArgs, + } + } + }, + { + decorations: (v) => v.decorations, + eventHandlers: { + keydown(event, view) { + if (event.key === 'Tab') { + event.preventDefault() + const plugin = view.plugin(inlineHints[0]! as ViewPlugin) + plugin?.handleTab(view) + } + }, + }, + } + ), + ghostTextTheme, +] + +class GhostTextWidget extends WidgetType { + constructor(private text: string) { + super() + } + + toDOM() { + const el = {this.text} + return toElement(el) + } +} diff --git a/src/server/shrimpLanguage.ts b/src/editor/shrimpLanguage.ts similarity index 100% rename from src/server/shrimpLanguage.ts rename to src/editor/shrimpLanguage.ts diff --git a/src/server/editorTheme.tsx b/src/editor/theme.tsx similarity index 63% rename from src/server/editorTheme.tsx rename to src/editor/theme.tsx index a18795d..6fabfdc 100644 --- a/src/server/editorTheme.tsx +++ b/src/editor/theme.tsx @@ -3,17 +3,19 @@ import { HighlightStyle, syntaxHighlighting } from '@codemirror/language' import { tags } from '@lezer/highlight' const highlightStyle = HighlightStyle.define([ - { tag: tags.keyword, color: '#C792EA' }, // fn - soft purple (Night Owl inspired) - { tag: tags.name, color: '#82AAFF' }, // identifiers - bright blue (Night Owl style) - { tag: tags.string, color: '#C3E88D' }, // strings - soft green - { tag: tags.number, color: '#F78C6C' }, // numbers - warm orange - { tag: tags.bool, color: '#FF5370' }, // booleans - coral red - { tag: tags.operator, color: '#89DDFF' }, // operators - cyan blue - { tag: tags.paren, color: '#676E95' }, // parens - muted blue-gray + { tag: tags.keyword, color: '#C792EA' }, + { tag: tags.name, color: '#82AAFF' }, + { tag: tags.string, color: '#C3E88D' }, + { tag: tags.number, color: '#F78C6C' }, + { tag: tags.bool, color: '#FF5370' }, + { tag: tags.operator, color: '#89DDFF' }, + { tag: tags.paren, color: '#676E95' }, + { tag: tags.function(tags.variableName), color: '#FF9CAC' }, + { tag: tags.function(tags.invalid), color: 'white' }, { tag: tags.definition(tags.variableName), - color: '#FFCB6B', // warm yellow - backgroundColor: '#1E2A4A', // dark blue background + color: '#FFCB6B', + backgroundColor: '#1E2A4A', padding: '1px 2px', borderRadius: '2px', fontWeight: '500', @@ -22,20 +24,19 @@ const highlightStyle = HighlightStyle.define([ export const shrimpHighlighting = syntaxHighlighting(highlightStyle) -export const editorTheme = EditorView.theme( +export const shrimpTheme = EditorView.theme( { '&': { color: '#D6DEEB', // Night Owl text color backgroundColor: '#011627', // Night Owl dark blue - fontFamily: '"Pixeloid Mono", "Courier New", monospace', - fontSize: '18px', height: '100%', + fontSize: '18px', }, '.cm-content': { + fontFamily: '"Pixeloid Mono", "Courier New", monospace', caretColor: '#80A4C2', // soft blue caret padding: '0px', minHeight: '100px', - borderBottom: '3px solid #1E2A4A', }, '.cm-activeLine': { backgroundColor: 'transparent', diff --git a/src/evaluator/evaluator.test.ts b/src/evaluator/evaluator.test.ts new file mode 100644 index 0000000..88ac688 --- /dev/null +++ b/src/evaluator/evaluator.test.ts @@ -0,0 +1,8 @@ +import { expect, test } from 'bun:test' + +test('parses simple assignments', () => { + expect('number = 5').toEvaluateTo(5) + expect('number = -5.3').toEvaluateTo(-5.3) + expect(`string = 'abc'`).toEvaluateTo('abc') + expect('boolean = true').toEvaluateTo(true) +}) diff --git a/src/evaluator/evaluator.ts b/src/evaluator/evaluator.ts index a2269a8..9dc6c3e 100644 --- a/src/evaluator/evaluator.ts +++ b/src/evaluator/evaluator.ts @@ -1,6 +1,7 @@ -import { nodeToString } from '@/evaluator/treeHelper' +import { nodeToString } from '#/evaluator/treeHelper' import { Tree, type SyntaxNode } from '@lezer/common' import * as terms from '../parser/shrimp.terms.ts' +import { errorMessage } from '#utils/utils.ts' type Context = Map @@ -14,13 +15,35 @@ function getChildren(node: SyntaxNode): SyntaxNode[] { return children } +class RuntimeError extends Error { + constructor(message: string, private input: string, private from: number, private to: number) { + super(message) + this.name = 'RuntimeError' + this.message = `${message} at "${input.slice(from, to)}" (${from}:${to})` + } + + toReadableString(code: string) { + const pointer = ' '.repeat(this.from) + '^'.repeat(this.to - this.from) + const context = code.split('\n').slice(-2).join('\n') + return `${context}\n${pointer}\n${this.message}` + } +} + export const evaluate = (input: string, tree: Tree, context: Context) => { // Just evaluate the top-level children, don't use iterate() let result = undefined let child = tree.topNode.firstChild - while (child) { - result = evaluateNode(child, input, context) - child = child.nextSibling + try { + while (child) { + result = evaluateNode(child, input, context) + child = child.nextSibling + } + } catch (error) { + if (error instanceof RuntimeError) { + throw new Error(error.toReadableString(input)) + } else { + throw new RuntimeError('Unknown error during evaluation', input, 0, input.length) + } } return result @@ -29,86 +52,105 @@ export const evaluate = (input: string, tree: Tree, context: Context) => { const evaluateNode = (node: SyntaxNode, input: string, context: Context): any => { const value = input.slice(node.from, node.to) - try { - switch (node.type.id) { - case terms.Number: { - return parseFloat(value) - } + switch (node.type.id) { + case terms.Number: { + return parseFloat(value) + } - case terms.Identifier: { - if (!context.has(value)) { - throw new Error(`Undefined identifier: ${value}`) + case terms.String: { + return value.slice(1, -1) // Remove quotes + } + case terms.Boolean: { + return value === 'true' + } + + case terms.Identifier: { + if (!context.has(value)) { + throw new RuntimeError(`Undefined identifier: ${value}`, input, node.from, node.to) + } + return context.get(value) + } + + case terms.BinOp: { + let [left, op, right] = getChildren(node) + + left = assertNode(left, 'LeftOperand') + op = assertNode(op, 'Operator') + right = assertNode(right, 'RightOperand') + + const leftValue = evaluateNode(left, input, context) + const opValue = input.slice(op.from, op.to) + const rightValue = evaluateNode(right, input, context) + + switch (opValue) { + case '+': + return leftValue + rightValue + case '-': + return leftValue - rightValue + case '*': + return leftValue * rightValue + case '/': + return leftValue / rightValue + default: + throw new RuntimeError(`Unknown operator: ${opValue}`, input, op.from, op.to) + } + } + + case terms.Assignment: { + const [identifier, _operator, expr] = getChildren(node) + + const identifierNode = assertNode(identifier, 'Identifier') + const exprNode = assertNode(expr, 'Expression') + + const name = input.slice(identifierNode.from, identifierNode.to) + const value = evaluateNode(exprNode, input, context) + context.set(name, value) + + return value + } + + case terms.Function: { + const [params, body] = getChildren(node) + + const paramNodes = getChildren(assertNode(params, 'Parameters')) + const bodyNode = assertNode(body, 'Body') + + const paramNames = paramNodes.map((param) => { + const paramNode = assertNode(param, 'Identifier') + return input.slice(paramNode.from, paramNode.to) + }) + + return (...args: any[]) => { + if (args.length !== paramNames.length) { + throw new RuntimeError( + `Expected ${paramNames.length} arguments, but got ${args.length}`, + input, + node.from, + node.to + ) } - return context.get(value) - } - case terms.BinOp: { - let [left, op, right] = getChildren(node) - - left = assertNode(left, 'LeftOperand') - op = assertNode(op, 'Operator') - right = assertNode(right, 'RightOperand') - - const leftValue = evaluateNode(left, input, context) - const opValue = input.slice(op.from, op.to) - const rightValue = evaluateNode(right, input, context) - - switch (opValue) { - case '+': - return leftValue + rightValue - case '-': - return leftValue - rightValue - case '*': - return leftValue * rightValue - case '/': - return leftValue / rightValue - default: - throw new Error(`Unsupported operator: ${opValue}`) - } - } - - case terms.Assignment: { - const [identifier, expr] = getChildren(node) - - const identifierNode = assertNode(identifier, 'Identifier') - const exprNode = assertNode(expr, 'Expression') - - const name = input.slice(identifierNode.from, identifierNode.to) - const value = evaluateNode(exprNode, input, context) - context.set(name, value) - return value - } - - case terms.Function: { - const [params, body] = getChildren(node) - - const paramNodes = getChildren(assertNode(params, 'Parameters')) - const bodyNode = assertNode(body, 'Body') - - const paramNames = paramNodes.map((param) => { - const paramNode = assertNode(param, 'Identifier') - return input.slice(paramNode.from, paramNode.to) + const localContext = new Map(context) + paramNames.forEach((param, index) => { + localContext.set(param, args[index]) }) - return (...args: any[]) => { - if (args.length !== paramNames.length) { - throw new Error(`Expected ${paramNames.length} arguments, but got ${args.length}`) - } - - const localContext = new Map(context) - paramNames.forEach((param, index) => { - localContext.set(param, args[index]) - }) - - return evaluateNode(bodyNode, input, localContext) - } + return evaluateNode(bodyNode, input, localContext) } - - default: - throw new Error(`Unsupported node type: ${node.name}`) } - } catch (error) { - throw new Error(`Error evaluating node "${value}"\n${error.message}`) + + default: + const isLowerCase = node.type.name[0] == node.type.name[0]?.toLowerCase() + + // Ignore nodes with lowercase names, those are for syntax only + if (!isLowerCase) { + throw new RuntimeError( + `Unsupported node type "${node.type.name}"`, + input, + node.from, + node.to + ) + } } } diff --git a/src/evaluator/treeHelper.ts b/src/evaluator/treeHelper.ts index 7420cc5..1fabdbc 100644 --- a/src/evaluator/treeHelper.ts +++ b/src/evaluator/treeHelper.ts @@ -11,7 +11,6 @@ export const nodeToString = (nodeRef: SyntaxNodeRef, input: string, maxDepth = 1 const indent = ' '.repeat(depth) const text = input.slice(currentNodeRef.from, currentNodeRef.to) - const singleTokens = ['+', '-', '*', '/', '->', 'fn', '=', 'equals'] let child = currentNodeRef.node.firstChild if (child) { @@ -22,11 +21,7 @@ export const nodeToString = (nodeRef: SyntaxNodeRef, input: string, maxDepth = 1 } } else { const cleanText = currentNodeRef.name === 'String' ? text.slice(1, -1) : text - if (singleTokens.includes(currentNodeRef.name)) { - lines.push(`${indent}${currentNodeRef.name}`) - } else { - lines.push(`${indent}${currentNodeRef.name} ${cleanText}`) - } + lines.push(`${indent}${currentNodeRef.name} ${cleanText}`) } } diff --git a/src/parser/highlight.js b/src/parser/highlight.js index 374b6ff..5a66e1d 100644 --- a/src/parser/highlight.js +++ b/src/parser/highlight.js @@ -1,5 +1,5 @@ -import { Identifier, Params } from '@/parser/shrimp.terms' import { styleTags, tags } from '@lezer/highlight' +import { Command, Identifier, Params } from '#/parser/shrimp.terms' export const highlighting = styleTags({ Identifier: tags.name, @@ -8,6 +8,8 @@ export const highlighting = styleTags({ Boolean: tags.bool, Keyword: tags.keyword, Operator: tags.operator, + Command: tags.function(tags.variableName), + CommandPartial: tags.function(tags.invalid), // Params: tags.definition(tags.variableName), 'Params/Identifier': tags.definition(tags.variableName), Paren: tags.paren, diff --git a/src/parser/shrimp.grammar b/src/parser/shrimp.grammar index d8c9d4a..d88ae3c 100644 --- a/src/parser/shrimp.grammar +++ b/src/parser/shrimp.grammar @@ -1,32 +1,48 @@ @external propSource highlighting from "./highlight.js" -@top Program { expr* } +@top Program { line } + +line { + CommandCall semi | + expr semi +} @skip { space } @tokens { + @precedence { Number "-"} space { @whitespace+ } - Number { $[0-9]+ ('.' $[0-9]+)? } + Number { "-"? $[0-9]+ ('.' $[0-9]+)? } Boolean { "true" | "false" } - String { '"' !["]* '"' } - - fn[@name=Keyword] { "fn" } - equals[@name=Operator] { "=" } - ":"[@name=Colon] - "+"[@name=Operator] - "-"[@name=Operator] - "*"[@name=Operator] - "/"[@name=Operator] - leftParen[@name=Paren] { "(" } - rightParen[@name=Paren] { ")" } + String { '\'' !["]* '\'' } + NamedArgPrefix { $[a-z]+ $[a-z0-9\-]* "=" } // matches "lines=", "follow=", etc. + + fn[@name=keyword] { "fn" } + equals[@name=operator] { "=" } + ":"[@name=colon] + "+"[@name=operator] + "-"[@name=operator] + "*"[@name=operator] + "/"[@name=operator] + leftParen[@name=paren] { "(" } + rightParen[@name=paren] { ")" } } -@external tokens identifierTokenizer from "./tokenizers" { - Identifier +@external tokens tokenizer from "./tokenizers" { + Identifier, + Command, + CommandPartial } +@external tokens argTokenizer from "./tokenizers" { + UnquotedArg +} + +@external tokens insertSemicolon from "./tokenizers" { insertedSemi } + @precedence { multiplicative @left, additive @left, + namedComplete @left, function @right assignment @right } @@ -38,6 +54,20 @@ expr { atom } +semi { insertedSemi | ";" } + +argValue { atom | UnquotedArg } + +CommandCall { (Command | CommandPartial) (NamedArg | PartialNamedArg | Arg)* } +Arg { !namedComplete argValue } +NamedArg { NamedArgPrefix !namedComplete argValue } // Required atom, higher precedence +PartialNamedArg { NamedArgPrefix } // Just the prefix + +Assignment { Identifier !assignment equals expr } + +Function { !function fn Params ":" expr } +Params { Identifier* } + BinOp { expr !multiplicative "*" expr | expr !multiplicative "/" expr | @@ -45,9 +75,4 @@ BinOp { expr !additive "-" expr } -Params { Identifier* } -Function { !function fn Params ":" expr } - -atom { Identifier | Number | String | Boolean | leftParen expr rightParen } - -Assignment { Identifier !assignment equals expr } \ No newline at end of file +atom { Identifier ~command | Number | String | Boolean | leftParen expr rightParen } diff --git a/src/parser/shrimp.terms.ts b/src/parser/shrimp.terms.ts index 1c25326..e87e5b9 100644 --- a/src/parser/shrimp.terms.ts +++ b/src/parser/shrimp.terms.ts @@ -1,15 +1,24 @@ // This file was generated by lezer-generator. You probably shouldn't edit it. export const Identifier = 1, - Program = 2, - Assignment = 3, - equals = 4, - Function = 5, - fn = 6, - Params = 7, - BinOp = 9, - Number = 14, - String = 15, - Boolean = 16, - leftParen = 17, - rightParen = 18 + Command = 2, + CommandPartial = 3, + UnquotedArg = 4, + insertedSemi = 30, + Program = 5, + CommandCall = 6, + NamedArg = 7, + NamedArgPrefix = 8, + Number = 9, + String = 10, + Boolean = 11, + leftParen = 12, + Assignment = 13, + equals = 14, + Function = 15, + fn = 16, + Params = 17, + BinOp = 19, + rightParen = 24, + PartialNamedArg = 25, + Arg = 26 diff --git a/src/parser/shrimp.test.ts b/src/parser/shrimp.test.ts index 35efeeb..7df1c79 100644 --- a/src/parser/shrimp.test.ts +++ b/src/parser/shrimp.test.ts @@ -1,4 +1,135 @@ import { expect, describe, test } from 'bun:test' +import { afterEach } from 'bun:test' +import { resetCommandSource, setCommandSource } from '#editor/commands' +import { beforeEach } from 'bun:test' +import './shrimp.grammar' // Importing this so changes cause it to retest! + +describe('calling commands', () => { + beforeEach(() => { + setCommandSource(() => [ + { command: 'tail', args: [{ name: 'path', type: 'string' }] }, + { command: 'head', args: [{ name: 'path', type: 'string' }] }, + { command: 'echo', args: [{ name: 'path', type: 'string' }] }, + ]) + }) + + afterEach(() => { + resetCommandSource() + }) + + test('basic', () => { + expect('tail path').toMatchTree(` + CommandCall + Command tail + Arg + Identifier path + `) + + expect('tai').toMatchTree(` + CommandCall + CommandPartial tai + `) + }) + + test('command with arg that is also a command', () => { + expect('tail tail').toMatchTree(` + CommandCall + Command tail + Arg + Identifier tail + `) + + expect('tai').toMatchTree(` + CommandCall + CommandPartial tai + `) + }) + + test('when no commands match, falls back to Identifier', () => { + expect('omgwtf').toMatchTree(` + Identifier omgwtf + `) + }) + + // In shrimp.test.ts, add to the 'calling commands' section + test('arg', () => { + expect('tail l').toMatchTree(` + CommandCall + Command tail + Arg + Identifier l + `) + }) + + test('partial namedArg', () => { + expect('tail lines=').toMatchTree(` + CommandCall + Command tail + PartialNamedArg + NamedArgPrefix lines= + `) + }) + + test('complete namedArg', () => { + expect('tail lines=10').toMatchTree(` + CommandCall + Command tail + NamedArg + NamedArgPrefix lines= + Number 10 + `) + }) + + test('mixed positional and named args', () => { + expect('tail ../file.txt lines=5').toMatchTree(` + CommandCall + Command tail + Arg + UnquotedArg ../file.txt + NamedArg + NamedArgPrefix lines= + Number 5 + `) + }) + + test('named args', () => { + expect(`tail lines='5' path`).toMatchTree(` + CommandCall + Command tail + NamedArg + NamedArgPrefix lines= + String 5 + Arg + Identifier path + `) + }) + + test('complex args', () => { + expect(`tail lines=(2 + 3) filter='error' (a + b)`).toMatchTree(` + CommandCall + Command tail + NamedArg + NamedArgPrefix lines= + paren ( + BinOp + Number 2 + operator + + Number 3 + paren ) + NamedArg + NamedArgPrefix filter= + String error + + Arg + paren ( + BinOp + Identifier a + operator + + Identifier b + paren ) + `) + }) +}) describe('Identifier', () => { test('parses simple identifiers', () => { @@ -26,7 +157,7 @@ describe('BinOp', () => { expect('2 + 3').toMatchTree(` BinOp Number 2 - Operator + + operator + Number 3 `) }) @@ -35,7 +166,7 @@ describe('BinOp', () => { expect('5 - 2').toMatchTree(` BinOp Number 5 - Operator - + operator - Number 2 `) }) @@ -44,7 +175,7 @@ describe('BinOp', () => { expect('4 * 3').toMatchTree(` BinOp Number 4 - Operator * + operator * Number 3 `) }) @@ -53,7 +184,7 @@ describe('BinOp', () => { expect('8 / 2').toMatchTree(` BinOp Number 8 - Operator / + operator / Number 2 `) }) @@ -63,15 +194,15 @@ describe('BinOp', () => { BinOp BinOp Number 2 - Operator + + operator + BinOp Number 3 - Operator * + operator * Number 4 - Operator - + operator - BinOp Number 5 - Operator / + operator / Number 1 `) }) @@ -81,68 +212,53 @@ describe('Fn', () => { test('parses function with single parameter', () => { expect('fn x: x + 1').toMatchTree(` Function - Keyword fn + keyword fn Params Identifier x - Colon : + colon : BinOp Identifier x - Operator + + operator + Number 1`) }) test('parses function with multiple parameters', () => { expect('fn x y: x * y').toMatchTree(` Function - Keyword fn + keyword fn Params Identifier x Identifier y - Colon : + colon : BinOp Identifier x - Operator * + operator * Identifier y`) }) test('parses nested functions', () => { expect('fn x: fn y: x + y').toMatchTree(` Function - Keyword fn + keyword fn Params Identifier x - Colon : + colon : Function - Keyword fn + keyword fn Params Identifier y - Colon : + colon : BinOp Identifier x - Operator + + operator + Identifier y`) }) }) describe('Identifier', () => { test('parses hyphenated identifiers correctly', () => { - expect('my-var - another-var').toMatchTree(` - BinOp - Identifier my-var - Operator - - Identifier another-var`) - - expect('double--trouble - another-var').toMatchTree(` - BinOp - Identifier double--trouble - Operator - - Identifier another-var`) - - expect('tail-- - another-var').toMatchTree(` - BinOp - Identifier tail-- - Operator - - Identifier another-var`) + expect('my-var').toMatchTree(`Identifier my-var`) + expect('double--trouble').toMatchTree(`Identifier double--trouble`) }) }) @@ -151,10 +267,10 @@ describe('Assignment', () => { expect('x = 5 + 3').toMatchTree(` Assignment Identifier x - Operator = + operator = BinOp Number 5 - Operator + + operator + Number 3`) }) @@ -162,16 +278,16 @@ describe('Assignment', () => { expect('add = fn a b: a + b').toMatchTree(` Assignment Identifier add - Operator = + operator = Function - Keyword fn + keyword fn Params Identifier a Identifier b - Colon : + colon : BinOp Identifier a - Operator + + operator + Identifier b`) }) }) @@ -180,59 +296,36 @@ describe('Parentheses', () => { test('parses expressions with parentheses correctly', () => { expect('(2 + 3) * 4').toMatchTree(` BinOp - Paren ( + paren ( BinOp Number 2 - Operator + + operator + Number 3 - Paren ) - Operator * + paren ) + operator * Number 4`) }) test('parses nested parentheses correctly', () => { expect('((1 + 2) * (3 - 4)) / 5').toMatchTree(` BinOp - Paren ( + paren ( BinOp - Paren ( + paren ( BinOp Number 1 - Operator + + operator + Number 2 - Paren ) - Operator * - Paren ( + paren ) + operator * + paren ( BinOp Number 3 - Operator - + operator - Number 4 - Paren ) - Paren ) - Operator / + paren ) + paren ) + operator / Number 5`) }) }) - -describe('multiline', () => { - test('parses multiline expressions', () => { - expect(` - 5 + 4 - fn x: x - 1 - `).toMatchTree(` - BinOp - Number 5 - Operator + - Number 4 - Function - Keyword fn - Params - Identifier x - Colon : - BinOp - Identifier x - Operator - - Number 1 - `) - }) -}) diff --git a/src/parser/shrimp.ts b/src/parser/shrimp.ts index e25ac62..55602b6 100644 --- a/src/parser/shrimp.ts +++ b/src/parser/shrimp.ts @@ -1,19 +1,19 @@ // This file was generated by lezer-generator. You probably shouldn't edit it. import {LRParser} from "@lezer/lr" -import {identifierTokenizer} from "./tokenizers" +import {tokenizer, argTokenizer, insertSemicolon} from "./tokenizers" import {highlighting} from "./highlight.js" export const parser = LRParser.deserialize({ version: 14, - states: "$OQVQROOOkQRO'#CuO!fQRO'#CaO!nQRO'#CoOOQQ'#Cu'#CuOVQRO'#CuOOQQ'#Ct'#CtQVQROOOVQRO,58yOOQQ'#Cp'#CpO#cQRO'#CcO#kQPO,58{OVQRO,59POVQRO,59PO#pQPO,59aOOQQ-E6m-E6mO$RQRO1G.eOOQQ-E6n-E6nOVQRO1G.gOOQQ1G.k1G.kO$yQRO1G.kOOQQ1G.{1G.{O%qQRO7+$R", - stateData: "&i~OgOS~OPPOUQO^SO_SO`SOaTO~OSWOPiXUiXYiXZiX[iX]iX^iX_iX`iXaiXeiXbiX~OPXOWVP~OY[OZ[O[]O]]OPcXUcX^cX_cX`cXacXecX~OPXOWVX~OWbO~OY[OZ[O[]O]]ObeO~OY[OZ[O[]O]]OPRiURi^Ri_Ri`RiaRieRibRi~OY[OZ[OPXiUXi[Xi]Xi^Xi_Xi`XiaXieXibXi~OY[OZ[O[]O]]OPTqUTq^Tq_Tq`TqaTqeTqbTq~O", - goto: "!hjPPPkPkPtPkPPPPPPPPPw}PPP!Tk_UOTVW[]bRZQQVOR_VQYQRaYSROVQ^TQ`WQc[Qd]Rfb", - nodeNames: "⚠ Identifier Program Assignment Operator Function Keyword Params Colon BinOp Operator Operator Operator Operator Number String Boolean Paren Paren", - maxTerm: 25, + states: "%^OkQTOOOuQaO'#DPO!aQTO'#CkO!iQaO'#C}OOQ`'#DQ'#DQOOQl'#DP'#DPOVQTO'#DPO#fQnO'#CbO!uQaO'#C}QOQPOOOVQTO,59TOOQS'#Cx'#CxO#pQTO'#CmO#xQPO,59VOVQTO,59ZOVQTO,59ZOOQO'#DR'#DROOQO,59i,59iO#}QPO,59kOOQl'#DO'#DOO$tQnO'#CuOOQl'#Cv'#CvOOQl'#Cw'#CwO%RQnO,58|O%]QaO1G.oOOQS-E6v-E6vOVQTO1G.qOOQ`1G.u1G.uO%tQaO1G.uOOQl1G/V1G/VOOQl,58},58}OOQl-E6u-E6uO&]QaO7+$]", + stateData: "&w~OpOS~OPPOXTOYTOZTO[UO`QO~OQVORVO~PVO^YOdsXesXfsXgsXnsXvsXhsX~OPZObaP~Od^Oe^Of_Og_On`Ov`O~OPTOScOWdOXTOYTOZTO[UO~OnUXvUX~P!}OPZObaX~ObjO~Od^Oe^Of_Og_OhmO~OPTOScOXTOYTOZTO[UO~OWiXniXviX~P$`OnUavUa~P!}Od^Oe^Of_Og_On]iv]ih]i~Od^Oe^Ofcigcincivcihci~Od^Oe^Of_Og_On_qv_qh_q~OXg~", + goto: "#fvPPPPPPwzPPPPP!OP!OP!WP!OPPPPPzz!Z!aPPPP!g!j!q#O#bRWOTfVg]SOUY^_jR]QQgVRogQ[QRi[RXOSeVgRnd[SOUY^_jVcVdgQROQbUQhYQk^Ql_RpjTaRW", + nodeNames: "⚠ Identifier Command CommandPartial UnquotedArg Program CommandCall NamedArg NamedArgPrefix Number String Boolean paren Assignment operator Function keyword Params colon BinOp operator operator operator operator paren PartialNamedArg Arg", + maxTerm: 38, propSources: [highlighting], skippedNodes: [0], repeatNodeCount: 2, - tokenData: "&a~RfX^!gpq!grs#[xy#yyz$Oz{$T{|$Y}!O$_!P!Q$d!Q![$i![!]%S!_!`%X#Y#Z%^#h#i&T#y#z!g$f$g!g#BY#BZ!g$IS$I_!g$I|$JO!g$JT$JU!g$KV$KW!g&FU&FV!g~!lYg~X^!gpq!g#y#z!g$f$g!g#BY#BZ!g$IS$I_!g$I|$JO!g$JT$JU!g$KV$KW!g&FU&FV!g~#_TOr#[rs#ns;'S#[;'S;=`#s<%lO#[~#sO_~~#vP;=`<%l#[~$OOa~~$TOb~~$YOY~~$_O[~~$dO]~~$iOZ~~$nQ^~!O!P$t!Q![$i~$wP!Q![$z~%PP^~!Q![$z~%XOW~~%^OS~~%aQ#T#U%g#b#c&O~%jP#`#a%m~%pP#g#h%s~%vP#X#Y%y~&OO`~~&TOU~~&WP#f#g&Z~&^P#i#j%s", - tokenizers: [0, identifierTokenizer], - topRules: {"Program":[0,2]}, - tokenPrec: 0 + tokenData: "*W~RjX^!spq!swx#hxy$lyz$qz{$v{|${}!O%Q!P!Q%s!Q![%Y![!]%x!]!^%}!_!`&S#T#Y&X#Y#Z&m#Z#h&X#h#i)[#i#o&X#y#z!s$f$g!s#BY#BZ!s$IS$I_!s$I|$JO!s$JT$JU!s$KV$KW!s&FU&FV!s~!xYp~X^!spq!s#y#z!s$f$g!s#BY#BZ!s$IS$I_!s$I|$JO!s$JT$JU!s$KV$KW!s&FU&FV!s~#kUOr#hsw#hwx#}x;'S#h;'S;=`$f<%lO#h~$SUY~Or#hsw#hwx#}x;'S#h;'S;=`$f<%lO#h~$iP;=`<%l#h~$qO[~~$vOh~~${Od~~%QOf~~%VPg~!Q![%Y~%_QX~!O!P%e!Q![%Y~%hP!Q![%k~%pPX~!Q![%k~%xOe~~%}Ob~~&SOv~~&XO^~Q&[S}!O&X!Q![&X!_!`&h#T#o&XQ&mOWQ~&pV}!O&X!Q![&X!_!`&h#T#U'V#U#b&X#b#c(y#c#o&X~'YU}!O&X!Q![&X!_!`&h#T#`&X#`#a'l#a#o&X~'oU}!O&X!Q![&X!_!`&h#T#g&X#g#h(R#h#o&X~(UU}!O&X!Q![&X!_!`&h#T#X&X#X#Y(h#Y#o&X~(mSZ~}!O&X!Q![&X!_!`&h#T#o&XR)OS`P}!O&X!Q![&X!_!`&h#T#o&X~)_U}!O&X!Q![&X!_!`&h#T#f&X#f#g)q#g#o&X~)tU}!O&X!Q![&X!_!`&h#T#i&X#i#j(R#j#o&X", + tokenizers: [0, 1, tokenizer, argTokenizer, insertSemicolon], + topRules: {"Program":[0,5]}, + tokenPrec: 266 }) diff --git a/src/parser/tokenizers.ts b/src/parser/tokenizers.ts index 85ad52b..01cb73f 100644 --- a/src/parser/tokenizers.ts +++ b/src/parser/tokenizers.ts @@ -1,5 +1,92 @@ import { ExternalTokenizer, InputStream } from '@lezer/lr' -import { Identifier } from './shrimp.terms' +import { CommandPartial, Command, Identifier, UnquotedArg, insertedSemi } from './shrimp.terms' +import { matchingCommands } from '#editor/commands' + +export const tokenizer = new ExternalTokenizer((input: InputStream, stack: Stack) => { + let ch = getFullCodePoint(input, 0) + if (!isLowercaseLetter(ch) && !isEmoji(ch)) return + + let pos = getCharSize(ch) + let text = String.fromCodePoint(ch) + + // Continue consuming identifier characters + while (true) { + ch = getFullCodePoint(input, pos) + + if (isLowercaseLetter(ch) || isDigit(ch) || ch === 45 /* - */ || isEmoji(ch)) { + text += String.fromCodePoint(ch) + pos += getCharSize(ch) + } else { + break + } + } + + input.advance(pos) + + if (!stack.canShift(Command) && !stack.canShift(CommandPartial)) { + input.acceptToken(Identifier) + return + } + + const { match, partialMatches } = matchingCommands(text) + if (match) { + input.acceptToken(Command) + } else if (partialMatches.length > 0) { + input.acceptToken(CommandPartial) + } else { + input.acceptToken(Identifier) + } +}) + +export const argTokenizer = new ExternalTokenizer((input: InputStream, stack: Stack) => { + // Only match if we're in a command argument position + if (!stack.canShift(UnquotedArg)) return + + const firstCh = input.peek(0) + + // Don't match if it starts with tokens we handle elsewhere + if ( + firstCh === 39 /* ' */ || + firstCh === 40 /* ( */ || + firstCh === 45 /* - (for negative numbers) */ || + (firstCh >= 48 && firstCh <= 57) /* 0-9 (numbers) */ + ) + return + + // Read everything that's not a space, newline, or paren + let pos = 0 + while (true) { + const ch = input.peek(pos) + if ( + ch === -1 || + ch === 32 /* space */ || + ch === 10 /* \n */ || + ch === 40 /* ( */ || + ch === 41 /* ) */ || + ch === 61 /* = */ + ) + break + pos++ + } + + if (pos > 0) { + input.advance(pos) + input.acceptToken(UnquotedArg) + } +}) + +export const insertSemicolon = new ExternalTokenizer((input: InputStream, stack: Stack) => { + const next = input.peek(0) + + // We're at a newline or end of file + if (next === 10 /* \n */ || next === -1 /* EOF */) { + // Check if insertedSemi would be valid here + if (stack.canShift(insertedSemi)) { + // Don't advance! Virtual token has zero width + input.acceptToken(insertedSemi, 0) + } + } +}) function isLowercaseLetter(ch: number): boolean { return ch >= 97 && ch <= 122 // a-z @@ -54,29 +141,4 @@ function isEmoji(ch: number): boolean { ) } -export const identifierTokenizer = new ExternalTokenizer((input: InputStream) => { - const ch = getFullCodePoint(input, 0) - - if (isLowercaseLetter(ch) || isEmoji(ch)) { - let pos = ch > 0xffff ? 2 : 1 // emoji takes 2 UTF-16 code units - - // Continue consuming identifier characters - while (true) { - const nextCh = getFullCodePoint(input, pos) - - if ( - isLowercaseLetter(nextCh) || - isDigit(nextCh) || - nextCh === 45 /* - */ || - isEmoji(nextCh) - ) { - pos += nextCh > 0xffff ? 2 : 1 // advance by 1 or 2 UTF-16 code units - } else { - break - } - } - - input.advance(pos) // advance by total length - input.acceptToken(Identifier) - } -}) +const getCharSize = (ch: number) => (ch > 0xffff ? 2 : 1) // emoji takes 2 UTF-16 code units diff --git a/src/server/app.tsx b/src/server/app.tsx index 2a45247..d5c088b 100644 --- a/src/server/app.tsx +++ b/src/server/app.tsx @@ -1,13 +1,9 @@ -import { Editor } from '@/server/editor' +import { Editor } from '#/editor/editor' import { render } from 'hono/jsx/dom' import './index.css' const App = () => { - return ( -
- -
- ) + return } const root = document.getElementById('root')! diff --git a/src/server/debugPlugin.ts b/src/server/debugPlugin.ts deleted file mode 100644 index 019cf72..0000000 --- a/src/server/debugPlugin.ts +++ /dev/null @@ -1,71 +0,0 @@ -import { - EditorView, - Decoration, - ViewPlugin, - ViewUpdate, - WidgetType, - type DecorationSet, -} from '@codemirror/view' -import { syntaxTree } from '@codemirror/language' - -const createDebugWidget = (tags: string) => - Decoration.widget({ - widget: new (class extends WidgetType { - toDOM() { - const div = document.createElement('div') - div.style.cssText = ` - position: fixed; - top: 10px; - right: 10px; - background: #000; - color: #00ff00; - padding: 8px; - font-family: monospace; - font-size: 12px; - border: 1px solid #333; - z-index: 1000; - max-width: 300px; - word-wrap: break-word; - white-space: pre-wrap; - ` - div.textContent = tags - return div - } - })(), - }) - -export const debugTags = ViewPlugin.fromClass( - class { - decorations: DecorationSet = Decoration.none - - constructor(view: EditorView) { - this.updateDecorations(view) - } - - update(update: ViewUpdate) { - if (update.docChanged || update.selectionSet) { - this.updateDecorations(update.view) - } - } - - updateDecorations(view: EditorView) { - const pos = view.state.selection.main.head - const tree = syntaxTree(view.state) - - let tags: string[] = [] - let node = tree.resolveInner(pos, -1) - - while (node) { - tags.push(node.type.name) - node = node.parent! - if (!node) break - } - - const debugText = tags.length ? tags.join(' > ') : 'No nodes' - this.decorations = Decoration.set([createDebugWidget(debugText).range(pos)]) - } - }, - { - decorations: (v) => v.decorations, - } -) diff --git a/src/server/editor.tsx b/src/server/editor.tsx deleted file mode 100644 index 5c16c33..0000000 --- a/src/server/editor.tsx +++ /dev/null @@ -1,32 +0,0 @@ -import { basicSetup } from 'codemirror' -import { EditorView } from '@codemirror/view' -import { editorTheme } from './editorTheme' -import { shrimpLanguage } from './shrimpLanguage' -import { shrimpHighlighting } from './editorTheme' -import { debugTags } from '@/server/debugPlugin' - -export const Editor = () => { - return ( -
{ - if (ref?.querySelector('.cm-editor')) return - - console.log('init editor') - new EditorView({ - doc: `a = 3 -fn x y: x + y -aa = fn radius: 3.14 * radius * radius -b = true -c = "cyan" -`, - parent: ref, - extensions: [basicSetup, editorTheme, shrimpLanguage(), shrimpHighlighting], - }) - }} - /> - ) -} - -export const Output = ({ children }: { children: string }) => { - return
{children}
-} diff --git a/src/server/index.css b/src/server/index.css index 15afa43..9470906 100644 --- a/src/server/index.css +++ b/src/server/index.css @@ -20,7 +20,7 @@ body { flex-direction: column; } -.terminal-output { +#output { flex: 1; background: #40318D; color: #7C70DA; @@ -29,4 +29,24 @@ body { white-space: pre-wrap; font-family: 'Pixeloid Mono', 'Courier New', monospace; font-size: 18px; +} + +#output .error { + color: #FF6E6E; +} + +#status-bar { + height: 30px; + background: #1E2A4A; + color: #B3A9FF; + display: flex; + align-items: center; + padding: 0 10px; + font-size: 14px; + border-top: 3px solid #0E1A3A; + border-bottom: 3px solid #0E1A3A; +} + +.syntax-error { + text-decoration: underline dotted #FF6E6E; } \ No newline at end of file diff --git a/src/testSetup.ts b/src/testSetup.ts index ee9badf..d674692 100644 --- a/src/testSetup.ts +++ b/src/testSetup.ts @@ -1,7 +1,9 @@ import { expect } from 'bun:test' import { Tree, TreeCursor } from '@lezer/common' -import { parser } from './parser/shrimp.ts' +import { parser } from '#parser/shrimp' import { $ } from 'bun' +import { assert } from '#utils/utils' +import { evaluate } from '#evaluator/evaluator' const regenerateParser = async () => { let generate = true @@ -29,21 +31,18 @@ declare module 'bun:test' { interface Matchers { toMatchTree(expected: string): T toFailParse(): T + toEvaluateTo(expected: unknown): T } } expect.extend({ toMatchTree(received: unknown, expected: string) { - if (typeof received !== 'string') { - return { - message: () => 'toMatchTree can only be used with string values', - pass: false, - } - } + assert(typeof received === 'string', 'toMatchTree can only be used with string values') const tree = parser.parse(received) const actual = treeToString(tree, received) const normalizedExpected = trimWhitespace(expected) + try { // A hacky way to show the colorized diff in the test output expect(actual).toEqual(normalizedExpected) @@ -55,13 +54,9 @@ expect.extend({ } } }, + toFailParse(received: unknown) { - if (typeof received !== 'string') { - return { - message: () => 'toMatchTree can only be used with string values', - pass: false, - } - } + assert(typeof received === 'string', 'toFailParse can only be used with string values') try { const tree = parser.parse(received) @@ -94,6 +89,50 @@ expect.extend({ } } }, + + toEvaluateTo(received: unknown, expected: unknown) { + assert(typeof received === 'string', 'toEvaluateTo can only be used with string values') + + try { + const tree = parser.parse(received) + let hasErrors = false + tree.iterate({ + enter(n) { + if (n.type.isError) { + hasErrors = true + return false + } + }, + }) + + if (hasErrors) { + const actual = treeToString(tree, received) + return { + message: () => + `Expected input to evaluate successfully, but it had syntax errors:\n${actual}`, + pass: false, + } + } else { + const context = new Map() + const result = evaluate(received, tree, context) + if (Object.is(result, expected)) { + return { pass: true } + } else { + const expectedStr = JSON.stringify(expected) + const resultStr = JSON.stringify(result) + return { + message: () => `Expected evaluation to be ${expectedStr}, but got ${resultStr}`, + pass: false, + } + } + } + } catch (error) { + return { + message: () => `Evaluation threw an error: ${(error as Error).message}`, + pass: false, + } + } + }, }) const treeToString = (tree: Tree, input: string): string => { @@ -152,3 +191,10 @@ const trimWhitespace = (str: string): string => { }) .join('\n') } + +const expectString = (value: unknown): string => { + if (typeof value !== 'string') { + throw new Error('Expected a string input') + } + return value +} diff --git a/src/utils/signal.ts b/src/utils/signal.ts new file mode 100644 index 0000000..c10609c --- /dev/null +++ b/src/utils/signal.ts @@ -0,0 +1,57 @@ +/** + * How to use a Signal: + * + * Create a signal: + * const chatSignal = new Signal<{ username: string, message: string }>() + * + * Connect to the signal: + * const disconnect = chatSignal.connect((data) => { + * const {username, message} = data; + * console.log(`${username} said "${message}"`); + * }) + * + * Emit a signal: + * chatSignal.emit({ username: "Chad", message: "Hey everyone, how's it going?" }); + * + * Forward a signal: + * const relaySignal = new Signal<{ username: string, message: string }>() + * const disconnectRelay = chatSignal.connect(relaySignal) + * // Now, when chatSignal emits, relaySignal will also emit the same data + * + * Disconnect a single listener: + * disconnect(); // The disconnect function is returned when you connect to a signal + * + * Disconnect all listeners: + * chatSignal.disconnect() + */ + +export class Signal { + private listeners: Array<(data: T) => void> = [] + + connect(listenerOrSignal: Signal | ((data: T) => void)) { + let listener: (data: T) => void + + // If it is a signal, forward the data to the signal + if (listenerOrSignal instanceof Signal) { + listener = (data: T) => listenerOrSignal.emit(data) + } else { + listener = listenerOrSignal + } + + this.listeners.push(listener) + + return () => { + this.listeners = this.listeners.filter((l) => l !== listener) + } + } + + emit(data: T) { + for (const listener of this.listeners) { + listener(data) + } + } + + disconnect() { + this.listeners = [] + } +} diff --git a/src/utils/utils.tsx b/src/utils/utils.tsx new file mode 100644 index 0000000..cd3733e --- /dev/null +++ b/src/utils/utils.tsx @@ -0,0 +1,25 @@ +import { render } from 'hono/jsx/dom' + +export type Timeout = ReturnType + +export const log = (...args: any[]) => console.log(...args) +log.error = (...args: any[]) => console.error(`💥 ${args.join(' ')}`) + +export const errorMessage = (error: unknown) => { + if (error instanceof Error) { + return error.message + } + return String(error) +} + +export function assert(condition: boolean, message: string): asserts condition { + if (!condition) { + throw new Error(message) + } +} + +export const toElement = (node: any): HTMLElement => { + const c = document.createElement('div') + render(node, c) + return c.firstElementChild as HTMLElement +} diff --git a/tsconfig.json b/tsconfig.json index a0eb694..e3d2832 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -24,7 +24,7 @@ "baseUrl": ".", "paths": { - "@/*": ["./src/*"] + "#*": ["./src/*"] }, // Some stricter flags (disabled by default)