From ff58f2b8ae4d5dccc246d73b4e2eece7dce6080c Mon Sep 17 00:00:00 2001 From: Corey Johnson Date: Wed, 17 Dec 2025 09:55:37 -0800 Subject: [PATCH 1/6] wip --- package.json | 2 +- src/compiler/compilerError.ts | 2 +- src/editor/commands.ts | 261 ------------------------ src/editor/completions.ts | 2 + src/editor/diagnostics.ts | 25 +++ src/editor/editor.css | 53 ----- src/editor/editor.tsx | 152 ++------------ src/editor/highlighter.ts | 76 +++++++ src/editor/index.html | 23 +++ src/editor/noseClient.ts | 59 ------ src/editor/{plugins => }/persistence.ts | 0 src/editor/plugins/catchErrors.ts | 9 - src/editor/plugins/debugTags.ts | 35 ---- src/editor/plugins/errors.ts | 62 ------ src/editor/plugins/inlineHints.tsx | 232 --------------------- src/editor/plugins/keymap.tsx | 184 ----------------- src/editor/plugins/shrimpLanguage.ts | 9 - src/editor/plugins/shrimpSetup.ts | 35 ---- src/editor/plugins/theme.tsx | 60 ------ src/editor/runCode.tsx | 38 ---- src/editor/server.tsx | 14 ++ src/editor/theme.ts | 38 ++++ src/index.ts | 141 ++++++------- src/parser/tokenizer2.ts | 132 ++++++------ src/prelude/index.ts | 87 +++++--- src/server/app.tsx | 10 - src/server/index.css | 84 -------- src/server/index.html | 12 -- src/server/server.tsx | 29 --- src/utils/signal.ts | 68 ------ src/utils/utils.tsx | 10 + 31 files changed, 404 insertions(+), 1540 deletions(-) delete mode 100644 src/editor/commands.ts create mode 100644 src/editor/completions.ts create mode 100644 src/editor/diagnostics.ts create mode 100644 src/editor/highlighter.ts create mode 100644 src/editor/index.html delete mode 100644 src/editor/noseClient.ts rename src/editor/{plugins => }/persistence.ts (100%) delete mode 100644 src/editor/plugins/catchErrors.ts delete mode 100644 src/editor/plugins/debugTags.ts delete mode 100644 src/editor/plugins/errors.ts delete mode 100644 src/editor/plugins/inlineHints.tsx delete mode 100644 src/editor/plugins/keymap.tsx delete mode 100644 src/editor/plugins/shrimpLanguage.ts delete mode 100644 src/editor/plugins/shrimpSetup.ts delete mode 100644 src/editor/plugins/theme.tsx delete mode 100644 src/editor/runCode.tsx create mode 100644 src/editor/server.tsx create mode 100644 src/editor/theme.ts delete mode 100644 src/server/app.tsx delete mode 100644 src/server/index.css delete mode 100644 src/server/index.html delete mode 100644 src/server/server.tsx delete mode 100644 src/utils/signal.ts diff --git a/package.json b/package.json index 8fdc7e2..403f020 100644 --- a/package.json +++ b/package.json @@ -5,7 +5,7 @@ "private": true, "type": "module", "scripts": { - "dev": "bun --hot src/server/server.tsx", + "editor": "bun --hot src/editor/server.tsx", "repl": "bun bin/repl", "update-reef": "rm -rf ~/.bun/install/cache/ && rm bun.lock && bun update reefvm", "cli:install": "ln -s \"$(pwd)/bin/shrimp\" ~/.bun/bin/shrimp", diff --git a/src/compiler/compilerError.ts b/src/compiler/compilerError.ts index c5492a1..5e0f710 100644 --- a/src/compiler/compilerError.ts +++ b/src/compiler/compilerError.ts @@ -1,5 +1,5 @@ export class CompilerError extends Error { - constructor(message: string, private from: number, private to: number) { + constructor(message: string, public from: number, public to: number) { super(message) if (from < 0 || to < 0 || to < from) { diff --git a/src/editor/commands.ts b/src/editor/commands.ts deleted file mode 100644 index 021aefb..0000000 --- a/src/editor/commands.ts +++ /dev/null @@ -1,261 +0,0 @@ -export type CommandShape = { - command: string - description?: string - args: ArgShape[] - execute: string | ((...args: any[]) => any) -} - -type ArgShape = { - name: string - type: T - description?: string - optional?: boolean - default?: ArgTypeMap[T] -} - -type ArgTypeMap = { - string: string - number: number - boolean: boolean -} - -const commandShapes: CommandShape[] = [ - { - command: 'ls', - description: 'List the contents of a directory', - execute: './commands/ls.ts', - 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', - execute: './commands/cd.ts', - args: [{ name: 'path', type: 'string', description: 'The path to change to' }], - }, - - { - command: 'cp', - description: 'Copy files or directories', - execute: './commands/cp.ts', - 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', - execute: './commands/mv.ts', - 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', - execute: './commands/rm.ts', - 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', - execute: './commands/mkdir.ts', - 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', - execute: './commands/touch.ts', - 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', - execute: './commands/echo.ts', - 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', - execute: './commands/cat.ts', - 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', - execute: './commands/head.ts', - 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', - execute: './commands/tail.ts', - 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', - execute: './commands/grep.ts', - 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', - execute: './commands/sort.ts', - 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', - execute: './commands/uniq.ts', - 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', - execute: './commands/select.ts', - args: [{ name: 'columns', type: 'string', description: 'Columns to select' }], - }, - - { - command: 'where', - description: 'Filter data based on conditions', - execute: './commands/where.ts', - args: [{ name: 'condition', type: 'string', description: 'Filter condition' }], - }, - - { - command: 'group-by', - description: 'Group data by column values', - execute: './commands/group-by.ts', - args: [{ name: 'column', type: 'string', description: 'Column to group by' }], - }, - - { - command: 'ps', - description: 'List running processes', - execute: './commands/ps.ts', - args: [ - { name: 'long', type: 'boolean', description: 'Show detailed information', default: false }, - ], - }, - - { - command: 'sys', - description: 'Show system information', - execute: './commands/sys.ts', - args: [], - }, - - { - command: 'which', - description: 'Find the location of a command', - execute: './commands/which.ts', - 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/completions.ts b/src/editor/completions.ts new file mode 100644 index 0000000..5a8db2e --- /dev/null +++ b/src/editor/completions.ts @@ -0,0 +1,2 @@ +// Autocomplete for Shrimp +// TODO: Keywords and prelude function names diff --git a/src/editor/diagnostics.ts b/src/editor/diagnostics.ts new file mode 100644 index 0000000..fe9af90 --- /dev/null +++ b/src/editor/diagnostics.ts @@ -0,0 +1,25 @@ +import { linter, type Diagnostic } from '@codemirror/lint' +import { Shrimp } from '#/index' +import { CompilerError } from '#compiler/compilerError' + +const shrimp = new Shrimp() + +export const shrimpDiagnostics = linter((view) => { + const code = view.state.doc.toString() + const diagnostics: Diagnostic[] = [] + + try { + shrimp.parse(code) + } catch (err) { + if (err instanceof CompilerError) { + diagnostics.push({ + from: err.from, + to: err.to, + severity: 'error', + message: err.message, + }) + } + } + + return diagnostics +}) diff --git a/src/editor/editor.css b/src/editor/editor.css index 29ec6f6..e69de29 100644 --- a/src/editor/editor.css +++ b/src/editor/editor.css @@ -1,53 +0,0 @@ -#output { - flex: 1; - background: var(--bg-output); - color: var(--text-output); - padding: 20px; - overflow-y: auto; - white-space: pre-wrap; - font-family: 'Pixeloid Mono', 'Courier New', monospace; - font-size: 18px; -} - -#status-bar { - height: 30px; - background: var(--bg-status-bar); - color: var(--text-status); - display: flex; - align-items: center; - padding: 0 10px; - font-size: 14px; - border-top: 3px solid var(--bg-status-border); - border-bottom: 3px solid var(--bg-status-border); - - display: flex; - justify-content: space-between; -} - -#status-bar .left, -#status-bar .right { - display: flex; - justify-content: center; - gap: 2rem; -} - -#status-bar .multiline { - display: flex; - - .dot { - padding-top: 1px; - margin-right: 4px; - } - - .active { - color: var(--color-string); - } - - .inactive { - color: inherit; - } -} - -.syntax-error { - text-decoration: underline dotted var(--color-error); -} \ No newline at end of file diff --git a/src/editor/editor.tsx b/src/editor/editor.tsx index ce7e0b1..19ee5b7 100644 --- a/src/editor/editor.tsx +++ b/src/editor/editor.tsx @@ -1,140 +1,30 @@ -import { EditorView } from '@codemirror/view' -import { asciiEscapeToHtml, assertNever, log, toElement } from '#utils/utils' -import { Signal } from '#utils/signal' -import { getContent } from '#editor/plugins/persistence' -import type { HtmlEscapedString } from 'hono/utils/html' -import { connectToNose, noseSignals } from '#editor/noseClient' -import type { Value } from 'reefvm' -import { Compartment } from '@codemirror/state' -import { lineNumbers } from '@codemirror/view' -import { shrimpSetup } from '#editor/plugins/shrimpSetup' +import { EditorView, basicSetup } from 'codemirror' +import { render } from 'hono/jsx/dom' -import '#editor/editor.css' - -const lineNumbersCompartment = new Compartment() - -connectToNose() - -export const outputSignal = new Signal() -export const errorSignal = new Signal() -export const multilineModeSignal = new Signal() +import { shrimpTheme } from './theme' +import { shrimpDiagnostics } from './diagnostics' +import { persistencePlugin, getContent } from './persistence' +import { shrimpHighlighter } from './highlighter' export const Editor = () => { return ( - <> -
{ - if (ref?.querySelector('.cm-editor')) return - const view = new EditorView({ - parent: ref, - doc: getContent(), - extensions: shrimpSetup(lineNumbersCompartment), - }) - - multilineModeSignal.connect((isMultiline) => { - view.dispatch({ - effects: lineNumbersCompartment.reconfigure(isMultiline ? lineNumbers() : []), - }) - }) - - requestAnimationFrame(() => view.focus()) - }} - /> -
-
-
-
-
-
- +
{ + if (!el?.querySelector('.cm-editor')) createEditorView(el) + }} + /> ) } -noseSignals.connect((message) => { - if (message.type === 'error') { - log.error(`Nose error: ${message.data}`) - errorSignal.emit(`Nose error: ${message.data}`) - } else if (message.type === 'reef-output') { - const x = outputSignal.emit(message.data) - } else if (message.type === 'connected') { - outputSignal.emit(`╞ Connected to Nose VM`) - } -}) - -outputSignal.connect((value) => { - const el = document.querySelector('#output')! - el.innerHTML = '' - el.innerHTML = asciiEscapeToHtml(valueToString(value)) -}) - -errorSignal.connect((error) => { - const el = document.querySelector('#output')! - el.innerHTML = '' - el.classList.add('error') - el.innerHTML = asciiEscapeToHtml(error) -}) - -type StatusBarMessage = { - side: 'left' | 'right' - message: string | Promise - className: string - order?: number -} -export const statusBarSignal = new Signal() -statusBarSignal.connect(async ({ side, message, className, order }) => { - document.querySelector(`#status-bar .${className}`)?.remove() - - const sideEl = document.querySelector(`#status-bar .${side}`)! - const messageEl = ( -
- {await message} -
- ) - - // Now go through the nodes and put it in the right spot based on order. Higher number means further right - const nodes = Array.from(sideEl.childNodes) - const index = nodes.findIndex((node) => { - if (!(node instanceof HTMLElement)) return false - return Number(node.dataset.order) > (order ?? 0) +const createEditorView = (el: Element) => { + new EditorView({ + parent: el, + doc: getContent() || '# type some code\necho hello', + extensions: [basicSetup, shrimpTheme, shrimpDiagnostics, persistencePlugin, shrimpHighlighter], }) - - if (index === -1) { - sideEl.appendChild(toElement(messageEl)) - } else { - sideEl.insertBefore(toElement(messageEl), nodes[index]!) - } -}) - -const valueToString = (value: Value | string): string => { - if (typeof value === 'string') { - return value - } - - switch (value.type) { - case 'null': - return 'null' - case 'boolean': - return value.value ? 'true' : 'false' - case 'number': - return value.value.toString() - case 'string': - return value.value - case 'array': - return `${value.value.map(valueToString).join('\n')}` - case 'dict': { - const entries = Array.from(value.value.entries()).map( - ([key, val]) => `"${key}": ${valueToString(val)}` - ) - return `{${entries.join(', ')}}` - } - case 'regex': - return `/${value.value.source}/` - case 'function': - return `` - case 'native': - return `` - default: - assertNever(value) - return `` - } +} + +// Mount when running in browser +if (typeof document !== 'undefined') { + render(, document.getElementById('root')!) } diff --git a/src/editor/highlighter.ts b/src/editor/highlighter.ts new file mode 100644 index 0000000..e561bc4 --- /dev/null +++ b/src/editor/highlighter.ts @@ -0,0 +1,76 @@ +import { EditorView, Decoration, DecorationSet, ViewPlugin, ViewUpdate } from '@codemirror/view' +import { RangeSetBuilder } from '@codemirror/state' +import { Shrimp } from '#/index' +import { type SyntaxNode } from '#parser/node' + +const shrimp = new Shrimp() + +export const shrimpHighlighter = ViewPlugin.fromClass( + class { + decorations: DecorationSet + + constructor(view: EditorView) { + this.decorations = this.highlight(view) + } + + update(update: ViewUpdate) { + if (update.docChanged) { + this.decorations = this.highlight(update.view) + } + } + + highlight(view: EditorView): DecorationSet { + const builder = new RangeSetBuilder() + const code = view.state.doc.toString() + + try { + const tree = shrimp.parse(code) + const decorations: { from: number; to: number; class: string }[] = [] + + tree.iterate({ + enter: (node) => { + const cls = tokenStyles[node.type.name] + if (cls && isLeaf(node)) { + decorations.push({ from: node.from, to: node.to, class: cls }) + } + }, + }) + + // Sort by position (required by RangeSetBuilder) + decorations.sort((a, b) => a.from - b.from) + + for (const d of decorations) { + builder.add(d.from, d.to, Decoration.mark({ class: d.class })) + } + } catch { + // Parse failed, no highlighting + } + + return builder.finish() + } + }, + { + decorations: (v) => v.decorations, + } +) + +// Map node types to CSS classes +const tokenStyles: Record = { + keyword: 'tok-keyword', + String: 'tok-string', + StringFragment: 'tok-string', + CurlyString: 'tok-string', + Number: 'tok-number', + Boolean: 'tok-bool', + Null: 'tok-null', + Identifier: 'tok-identifier', + AssignableIdentifier: 'tok-variable-def', + Comment: 'tok-comment', + operator: 'tok-operator', + Regex: 'tok-regex', +} + +// Check if node is a leaf (should be highlighted) +const isLeaf = (node: SyntaxNode): boolean => { + return node.children.length === 0 +} diff --git a/src/editor/index.html b/src/editor/index.html new file mode 100644 index 0000000..f2129e5 --- /dev/null +++ b/src/editor/index.html @@ -0,0 +1,23 @@ + + + + + + +
+ + + + diff --git a/src/editor/noseClient.ts b/src/editor/noseClient.ts deleted file mode 100644 index a581ae7..0000000 --- a/src/editor/noseClient.ts +++ /dev/null @@ -1,59 +0,0 @@ -import { Signal } from '#utils/signal' -import type { Bytecode, Value } from 'reefvm' -let ws: WebSocket - -type IncomingMessage = - | { type: 'connected' } - | { type: 'ping'; data: number } - | { type: 'commands'; data: number } - | { - type: 'apps' - data: { - name: string - type: 'browser' | 'server' - }[] - } - | { - type: 'session:start' - data: { - NOSE_DIR: string - cwd: string - hostname: string - mode: string - project: string - } - } - | { type: 'reef-output'; data: Value } - | { type: 'error'; data: string } - -export const noseSignals = new Signal() - -export const connectToNose = (url: string = 'ws://localhost:3000/ws') => { - ws = new WebSocket(url) - ws.onopen = () => noseSignals.emit({ type: 'connected' }) - - ws.onmessage = (event) => { - const message = JSON.parse(event.data) - noseSignals.emit(message) - } - - ws.onerror = (event) => { - console.error(`💥WebSocket error:`, event) - } - - ws.onclose = () => { - console.log(`🚪 Connection closed`) - } -} - -let id = 0 -export const sendToNose = (code: Bytecode) => { - if (!ws) { - throw new Error('WebSocket is not connected.') - } else if (ws.readyState !== WebSocket.OPEN) { - throw new Error(`WebSocket is not open, current status is ${ws.readyState}.`) - } - - id += 1 - ws.send(JSON.stringify({ type: 'reef-bytecode', data: code, id })) -} diff --git a/src/editor/plugins/persistence.ts b/src/editor/persistence.ts similarity index 100% rename from src/editor/plugins/persistence.ts rename to src/editor/persistence.ts diff --git a/src/editor/plugins/catchErrors.ts b/src/editor/plugins/catchErrors.ts deleted file mode 100644 index b40cee6..0000000 --- a/src/editor/plugins/catchErrors.ts +++ /dev/null @@ -1,9 +0,0 @@ -import { errorSignal } from '#editor/editor' -import { EditorView } from '@codemirror/view' - -export const catchErrors = EditorView.exceptionSink.of((exception) => { - console.error('CodeMirror error:', exception) - errorSignal.emit( - `Editor error: ${exception instanceof Error ? exception.message : String(exception)}` - ) -}) diff --git a/src/editor/plugins/debugTags.ts b/src/editor/plugins/debugTags.ts deleted file mode 100644 index d959d64..0000000 --- a/src/editor/plugins/debugTags.ts +++ /dev/null @@ -1,35 +0,0 @@ -import { EditorView, ViewPlugin, ViewUpdate } from '@codemirror/view' -import { syntaxTree } from '@codemirror/language' -import { statusBarSignal } from '#editor/editor' - -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 + 1 - 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' - statusBarSignal.emit({ - side: 'right', - message: debugText, - className: 'debug-tags', - order: -1, - }) - } - } -) diff --git a/src/editor/plugins/errors.ts b/src/editor/plugins/errors.ts deleted file mode 100644 index 6536145..0000000 --- a/src/editor/plugins/errors.ts +++ /dev/null @@ -1,62 +0,0 @@ -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 deleted file mode 100644 index e96e491..0000000 --- a/src/editor/plugins/inlineHints.tsx +++ /dev/null @@ -1,232 +0,0 @@ -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 - } - - 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/editor/plugins/keymap.tsx b/src/editor/plugins/keymap.tsx deleted file mode 100644 index 6c77f14..0000000 --- a/src/editor/plugins/keymap.tsx +++ /dev/null @@ -1,184 +0,0 @@ -import { multilineModeSignal, outputSignal } from '#editor/editor' -import { printBytecodeOutput, printParserOutput, runCode } from '#editor/runCode' -import { EditorState } from '@codemirror/state' -import { keymap } from '@codemirror/view' - -let multilineMode = false - -const customKeymap = keymap.of([ - { - key: 'Enter', - run: (view) => { - if (multilineMode) return false - - const input = view.state.doc.toString() - history.push(input) - runCode(input) - view.dispatch({ - changes: { from: 0, to: view.state.doc.length, insert: '' }, - selection: { anchor: 0 }, - }) - return true - }, - }, - - { - key: 'Shift-Enter', - run: (view) => { - if (multilineMode) { - const input = view.state.doc.toString() - runCode(input) - - return true - } else { - outputSignal.emit('Press Shift+Enter to insert run the code.') - } - - multilineModeSignal.emit(true) - multilineMode = true - view.dispatch({ - changes: { from: view.state.doc.length, insert: '\n' }, - selection: { anchor: view.state.doc.length + 1 }, - }) - - return true - }, - }, - - { - key: 'Tab', - preventDefault: true, - run: (view) => { - view.dispatch({ - changes: { from: view.state.selection.main.from, insert: ' ' }, - selection: { anchor: view.state.selection.main.from + 2 }, - }) - return true - }, - }, - - { - key: 'ArrowUp', - run: (view) => { - if (multilineMode) return false - - const command = history.previous() - if (command === undefined) return false - view.dispatch({ - changes: { from: 0, to: view.state.doc.length, insert: command }, - selection: { anchor: command.length }, - }) - return true - }, - }, - - { - key: 'ArrowDown', - run: (view) => { - if (multilineMode) return false - - const command = history.next() - if (command === undefined) return false - view.dispatch({ - changes: { from: 0, to: view.state.doc.length, insert: command }, - selection: { anchor: command.length }, - }) - return true - }, - }, - - { - key: 'Mod-k 1', - preventDefault: true, - run: (view) => { - const input = view.state.doc.toString() - printParserOutput(input) - - return true - }, - }, - - { - key: 'Mod-k 2', - preventDefault: true, - run: (view) => { - const input = view.state.doc.toString() - printBytecodeOutput(input) - - return true - }, - }, -]) - -let firstTime = true -const singleLineFilter = EditorState.transactionFilter.of((transaction) => { - if (multilineMode) return transaction // Allow everything in multiline mode - - if (firstTime) { - firstTime = false - if (transaction.newDoc.toString().includes('\n')) { - multilineMode = true - return transaction - } - } - - transaction.changes.iterChanges((fromA, toA, fromB, toB, inserted) => { - if (inserted.toString().includes('\n')) { - multilineMode = true - return - } - }) - - return transaction -}) - -export const shrimpKeymap = [customKeymap, singleLineFilter] - -class History { - private commands: string[] = [] - private index: number | undefined - private storageKey = 'shrimp-command-history' - - constructor() { - try { - this.commands = JSON.parse(localStorage.getItem(this.storageKey) || '[]') - } catch { - console.warn('Failed to load command history from localStorage') - } - } - - push(command: string) { - this.commands.push(command) - - // Limit to last 50 commands - this.commands = this.commands.slice(-50) - localStorage.setItem(this.storageKey, JSON.stringify(this.commands)) - this.index = undefined - } - - previous(): string | undefined { - if (this.commands.length === 0) return - - if (this.index === undefined) { - this.index = this.commands.length - 1 - } else if (this.index > 0) { - this.index -= 1 - } - - return this.commands[this.index] - } - - next(): string | undefined { - if (this.commands.length === 0 || this.index === undefined) return - - if (this.index < this.commands.length - 1) { - this.index += 1 - return this.commands[this.index] - } else { - this.index = undefined - return '' - } - } -} - -const history = new History() diff --git a/src/editor/plugins/shrimpLanguage.ts b/src/editor/plugins/shrimpLanguage.ts deleted file mode 100644 index f23e73e..0000000 --- a/src/editor/plugins/shrimpLanguage.ts +++ /dev/null @@ -1,9 +0,0 @@ -import { parser } from '#/parser/shrimp' -import { LRLanguage, LanguageSupport } from '@codemirror/language' -import { highlighting } from '#/parser/highlight.js' - -const language = LRLanguage.define({ - parser: parser.configure({ props: [highlighting] }), -}) - -export const shrimpLanguage = new LanguageSupport(language) diff --git a/src/editor/plugins/shrimpSetup.ts b/src/editor/plugins/shrimpSetup.ts deleted file mode 100644 index 9adaea2..0000000 --- a/src/editor/plugins/shrimpSetup.ts +++ /dev/null @@ -1,35 +0,0 @@ -import { history, defaultKeymap, historyKeymap } from '@codemirror/commands' -import { bracketMatching, indentOnInput } from '@codemirror/language' -import { highlightSpecialChars, drawSelection, dropCursor, keymap } from '@codemirror/view' -import { closeBrackets, autocompletion, completionKeymap } from '@codemirror/autocomplete' -import { EditorState, Compartment } from '@codemirror/state' -import { searchKeymap } from '@codemirror/search' -import { shrimpKeymap } from './keymap' -import { shrimpTheme, shrimpHighlighting } from './theme' -import { shrimpLanguage } from './shrimpLanguage' -import { shrimpErrors } from './errors' -import { persistencePlugin } from './persistence' -import { catchErrors } from './catchErrors' - -export const shrimpSetup = (lineNumbersCompartment: Compartment) => { - return [ - catchErrors, - shrimpKeymap, - highlightSpecialChars(), - history(), - drawSelection(), - dropCursor(), - EditorState.allowMultipleSelections.of(true), - bracketMatching(), - closeBrackets(), - autocompletion(), - indentOnInput(), - keymap.of([...defaultKeymap, ...historyKeymap, ...searchKeymap, ...completionKeymap]), - lineNumbersCompartment.of([]), - shrimpTheme, - shrimpLanguage, - shrimpHighlighting, - shrimpErrors, - persistencePlugin, - ] -} diff --git a/src/editor/plugins/theme.tsx b/src/editor/plugins/theme.tsx deleted file mode 100644 index c1e46f1..0000000 --- a/src/editor/plugins/theme.tsx +++ /dev/null @@ -1,60 +0,0 @@ -import { EditorView } from '@codemirror/view' -import { HighlightStyle, syntaxHighlighting } from '@codemirror/language' -import { tags } from '@lezer/highlight' - -const highlightStyle = HighlightStyle.define([ - { tag: tags.keyword, color: 'var(--color-keyword)' }, - { tag: tags.name, color: 'var(--color-function)' }, - { tag: tags.string, color: 'var(--color-string)' }, - { tag: tags.number, color: 'var(--color-number)' }, - { tag: tags.bool, color: 'var(--color-bool)' }, - { tag: tags.operator, color: 'var(--color-operator)' }, - { tag: tags.paren, color: 'var(--color-paren)' }, - { tag: tags.regexp, color: 'var(--color-regex)' }, - { tag: tags.function(tags.variableName), color: 'var(--color-function-call)' }, - { tag: tags.function(tags.invalid), color: 'white' }, - { - tag: tags.definition(tags.variableName), - color: 'var(--color-variable-def)', - backgroundColor: 'var(--bg-variable-def)', - padding: '1px 2px', - borderRadius: '2px', - fontWeight: '500', - }, -]) - -export const shrimpHighlighting = syntaxHighlighting(highlightStyle) - -export const shrimpTheme = EditorView.theme( - { - '&': { - color: 'var(--text-editor)', - backgroundColor: 'var(--bg-editor)', - height: '100%', - fontSize: '18px', - }, - '.cm-content': { - fontFamily: '"Pixeloid Mono", "Courier New", monospace', - caretColor: 'var(--caret)', - padding: '0px', - }, - '&.cm-focused .cm-cursor': { - borderLeftColor: 'var(--caret)', - }, - '&.cm-focused .cm-selectionBackground, ::selection': { - backgroundColor: 'var(--bg-selection)', - }, - '.cm-editor': { - border: 'none', - outline: 'none', - height: '100%', - }, - '.cm-matchingBracket': { - backgroundColor: 'var(--color-bool)', - }, - '.cm-nonmatchingBracket': { - backgroundColor: 'var(--color-string)', - }, - }, - { dark: true } -) diff --git a/src/editor/runCode.tsx b/src/editor/runCode.tsx deleted file mode 100644 index 1583b5f..0000000 --- a/src/editor/runCode.tsx +++ /dev/null @@ -1,38 +0,0 @@ -import { outputSignal, errorSignal } from '#editor/editor' -import { Compiler } from '#compiler/compiler' -import { errorMessage, log } from '#utils/utils' -import { bytecodeToString } from 'reefvm' -import { parser } from '#parser/shrimp' -import { sendToNose } from '#editor/noseClient' -import { treeToString } from '#utils/tree' - -export const runCode = async (input: string) => { - try { - const compiler = new Compiler(input) - sendToNose(compiler.bytecode) - } catch (error) { - log.error(error) - errorSignal.emit(`${errorMessage(error)}`) - } -} - -export const printParserOutput = (input: string) => { - try { - const cst = parser.parse(input) - const string = treeToString(cst, input) - outputSignal.emit(string) - } catch (error) { - log.error(error) - errorSignal.emit(`${errorMessage(error)}`) - } -} - -export const printBytecodeOutput = (input: string) => { - try { - const compiler = new Compiler(input) - outputSignal.emit(bytecodeToString(compiler.bytecode)) - } catch (error) { - log.error(error) - errorSignal.emit(`${errorMessage(error)}`) - } -} diff --git a/src/editor/server.tsx b/src/editor/server.tsx new file mode 100644 index 0000000..dbe8621 --- /dev/null +++ b/src/editor/server.tsx @@ -0,0 +1,14 @@ +import index from './index.html' + +const server = Bun.serve({ + port: 3000, + routes: { + '/': index, + }, + development: { + hmr: true, + console: true, + }, +}) + +console.log(`Editor running at ${server.url}`) diff --git a/src/editor/theme.ts b/src/editor/theme.ts new file mode 100644 index 0000000..af24780 --- /dev/null +++ b/src/editor/theme.ts @@ -0,0 +1,38 @@ +import { EditorView } from '@codemirror/view' + +export const shrimpTheme = EditorView.theme( + { + '&': { + color: 'var(--text-editor)', + backgroundColor: 'var(--bg-editor)', + height: '100%', + fontSize: '18px', + }, + '.cm-content': { + fontFamily: '"Pixeloid Mono", "Courier New", monospace', + caretColor: 'var(--caret)', + padding: '0px', + }, + '&.cm-focused .cm-cursor': { + borderLeftColor: 'var(--caret)', + }, + '&.cm-focused .cm-selectionBackground, ::selection': { + backgroundColor: 'var(--bg-selection)', + }, + '.cm-editor': { + border: 'none', + outline: 'none', + height: '100%', + }, + '.cm-matchingBracket': { + backgroundColor: 'var(--color-bool)', + }, + '.cm-nonmatchingBracket': { + backgroundColor: 'var(--color-string)', + }, + '.cm-activeLine': { + backgroundColor: 'var(--bg-active-line)', + }, + }, + { dark: true } +) diff --git a/src/index.ts b/src/index.ts index 6062fb3..922e47e 100644 --- a/src/index.ts +++ b/src/index.ts @@ -9,104 +9,105 @@ import { globals as prelude } from '#prelude' export { Compiler } from '#compiler/compiler' export { parse } from '#parser/parser2' export { type SyntaxNode, Tree } from '#parser/node' -export { globals as prelude } from '#prelude' export { type Value, type Bytecode } from 'reefvm' export { toValue, fromValue, isValue, Scope, VM, bytecodeToString } from 'reefvm' export class Shrimp { - vm: VM - private globals?: Record + vm: VM + private globals?: Record - constructor(globals?: Record) { - const emptyBytecode = { instructions: [], constants: [], labels: new Map() } - this.vm = new VM(emptyBytecode, Object.assign({}, prelude, globals ?? {})) - this.globals = globals + constructor(globals?: Record) { + const emptyBytecode = { instructions: [], constants: [], labels: new Map() } + this.vm = new VM(emptyBytecode, Object.assign({}, prelude, globals ?? {})) + this.globals = globals + } + + get(name: string): any { + const value = this.vm.scope.get(name) + return value ? fromValue(value, this.vm) : null + } + + set(name: string, value: any) { + this.vm.scope.set(name, toValue(value, this.vm)) + } + + has(name: string): boolean { + return this.vm.scope.has(name) + } + + async call(name: string, ...args: any[]): Promise { + const result = await this.vm.call(name, ...args) + return isValue(result) ? fromValue(result, this.vm) : result + } + + parse(code: string): Tree { + return parseCode(code, this.globals) + } + + compile(code: string): Bytecode { + return compileCode(code, this.globals) + } + + async run(code: string | Bytecode, locals?: Record): Promise { + let bytecode + + if (typeof code === 'string') { + const compiler = new Compiler( + code, + Object.keys(Object.assign({}, prelude, this.globals ?? {}, locals ?? {})) + ) + bytecode = compiler.bytecode + } else { + bytecode = code } - get(name: string): any { - const value = this.vm.scope.get(name) - return value ? fromValue(value, this.vm) : null - } - - set(name: string, value: any) { - this.vm.scope.set(name, toValue(value, this.vm)) - } - - has(name: string): boolean { - return this.vm.scope.has(name) - } - - async call(name: string, ...args: any[]): Promise { - const result = await this.vm.call(name, ...args) - return isValue(result) ? fromValue(result, this.vm) : result - } - - parse(code: string): Tree { - return parseCode(code, this.globals) - } - - compile(code: string): Bytecode { - return compileCode(code, this.globals) - } - - async run(code: string | Bytecode, locals?: Record): Promise { - let bytecode - - if (typeof code === 'string') { - const compiler = new Compiler(code, Object.keys(Object.assign({}, prelude, this.globals ?? {}, locals ?? {}))) - bytecode = compiler.bytecode - } else { - bytecode = code - } - - if (locals) this.vm.pushScope(locals) - this.vm.appendBytecode(bytecode) - await this.vm.continue() - if (locals) this.vm.popScope() - - return this.vm.stack.length ? fromValue(this.vm.stack.at(-1)!, this.vm) : null - } + if (locals) this.vm.pushScope(locals) + this.vm.appendBytecode(bytecode) + await this.vm.continue() + if (locals) this.vm.popScope() + return this.vm.stack.length ? fromValue(this.vm.stack.at(-1)!, this.vm) : null + } } export async function runFile(path: string, globals?: Record): Promise { - const code = readFileSync(path, 'utf-8') - return await runCode(code, globals) + const code = readFileSync(path, 'utf-8') + return await runCode(code, globals) } export async function runCode(code: string, globals?: Record): Promise { - return await runBytecode(compileCode(code, globals), globals) + return await runBytecode(compileCode(code, globals), globals) } export async function runBytecode(bytecode: Bytecode, globals?: Record): Promise { - const vm = new VM(bytecode, Object.assign({}, prelude, globals)) - await vm.run() - return vm.stack.length ? fromValue(vm.stack[vm.stack.length - 1]!, vm) : null + const vm = new VM(bytecode, Object.assign({}, prelude, globals)) + await vm.run() + return vm.stack.length ? fromValue(vm.stack[vm.stack.length - 1]!, vm) : null } export function compileFile(path: string, globals?: Record): Bytecode { - const code = readFileSync(path, 'utf-8') - return compileCode(code, globals) + const code = readFileSync(path, 'utf-8') + return compileCode(code, globals) } export function compileCode(code: string, globals?: Record): Bytecode { - const globalNames = [...Object.keys(prelude), ...(globals ? Object.keys(globals) : [])] - const compiler = new Compiler(code, globalNames) - return compiler.bytecode + const globalNames = [...Object.keys(prelude), ...(globals ? Object.keys(globals) : [])] + const compiler = new Compiler(code, globalNames) + return compiler.bytecode } export function parseFile(path: string, globals?: Record): Tree { - const code = readFileSync(path, 'utf-8') - return parseCode(code, globals) + const code = readFileSync(path, 'utf-8') + return parseCode(code, globals) } export function parseCode(code: string, globals?: Record): Tree { - const oldGlobals = [...parserGlobals] - const globalNames = [...Object.keys(prelude), ...(globals ? Object.keys(globals) : [])] + const oldGlobals = [...parserGlobals] + const globalNames = [...Object.keys(prelude), ...(globals ? Object.keys(globals) : [])] - setParserGlobals(globalNames) - const result = parse(code) - setParserGlobals(oldGlobals) + setParserGlobals(globalNames) + const result = parse(code) + setParserGlobals(oldGlobals) - return new Tree(result) -} \ No newline at end of file + return new Tree(result) +} diff --git a/src/parser/tokenizer2.ts b/src/parser/tokenizer2.ts index 4619c55..7b9886b 100644 --- a/src/parser/tokenizer2.ts +++ b/src/parser/tokenizer2.ts @@ -1,10 +1,10 @@ -const DEBUG = process.env.DEBUG || false +import { isDebug } from '#utils/utils' export type Token = { type: TokenType - value?: string, - from: number, - to: number, + value?: string + from: number + to: number } export enum TokenType { @@ -36,10 +36,16 @@ export enum TokenType { const valueTokens = new Set([ TokenType.Comment, - TokenType.Keyword, TokenType.Operator, - TokenType.Identifier, TokenType.Word, TokenType.NamedArgPrefix, - TokenType.Boolean, TokenType.Number, TokenType.String, TokenType.Regex, - TokenType.Underscore + TokenType.Keyword, + TokenType.Operator, + TokenType.Identifier, + TokenType.Word, + TokenType.NamedArgPrefix, + TokenType.Boolean, + TokenType.Number, + TokenType.String, + TokenType.Regex, + TokenType.Underscore, ]) const operators = new Set([ @@ -109,7 +115,7 @@ const keywords = new Set([ // helper function c(strings: TemplateStringsArray, ...values: any[]) { - return strings.reduce((result, str, i) => result + str + (values[i] ?? ""), "").charCodeAt(0) + return strings.reduce((result, str, i) => result + str + (values[i] ?? ''), '').charCodeAt(0) } function s(c: number): string { @@ -155,13 +161,19 @@ export class Scanner { to ??= this.pos - getCharSize(this.char) if (to < from) to = from - this.tokens.push(Object.assign({}, { - type, - from, - to, - }, valueTokens.has(type) ? { value: this.input.slice(from, to) } : {})) + this.tokens.push( + Object.assign( + {}, + { + type, + from, + to, + }, + valueTokens.has(type) ? { value: this.input.slice(from, to) } : {} + ) + ) - if (DEBUG) { + if (isDebug()) { const tok = this.tokens.at(-1) console.log(`≫ PUSH(${from},${to})`, TokenType[tok?.type || 0], '—', tok?.value) } @@ -238,8 +250,7 @@ export class Scanner { } if (char === c`\n`) { - if (this.inParen === 0 && this.inBracket === 0) - this.pushChar(TokenType.Newline) + if (this.inParen === 0 && this.inBracket === 0) this.pushChar(TokenType.Newline) this.next() continue } @@ -266,16 +277,20 @@ export class Scanner { switch (this.char) { case c`(`: this.inParen++ - this.pushChar(TokenType.OpenParen); break + this.pushChar(TokenType.OpenParen) + break case c`)`: this.inParen-- - this.pushChar(TokenType.CloseParen); break + this.pushChar(TokenType.CloseParen) + break case c`[`: this.inBracket++ - this.pushChar(TokenType.OpenBracket); break + this.pushChar(TokenType.OpenBracket) + break case c`]`: this.inBracket-- - this.pushChar(TokenType.CloseBracket); break + this.pushChar(TokenType.CloseBracket) + break } this.next() } @@ -339,29 +354,14 @@ export class Scanner { const word = this.input.slice(this.start, this.pos - getCharSize(this.char)) // classify the token based on what we read - if (word === '_') - this.push(TokenType.Underscore) - - else if (word === 'null') - this.push(TokenType.Null) - - else if (word === 'true' || word === 'false') - this.push(TokenType.Boolean) - - else if (isKeyword(word)) - this.push(TokenType.Keyword) - - else if (isOperator(word)) - this.push(TokenType.Operator) - - else if (isIdentifer(word)) - this.push(TokenType.Identifier) - - else if (word.endsWith('=')) - this.push(TokenType.NamedArgPrefix) - - else - this.push(TokenType.Word) + if (word === '_') this.push(TokenType.Underscore) + else if (word === 'null') this.push(TokenType.Null) + else if (word === 'true' || word === 'false') this.push(TokenType.Boolean) + else if (isKeyword(word)) this.push(TokenType.Keyword) + else if (isOperator(word)) this.push(TokenType.Operator) + else if (isIdentifer(word)) this.push(TokenType.Identifier) + else if (word.endsWith('=')) this.push(TokenType.NamedArgPrefix) + else this.push(TokenType.Word) } readNumber() { @@ -394,8 +394,7 @@ export class Scanner { this.next() // skip / // read regex flags - while (this.char > 0 && isIdentStart(this.char)) - this.next() + while (this.char > 0 && isIdentStart(this.char)) this.next() // validate regex const to = this.pos - getCharSize(this.char) @@ -422,30 +421,29 @@ export class Scanner { } canBeDotGet(lastToken?: Token): boolean { - return !this.prevIsWhitespace && !!lastToken && + return ( + !this.prevIsWhitespace && + !!lastToken && (lastToken.type === TokenType.Identifier || lastToken.type === TokenType.Number || lastToken.type === TokenType.CloseParen || lastToken.type === TokenType.CloseBracket) + ) } } const isNumber = (word: string): boolean => { // regular number - if (/^[+-]?\d+(_?\d+)*(\.(\d+(_?\d+)*))?$/.test(word)) - return true + if (/^[+-]?\d+(_?\d+)*(\.(\d+(_?\d+)*))?$/.test(word)) return true // binary - if (/^[+-]?0b[01]+(_?[01]+)*(\.[01](_?[01]*))?$/.test(word)) - return true + if (/^[+-]?0b[01]+(_?[01]+)*(\.[01](_?[01]*))?$/.test(word)) return true // octal - if (/^[+-]?0o[0-7]+(_?[0-7]+)*(\.[0-7](_?[0-7]*))?$/.test(word)) - return true + if (/^[+-]?0o[0-7]+(_?[0-7]+)*(\.[0-7](_?[0-7]*))?$/.test(word)) return true // hex - if (/^[+-]?0x[0-9a-f]+([0-9a-f]_?[0-9a-f]+)*(\.([0-9a-f]_?[0-9a-f]*))?$/i.test(word)) - return true + if (/^[+-]?0x[0-9a-f]+([0-9a-f]_?[0-9a-f]+)*(\.([0-9a-f]_?[0-9a-f]*))?$/i.test(word)) return true return false } @@ -461,14 +459,14 @@ const isIdentifer = (s: string): boolean => { chars.push(out) } - if (chars.length === 1) - return isIdentStart(chars[0]!) - else if (chars.length === 2) - return isIdentStart(chars[0]!) && isIdentEnd(chars[1]!) + if (chars.length === 1) return isIdentStart(chars[0]!) + else if (chars.length === 2) return isIdentStart(chars[0]!) && isIdentEnd(chars[1]!) else - return isIdentStart(chars[0]!) && + return ( + isIdentStart(chars[0]!) && chars.slice(1, chars.length - 1).every(isIdentChar) && isIdentEnd(chars.at(-1)!) + ) } const isStringDelim = (ch: number): boolean => { @@ -498,9 +496,14 @@ const isDigit = (ch: number): boolean => { } const isWhitespace = (ch: number): boolean => { - return ch === 32 /* space */ || ch === 9 /* tab */ || - ch === 13 /* \r */ || ch === 10 /* \n */ || - ch === -1 || ch === 0 /* EOF */ + return ( + ch === 32 /* space */ || + ch === 9 /* tab */ || + ch === 13 /* \r */ || + ch === 10 /* \n */ || + ch === -1 || + ch === 0 + ) /* EOF */ } const isWordChar = (ch: number): boolean => { @@ -527,8 +530,7 @@ const isBracket = (char: number): boolean => { return char === c`(` || char === c`)` || char === c`[` || char === c`]` } -const getCharSize = (ch: number) => - (ch > 0xffff ? 2 : 1) // emoji takes 2 UTF-16 code units +const getCharSize = (ch: number) => (ch > 0xffff ? 2 : 1) // emoji takes 2 UTF-16 code units const getFullCodePoint = (input: string, pos: number): number => { const ch = input[pos]?.charCodeAt(0) || 0 diff --git a/src/prelude/index.ts b/src/prelude/index.ts index c0fb87b..04a2ccb 100644 --- a/src/prelude/index.ts +++ b/src/prelude/index.ts @@ -2,8 +2,12 @@ import { join, resolve } from 'path' import { - type Value, type VM, toValue, - extractParamInfo, isWrapped, getOriginalFunction, + type Value, + type VM, + toValue, + extractParamInfo, + isWrapped, + getOriginalFunction, } from 'reefvm' import { date } from './date' @@ -15,6 +19,7 @@ import { list } from './list' import { math } from './math' import { str } from './str' import { types } from './types' +import { isServer } from '#utils/utils' export const globals: Record = { date, @@ -27,24 +32,38 @@ export const globals: Record = { str, // shrimp runtime info - $: { - args: Bun.argv.slice(3), - argv: Bun.argv.slice(1), - env: process.env, - pid: process.pid, - cwd: process.env.PWD, - script: { - name: Bun.argv[2] || '(shrimp)', - path: resolve(join('.', Bun.argv[2] ?? '')) - }, - }, + $: isServer + ? { + args: Bun.argv.slice(3), + argv: Bun.argv.slice(1), + env: process.env, + pid: process.pid, + cwd: process.env.PWD, + script: { + name: Bun.argv[2] || '(shrimp)', + path: resolve(join('.', Bun.argv[2] ?? '')), + }, + } + : { + args: [], + argv: [], + env: {}, + pid: 0, + cwd: '', + script: { + name: '', + path: '.', + }, + }, // hello echo: (...args: any[]) => { - console.log(...args.map(a => { - const v = toValue(a) - return ['array', 'dict'].includes(v.type) ? formatValue(v, true) : v.value - })) + console.log( + ...args.map((a) => { + const v = toValue(a) + return ['array', 'dict'].includes(v.type) ? formatValue(v, true) : v.value + }) + ) return toValue(null) }, @@ -63,11 +82,10 @@ export const globals: Record = { }, ref: (fn: Function) => fn, import: function (this: VM, atNamed: Record = {}, ...idents: string[]) { - const onlyArray = Array.isArray(atNamed.only) ? atNamed.only : [atNamed.only].filter(a => a) + const onlyArray = Array.isArray(atNamed.only) ? atNamed.only : [atNamed.only].filter((a) => a) const only = new Set(onlyArray) const wantsOnly = only.size > 0 - for (const ident of idents) { const module = this.get(ident) @@ -100,9 +118,13 @@ export const globals: Record = { length: (v: any) => { const value = toValue(v) switch (value.type) { - case 'string': case 'array': return value.value.length - case 'dict': return value.value.size - default: throw new Error(`length: expected string, array, or dict, got ${value.type}`) + case 'string': + case 'array': + return value.value.length + case 'dict': + return value.value.size + default: + throw new Error(`length: expected string, array, or dict, got ${value.type}`) } }, at: (collection: any, index: number | string) => { @@ -110,7 +132,9 @@ export const globals: Record = { if (value.type === 'string' || value.type === 'array') { const idx = typeof index === 'number' ? index : parseInt(index as string) if (idx < 0 || idx >= value.value.length) { - throw new Error(`at: index ${idx} out of bounds for ${value.type} of length ${value.value.length}`) + throw new Error( + `at: index ${idx} out of bounds for ${value.type} of length ${value.value.length}` + ) } return value.value[idx] } else if (value.type === 'dict') { @@ -137,7 +161,8 @@ export const globals: Record = { 'empty?': (v: any) => { const value = toValue(v) switch (value.type) { - case 'string': case 'array': + case 'string': + case 'array': return value.value.length === 0 case 'dict': return value.value.size === 0 @@ -151,7 +176,6 @@ export const globals: Record = { for (const value of list) await cb(value) return list }, - } export const colors = { @@ -164,7 +188,7 @@ export const colors = { red: '\x1b[31m', blue: '\x1b[34m', magenta: '\x1b[35m', - pink: '\x1b[38;2;255;105;180m' + pink: '\x1b[38;2;255;105;180m', } export function formatValue(value: Value, inner = false): string { @@ -178,15 +202,15 @@ export function formatValue(value: Value, inner = false): string { case 'null': return `${colors.dim}null${colors.reset}` case 'array': { - const items = value.value.map(x => formatValue(x, true)).join(' ') + const items = value.value.map((x) => formatValue(x, true)).join(' ') return `${colors.blue}[${colors.reset}${items}${colors.blue}]${colors.reset}` } case 'dict': { - const entries = Array.from(value.value.entries()).reverse() + const entries = Array.from(value.value.entries()) + .reverse() .map(([k, v]) => `${k.trim()}${colors.blue}=${colors.reset}${formatValue(v, true)}`) .join(' ') - if (entries.length === 0) - return `${colors.blue}[=]${colors.reset}` + if (entries.length === 0) return `${colors.blue}[=]${colors.reset}` return `${colors.blue}[${colors.reset}${entries}${colors.blue}]${colors.reset}` } case 'function': { @@ -206,5 +230,4 @@ export function formatValue(value: Value, inner = false): string { } // add types functions to top-level namespace -for (const [key, value] of Object.entries(types)) - globals[key] = value \ No newline at end of file +for (const [key, value] of Object.entries(types)) globals[key] = value diff --git a/src/server/app.tsx b/src/server/app.tsx deleted file mode 100644 index d5c088b..0000000 --- a/src/server/app.tsx +++ /dev/null @@ -1,10 +0,0 @@ -import { Editor } from '#/editor/editor' -import { render } from 'hono/jsx/dom' -import './index.css' - -const App = () => { - return -} - -const root = document.getElementById('root')! -render(, root) diff --git a/src/server/index.css b/src/server/index.css deleted file mode 100644 index 2719f41..0000000 --- a/src/server/index.css +++ /dev/null @@ -1,84 +0,0 @@ -:root { - /* Background colors */ - --bg-editor: #011627; - --bg-output: #40318D; - --bg-status-bar: #1E2A4A; - --bg-status-border: #0E1A3A; - --bg-selection: #1D3B53; - --bg-variable-def: #1E2A4A; - - /* Text colors */ - --text-editor: #D6DEEB; - --text-output: #7C70DA; - --text-status: #B3A9FF55; - --caret: #80A4C2; - - /* Syntax highlighting colors */ - --color-keyword: #C792EA; - --color-function: #82AAFF; - --color-string: #C3E88D; - --color-number: #F78C6C; - --color-bool: #FF5370; - --color-operator: #89DDFF; - --color-paren: #676E95; - --color-function-call: #FF9CAC; - --color-variable-def: #FFCB6B; - --color-error: #FF6E6E; - --color-regex: #E1ACFF; - - /* ANSI terminal colors */ - --ansi-black: #011627; - --ansi-red: #FF5370; - --ansi-green: #C3E88D; - --ansi-yellow: #FFCB6B; - --ansi-blue: #82AAFF; - --ansi-magenta: #C792EA; - --ansi-cyan: #89DDFF; - --ansi-white: #D6DEEB; - - /* ANSI bright colors (slightly more vibrant) */ - --ansi-bright-black: #676E95; - --ansi-bright-red: #FF6E90; - --ansi-bright-green: #D4F6A8; - --ansi-bright-yellow: #FFE082; - --ansi-bright-blue: #A8C7FA; - --ansi-bright-magenta: #E1ACFF; - --ansi-bright-cyan: #A8F5FF; - --ansi-bright-white: #FFFFFF; -} - -@font-face { - font-family: 'C64ProMono'; - src: url('../../assets/C64_Pro_Mono-STYLE.woff2') format('woff2'); - font-weight: normal; - font-style: normal; -} - -@font-face { - font-family: 'Pixeloid Mono'; - src: url('../../assets/PixeloidMono.ttf') format('truetype'); - font-weight: normal; - font-style: normal; -} - -* { - margin: 0; - padding: 0; - box-sizing: border-box; -} - -body { - background: var(--bg-output); - color: var(--text-output); - font-family: 'Pixeloid Mono', 'Courier New', monospace; - font-size: 18px; - height: 100vh; - overflow: hidden; -} - -#root { - height: 100vh; - background: var(--bg-output); - display: flex; - flex-direction: column; -} \ No newline at end of file diff --git a/src/server/index.html b/src/server/index.html deleted file mode 100644 index 2f4d226..0000000 --- a/src/server/index.html +++ /dev/null @@ -1,12 +0,0 @@ - - - - - - Shrimp - - -
- - - diff --git a/src/server/server.tsx b/src/server/server.tsx deleted file mode 100644 index 1c7291d..0000000 --- a/src/server/server.tsx +++ /dev/null @@ -1,29 +0,0 @@ -import index from './index.html' - -const server = Bun.serve({ - port: process.env.PORT ? Number(process.env.PORT) : 3001, - routes: { - '/*': index, - - '/api/hello': { - async GET(req) { - return Response.json({ - message: 'Hello, world!', - method: 'GET', - }) - }, - async PUT(req) { - return Response.json({ - message: 'Hello, world!', - method: 'PUT', - }) - }, - }, - }, - development: process.env.NODE_ENV !== 'production' && { - hmr: true, - console: true, - }, -}) - -console.log(`🚀 Server running at ${server.url}`) diff --git a/src/utils/signal.ts b/src/utils/signal.ts deleted file mode 100644 index d9629a4..0000000 --- a/src/utils/signal.ts +++ /dev/null @@ -1,68 +0,0 @@ -/** - * How to use a Signal: - * - * Create a signal with primitives: - * const nameSignal = new Signal() - * const countSignal = new Signal() - * - * Create a signal with objects: - * const chatSignal = new Signal<{ username: string, message: string }>() - * - * Create a signal with no data (void): - * const clickSignal = new Signal() - * const clickSignal2 = new Signal() // Defaults to void - * - * Connect to the signal: - * const disconnect = chatSignal.connect((data) => { - * const {username, message} = data; - * console.log(`${username} said "${message}"`); - * }) - * - * Emit a signal: - * nameSignal.emit("Alice") - * countSignal.emit(42) - * chatSignal.emit({ username: "Chad", message: "Hey everyone, how's it going?" }); - * clickSignal.emit() // No argument for void signals - * - * 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 index a33cef5..a155fec 100644 --- a/src/utils/utils.tsx +++ b/src/utils/utils.tsx @@ -136,3 +136,13 @@ export const asciiEscapeToHtml = (str: string): HtmlEscapedString => { return result as HtmlEscapedString } + +export const isDebug = (): boolean => { + if (typeof process !== 'undefined' && process.env) { + return !!process.env.DEBUG + } + return false +} + +export const isBrowser = typeof window !== 'undefined' +export const isServer = typeof process !== 'undefined' From 04c2137fe24ff5d4e9eef7a2c5c806339159e8a5 Mon Sep 17 00:00:00 2001 From: Corey Johnson Date: Mon, 5 Jan 2026 11:30:02 -0800 Subject: [PATCH 2/6] Use isDebug --- src/parser/tokenizer2.ts | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/src/parser/tokenizer2.ts b/src/parser/tokenizer2.ts index a5d4a89..7b9886b 100644 --- a/src/parser/tokenizer2.ts +++ b/src/parser/tokenizer2.ts @@ -1,4 +1,4 @@ -const DEBUG = process.env.DEBUG || false +import { isDebug } from '#utils/utils' export type Token = { type: TokenType @@ -169,11 +169,11 @@ export class Scanner { from, to, }, - valueTokens.has(type) ? { value: this.input.slice(from, to) } : {}, - ), + valueTokens.has(type) ? { value: this.input.slice(from, to) } : {} + ) ) - if (DEBUG) { + if (isDebug()) { const tok = this.tokens.at(-1) console.log(`≫ PUSH(${from},${to})`, TokenType[tok?.type || 0], '—', tok?.value) } From 82722ec9e4f24f7b415a28f8d3662ea21f7f7b75 Mon Sep 17 00:00:00 2001 From: Corey Johnson Date: Mon, 5 Jan 2026 11:30:23 -0800 Subject: [PATCH 3/6] Update exports and editor script in package.json Refactored the exports field to provide explicit entry points for the main module, editor, and editor CSS. Updated the editor script to point to the example server file. --- package.json | 10 +++++++--- 1 file changed, 7 insertions(+), 3 deletions(-) diff --git a/package.json b/package.json index 001c0b3..3bd935d 100644 --- a/package.json +++ b/package.json @@ -1,11 +1,15 @@ { "name": "shrimp", "version": "0.1.0", - "exports": "./src/index.ts", + "exports": { + ".": "./src/index.ts", + "./editor": "./src/editor/index.ts", + "./editor.css": "./src/editor/editor.css" + }, "private": true, "type": "module", "scripts": { - "editor": "bun --hot src/editor/server.tsx", + "editor": "bun --hot src/editor/example/server.tsx", "repl": "bun bin/repl", "update-reef": "rm -rf ~/.bun/install/cache/ && rm bun.lock && bun update reefvm", "cli:install": "ln -s \"$(pwd)/bin/shrimp\" ~/.bun/bin/shrimp", @@ -33,4 +37,4 @@ "singleQuote": true, "printWidth": 100 } -} +} \ No newline at end of file From 2c7508c2a460d0341d943dce62b3091e0444c157 Mon Sep 17 00:00:00 2001 From: Corey Johnson Date: Mon, 5 Jan 2026 11:30:32 -0800 Subject: [PATCH 4/6] Better edtior --- src/editor/completions.ts | 104 +++++++++++++++++++++++++++- src/editor/diagnostics.ts | 35 +++++----- src/editor/editor.css | 78 +++++++++++++++++++++ src/editor/editor.tsx | 64 +++++++++++++---- src/editor/example/frontend.tsx | 6 ++ src/editor/example/index.html | 11 +++ src/editor/{ => example}/server.tsx | 0 src/editor/highlighter.ts | 19 +++-- src/editor/index.html | 23 ------ src/editor/index.ts | 1 + src/editor/keymap.ts | 8 +++ src/editor/persistence.ts | 6 +- src/editor/theme.ts | 17 ++++- 13 files changed, 301 insertions(+), 71 deletions(-) create mode 100644 src/editor/example/frontend.tsx create mode 100644 src/editor/example/index.html rename src/editor/{ => example}/server.tsx (100%) delete mode 100644 src/editor/index.html create mode 100644 src/editor/index.ts create mode 100644 src/editor/keymap.ts diff --git a/src/editor/completions.ts b/src/editor/completions.ts index 5a8db2e..5a0fa91 100644 --- a/src/editor/completions.ts +++ b/src/editor/completions.ts @@ -1,2 +1,102 @@ -// Autocomplete for Shrimp -// TODO: Keywords and prelude function names +import { autocompletion, type CompletionContext, type Completion } from '@codemirror/autocomplete' +import { Shrimp, type Value } from '#/index' + +const keywords = [ + 'import', + 'end', + 'do', + 'if', + 'else', + 'while', + 'try', + 'catch', + 'finally', + 'throw', + 'not', + 'and', + 'or', + 'true', + 'false', + 'null', +] + +const keywordCompletions: Completion[] = keywords.map((k) => ({ + label: k, + type: 'keyword', + boost: -1, +})) + +const buildFunctionCompletion = (name: string, value: unknown): Completion => { + let detail: string | undefined + + console.log(`🌭`, { name, fn: value?.toString() }) + + // console.log(`🌭`, { name, value }) + + // const isFunction = value?.type === 'function' + // if (isFunction) { + // const paramStrs = value.params.map((p) => (p in value.defaults ? `${p}?` : p)) + + // if (value.variadic && paramStrs.length > 0) { + // paramStrs[paramStrs.length - 1] = `...${paramStrs[paramStrs.length - 1]}` + // } + + // detail = `(${paramStrs.join(', ')})` + // } + + return { + label: name, + type: 'function', + detail, + } +} + +export const createShrimpCompletions = (shrimp: Shrimp) => { + // Build completions from all names in the shrimp scope + const scopeNames = shrimp.vm.scope.vars() + const functionCompletions: Completion[] = scopeNames.map((name) => + buildFunctionCompletion(name, shrimp.get(name)) + ) + + const allCompletions = [...keywordCompletions, ...functionCompletions] + + // Get methods for a module (e.g., math, str, list) + const getModuleMethods = (moduleName: string): Completion[] => { + const module = shrimp.get(moduleName) + if (!module || module.type !== 'dict') return [] + + return Array.from(module.value.keys()).map((name) => + buildFunctionCompletion(name, module.value.get(name)) + ) + } + + const shrimpCompletionSource = (context: CompletionContext) => { + // Check for module.method pattern (e.g., "math.") + const dotMatch = context.matchBefore(/[\w\-]+\.[\w\-\?]*/) + if (dotMatch) { + const [moduleName, methodPrefix] = dotMatch.text.split('.') + const methods = getModuleMethods(moduleName!) + + if (methods.length > 0) { + const dotPos = dotMatch.from + moduleName!.length + 1 + return { + from: dotPos, + options: methods.filter((m) => m.label.startsWith(methodPrefix ?? '')), + } + } + } + + // Regular completions + const word = context.matchBefore(/[\w\-\?\$]+/) + if (!word || (word.from === word.to && !context.explicit)) return null + + return { + from: word.from, + options: allCompletions.filter((c) => c.label.startsWith(word.text)), + } + } + + return autocompletion({ + override: [shrimpCompletionSource], + }) +} diff --git a/src/editor/diagnostics.ts b/src/editor/diagnostics.ts index fe9af90..18c3a86 100644 --- a/src/editor/diagnostics.ts +++ b/src/editor/diagnostics.ts @@ -2,24 +2,23 @@ import { linter, type Diagnostic } from '@codemirror/lint' import { Shrimp } from '#/index' import { CompilerError } from '#compiler/compilerError' -const shrimp = new Shrimp() +export const createShrimpDiagnostics = (shrimp: Shrimp) => + linter((view) => { + const code = view.state.doc.toString() + const diagnostics: Diagnostic[] = [] -export const shrimpDiagnostics = linter((view) => { - const code = view.state.doc.toString() - const diagnostics: Diagnostic[] = [] - - try { - shrimp.parse(code) - } catch (err) { - if (err instanceof CompilerError) { - diagnostics.push({ - from: err.from, - to: err.to, - severity: 'error', - message: err.message, - }) + try { + shrimp.parse(code) + } catch (err) { + if (err instanceof CompilerError) { + diagnostics.push({ + from: err.from, + to: err.to, + severity: 'error', + message: err.message, + }) + } } - } - return diagnostics -}) + return diagnostics + }) diff --git a/src/editor/editor.css b/src/editor/editor.css index e69de29..84282f8 100644 --- a/src/editor/editor.css +++ b/src/editor/editor.css @@ -0,0 +1,78 @@ +:root { + /* Background colors */ + --bg-editor: #011627; + --bg-output: #40318D; + --bg-status-bar: #1E2A4A; + --bg-status-border: #0E1A3A; + --bg-selection: #1D3B53; + + /* Text colors */ + --text-editor: #D6DEEB; + --text-output: #7C70DA; + --text-status: #B3A9FF55; + --caret: #80A4C2; + + /* Syntax highlighting colors */ + --color-keyword: #C792EA; + --color-function: #82AAFF; + --color-string: #C3E88D; + --color-number: #F78C6C; + --color-bool: #FF5370; + --color-operator: #89DDFF; + --color-paren: #676E95; + --color-function-call: #FF9CAC; + --color-variable-def: #FFCB6B; + --color-error: #FF6E6E; + --color-regex: #E1ACFF; + + /* ANSI terminal colors */ + --ansi-black: #011627; + --ansi-red: #FF5370; + --ansi-green: #C3E88D; + --ansi-yellow: #FFCB6B; + --ansi-blue: #82AAFF; + --ansi-magenta: #C792EA; + --ansi-cyan: #89DDFF; + --ansi-white: #D6DEEB; + + /* ANSI bright colors (slightly more vibrant) */ + --ansi-bright-black: #676E95; + --ansi-bright-red: #FF6E90; + --ansi-bright-green: #D4F6A8; + --ansi-bright-yellow: #FFE082; + --ansi-bright-blue: #A8C7FA; + --ansi-bright-magenta: #E1ACFF; + --ansi-bright-cyan: #A8F5FF; + --ansi-bright-white: #FFFFFF; +} + +@font-face { + font-family: 'C64ProMono'; + src: url('../../assets/C64_Pro_Mono-STYLE.woff2') format('woff2'); + font-weight: normal; + font-style: normal; +} + +@font-face { + font-family: 'Pixeloid Mono'; + src: url('../../assets/PixeloidMono.ttf') format('truetype'); + font-weight: normal; + font-style: normal; +} + +* { + margin: 0; + padding: 0; + box-sizing: border-box; +} + +body { + background-color: var(--bg-editor); + font-family: 'Pixeloid Mono', 'Courier New', monospace; + font-size: 18px; + height: 100vh; +} + +#root { + height: 100vh; +} \ No newline at end of file diff --git a/src/editor/editor.tsx b/src/editor/editor.tsx index 19ee5b7..273fe34 100644 --- a/src/editor/editor.tsx +++ b/src/editor/editor.tsx @@ -1,30 +1,70 @@ import { EditorView, basicSetup } from 'codemirror' -import { render } from 'hono/jsx/dom' +import { Shrimp } from '#/index' import { shrimpTheme } from './theme' -import { shrimpDiagnostics } from './diagnostics' -import { persistencePlugin, getContent } from './persistence' +import { createShrimpDiagnostics } from './diagnostics' import { shrimpHighlighter } from './highlighter' +import { createShrimpCompletions } from './completions' +import { shrimpKeymap } from './keymap' +import { getContent, persistence } from './persistence' -export const Editor = () => { +type EditorProps = { + initialCode?: string + onChange?: (code: string) => void + extensions?: import('@codemirror/state').Extension[] + shrimp?: Shrimp +} + +export const Editor = ({ + initialCode = '', + onChange, + extensions: customExtensions = [], + shrimp = new Shrimp(), +}: EditorProps) => { return (
{ - if (!el?.querySelector('.cm-editor')) createEditorView(el) + if (!el?.querySelector('.cm-editor')) + createEditorView(el, getContent() ?? initialCode, onChange, customExtensions, shrimp) }} /> ) } -const createEditorView = (el: Element) => { +const createEditorView = ( + el: Element, + initialCode: string, + onChange: ((code: string) => void) | undefined, + customExtensions: import('@codemirror/state').Extension[], + shrimp: Shrimp +) => { + const extensions = [ + basicSetup, + shrimpTheme, + createShrimpDiagnostics(shrimp), + createShrimpCompletions(shrimp), + shrimpHighlighter, + shrimpKeymap, + persistence, + ...customExtensions, + ] + + if (onChange) { + extensions.push( + EditorView.updateListener.of((update) => { + if (update.docChanged) { + onChange(update.state.doc.toString()) + } + }) + ) + } + new EditorView({ parent: el, - doc: getContent() || '# type some code\necho hello', - extensions: [basicSetup, shrimpTheme, shrimpDiagnostics, persistencePlugin, shrimpHighlighter], + doc: initialCode, + extensions, }) -} -// Mount when running in browser -if (typeof document !== 'undefined') { - render(, document.getElementById('root')!) + // Trigger onChange with initial content + onChange?.(initialCode) } diff --git a/src/editor/example/frontend.tsx b/src/editor/example/frontend.tsx new file mode 100644 index 0000000..da34265 --- /dev/null +++ b/src/editor/example/frontend.tsx @@ -0,0 +1,6 @@ +import { Editor } from '#editor/editor' +import { render } from 'hono/jsx/dom' + +if (typeof document !== 'undefined') { + render(, document.getElementById('root')!) +} diff --git a/src/editor/example/index.html b/src/editor/example/index.html new file mode 100644 index 0000000..8ef8eba --- /dev/null +++ b/src/editor/example/index.html @@ -0,0 +1,11 @@ + + + + + + + +
+ + + diff --git a/src/editor/server.tsx b/src/editor/example/server.tsx similarity index 100% rename from src/editor/server.tsx rename to src/editor/example/server.tsx diff --git a/src/editor/highlighter.ts b/src/editor/highlighter.ts index e561bc4..93e1f3d 100644 --- a/src/editor/highlighter.ts +++ b/src/editor/highlighter.ts @@ -2,6 +2,7 @@ import { EditorView, Decoration, DecorationSet, ViewPlugin, ViewUpdate } from '@ import { RangeSetBuilder } from '@codemirror/state' import { Shrimp } from '#/index' import { type SyntaxNode } from '#parser/node' +import { log } from '#utils/utils' const shrimp = new Shrimp() @@ -14,9 +15,9 @@ export const shrimpHighlighter = ViewPlugin.fromClass( } update(update: ViewUpdate) { - if (update.docChanged) { - this.decorations = this.highlight(update.view) - } + if (!update.docChanged) return + + this.decorations = this.highlight(update.view) } highlight(view: EditorView): DecorationSet { @@ -30,7 +31,8 @@ export const shrimpHighlighter = ViewPlugin.fromClass( tree.iterate({ enter: (node) => { const cls = tokenStyles[node.type.name] - if (cls && isLeaf(node)) { + const isLeaf = node.children.length === 0 + if (cls && isLeaf) { decorations.push({ from: node.from, to: node.to, class: cls }) } }, @@ -42,8 +44,8 @@ export const shrimpHighlighter = ViewPlugin.fromClass( for (const d of decorations) { builder.add(d.from, d.to, Decoration.mark({ class: d.class })) } - } catch { - // Parse failed, no highlighting + } catch (error) { + log('Parsing error in highlighter', error) } return builder.finish() @@ -69,8 +71,3 @@ const tokenStyles: Record = { operator: 'tok-operator', Regex: 'tok-regex', } - -// Check if node is a leaf (should be highlighted) -const isLeaf = (node: SyntaxNode): boolean => { - return node.children.length === 0 -} diff --git a/src/editor/index.html b/src/editor/index.html deleted file mode 100644 index f2129e5..0000000 --- a/src/editor/index.html +++ /dev/null @@ -1,23 +0,0 @@ - - - - - - -
- - - - diff --git a/src/editor/index.ts b/src/editor/index.ts new file mode 100644 index 0000000..eac45fe --- /dev/null +++ b/src/editor/index.ts @@ -0,0 +1 @@ +export { Editor } from './editor' diff --git a/src/editor/keymap.ts b/src/editor/keymap.ts new file mode 100644 index 0000000..7a3e2e7 --- /dev/null +++ b/src/editor/keymap.ts @@ -0,0 +1,8 @@ +import { keymap } from '@codemirror/view' +import { acceptCompletion } from '@codemirror/autocomplete' +import { indentWithTab } from '@codemirror/commands' + +export const shrimpKeymap = keymap.of([ + { key: 'Tab', run: acceptCompletion }, + indentWithTab, +]) diff --git a/src/editor/persistence.ts b/src/editor/persistence.ts index 4654593..d729b26 100644 --- a/src/editor/persistence.ts +++ b/src/editor/persistence.ts @@ -1,6 +1,6 @@ import { ViewPlugin, ViewUpdate } from '@codemirror/view' -export const persistencePlugin = ViewPlugin.fromClass( +export const persistence = ViewPlugin.fromClass( class { saveTimeout?: ReturnType @@ -17,13 +17,13 @@ export const persistencePlugin = ViewPlugin.fromClass( destroy() { if (this.saveTimeout) clearTimeout(this.saveTimeout) } - }, + } ) export const getContent = () => { return localStorage.getItem('shrimp-editor-content') || '' } -const setContent = (data: string) => { +export const setContent = (data: string) => { localStorage.setItem('shrimp-editor-content', data) } diff --git a/src/editor/theme.ts b/src/editor/theme.ts index af24780..5420320 100644 --- a/src/editor/theme.ts +++ b/src/editor/theme.ts @@ -12,11 +12,12 @@ export const shrimpTheme = EditorView.theme( fontFamily: '"Pixeloid Mono", "Courier New", monospace', caretColor: 'var(--caret)', padding: '0px', + borderTop: '1px solid var(--bg-editor)', }, '&.cm-focused .cm-cursor': { borderLeftColor: 'var(--caret)', }, - '&.cm-focused .cm-selectionBackground, ::selection': { + '.cm-selectionLayer .cm-selectionBackground': { backgroundColor: 'var(--bg-selection)', }, '.cm-editor': { @@ -31,8 +32,20 @@ export const shrimpTheme = EditorView.theme( backgroundColor: 'var(--color-string)', }, '.cm-activeLine': { - backgroundColor: 'var(--bg-active-line)', + backgroundColor: 'rgba(255, 255, 255, 0.03)', }, + + // Token highlighting + '.tok-keyword': { color: 'var(--color-keyword)' }, + '.tok-string': { color: 'var(--color-string)' }, + '.tok-number': { color: 'var(--color-number)' }, + '.tok-bool': { color: 'var(--color-bool)' }, + '.tok-null': { color: 'var(--color-number)', fontStyle: 'italic' }, + '.tok-identifier': { color: 'var(--color-function)' }, + '.tok-variable-def': { color: 'var(--color-variable-def)' }, + '.tok-comment': { color: 'var(--ansi-bright-black)', fontStyle: 'italic' }, + '.tok-operator': { color: 'var(--color-operator)' }, + '.tok-regex': { color: 'var(--color-regex)' }, }, { dark: true } ) From d1b16ce3e12647ad435e91e31f382b7d246ad922 Mon Sep 17 00:00:00 2001 From: Corey Johnson Date: Mon, 5 Jan 2026 13:34:18 -0800 Subject: [PATCH 5/6] Simplify function completions in completions.ts Removed the buildFunctionCompletion helper and inlined function completion creation for both scope and module methods. Updated module method extraction to use Object.keys for compatibility with the new module structure. --- src/editor/completions.ts | 51 ++++++++++++++------------------------- 1 file changed, 18 insertions(+), 33 deletions(-) diff --git a/src/editor/completions.ts b/src/editor/completions.ts index 5a0fa91..a090ac5 100644 --- a/src/editor/completions.ts +++ b/src/editor/completions.ts @@ -1,5 +1,5 @@ import { autocompletion, type CompletionContext, type Completion } from '@codemirror/autocomplete' -import { Shrimp, type Value } from '#/index' +import { Shrimp } from '#/index' const keywords = [ 'import', @@ -26,48 +26,33 @@ const keywordCompletions: Completion[] = keywords.map((k) => ({ boost: -1, })) -const buildFunctionCompletion = (name: string, value: unknown): Completion => { - let detail: string | undefined - - console.log(`🌭`, { name, fn: value?.toString() }) - - // console.log(`🌭`, { name, value }) - - // const isFunction = value?.type === 'function' - // if (isFunction) { - // const paramStrs = value.params.map((p) => (p in value.defaults ? `${p}?` : p)) - - // if (value.variadic && paramStrs.length > 0) { - // paramStrs[paramStrs.length - 1] = `...${paramStrs[paramStrs.length - 1]}` - // } - - // detail = `(${paramStrs.join(', ')})` - // } - - return { - label: name, - type: 'function', - detail, - } -} - export const createShrimpCompletions = (shrimp: Shrimp) => { // Build completions from all names in the shrimp scope const scopeNames = shrimp.vm.scope.vars() - const functionCompletions: Completion[] = scopeNames.map((name) => - buildFunctionCompletion(name, shrimp.get(name)) - ) + console.log(`🌭`, shrimp.vm.vars()) + + const functionCompletions: Completion[] = scopeNames.map((name) => { + const value = shrimp.vm.scope.get(name) + let type + if (value?.type === 'function' || value?.type === 'native') { + type = 'function' + } else if (value?.type === 'dict') { + type = 'namespace' + } else { + type = 'variable' + } + + return { type, label: name } + }) const allCompletions = [...keywordCompletions, ...functionCompletions] // Get methods for a module (e.g., math, str, list) const getModuleMethods = (moduleName: string): Completion[] => { const module = shrimp.get(moduleName) - if (!module || module.type !== 'dict') return [] + if (!module || typeof module !== 'object') return [] - return Array.from(module.value.keys()).map((name) => - buildFunctionCompletion(name, module.value.get(name)) - ) + return Object.keys(module).map((m) => ({ label: m, type: 'function' })) } const shrimpCompletionSource = (context: CompletionContext) => { From 23c8d4822a11088f24ef800d788d7688d0c73065 Mon Sep 17 00:00:00 2001 From: Corey Johnson Date: Mon, 5 Jan 2026 13:38:11 -0800 Subject: [PATCH 6/6] better naming --- src/prelude/index.ts | 25 ++++++++++++------------- src/utils/utils.tsx | 3 +-- 2 files changed, 13 insertions(+), 15 deletions(-) diff --git a/src/prelude/index.ts b/src/prelude/index.ts index 04a2ccb..cada59d 100644 --- a/src/prelude/index.ts +++ b/src/prelude/index.ts @@ -19,7 +19,7 @@ import { list } from './list' import { math } from './math' import { str } from './str' import { types } from './types' -import { isServer } from '#utils/utils' +import { runningInBrowser } from '#utils/utils' export const globals: Record = { date, @@ -32,8 +32,18 @@ export const globals: Record = { str, // shrimp runtime info - $: isServer + $: runningInBrowser ? { + args: [], + argv: [], + env: {}, + pid: 0, + cwd: '', + script: { + name: '', + path: '.', + }, + } : { args: Bun.argv.slice(3), argv: Bun.argv.slice(1), env: process.env, @@ -44,17 +54,6 @@ export const globals: Record = { path: resolve(join('.', Bun.argv[2] ?? '')), }, } - : { - args: [], - argv: [], - env: {}, - pid: 0, - cwd: '', - script: { - name: '', - path: '.', - }, - }, // hello echo: (...args: any[]) => { diff --git a/src/utils/utils.tsx b/src/utils/utils.tsx index a155fec..9fe6749 100644 --- a/src/utils/utils.tsx +++ b/src/utils/utils.tsx @@ -144,5 +144,4 @@ export const isDebug = (): boolean => { return false } -export const isBrowser = typeof window !== 'undefined' -export const isServer = typeof process !== 'undefined' +export const runningInBrowser = typeof window !== 'undefined'