From ff58f2b8ae4d5dccc246d73b4e2eece7dce6080c Mon Sep 17 00:00:00 2001 From: Corey Johnson Date: Wed, 17 Dec 2025 09:55:37 -0800 Subject: [PATCH] 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'