OMG hairy

This commit is contained in:
Corey Johnson 2025-10-02 14:05:17 -07:00
parent 0168d7f933
commit d89130b169
25 changed files with 1311 additions and 370 deletions

247
src/editor/commands.ts Normal file
View File

@ -0,0 +1,247 @@
export type CommandShape = {
command: string
description?: string
args: ArgShape[]
}
type ArgShape<T extends keyof ArgTypeMap = keyof ArgTypeMap> =
| {
name: string
type: T
description?: string
named?: false
}
| {
name: string
type: T
description?: string
named: true
default: ArgTypeMap[T]
}
type ArgTypeMap = {
string: string
number: number
boolean: boolean
}
const commandShapes: CommandShape[] = [
{
command: 'ls',
description: 'List the contents of a directory',
args: [
{ name: 'path', type: 'string', description: 'The path to list' },
{ name: 'all', type: 'boolean', description: 'Show hidden files', default: false },
{ name: 'long', type: 'boolean', description: 'List in long format', default: false },
{
name: 'short-names',
type: 'boolean',
description: 'Only print file names',
default: false,
},
{ name: 'full-paths', type: 'boolean', description: 'Display full paths', default: false },
],
},
{
command: 'cd',
description: 'Change the current working directory',
args: [{ name: 'path', type: 'string', description: 'The path to change to' }],
},
{
command: 'cp',
description: 'Copy files or directories',
args: [
{ name: 'source', type: 'string', description: 'Source file or directory' },
{ name: 'destination', type: 'string', description: 'Destination path' },
{ name: 'recursive', type: 'boolean', description: 'Copy recursively', default: false },
{ name: 'verbose', type: 'boolean', description: 'Verbose output', default: false },
],
},
{
command: 'mv',
description: 'Move files or directories',
args: [
{ name: 'source', type: 'string', description: 'Source file or directory' },
{ name: 'destination', type: 'string', description: 'Destination path' },
{ name: 'verbose', type: 'boolean', description: 'Verbose output', default: false },
],
},
{
command: 'rm',
description: 'Remove files or directories',
args: [
{ name: 'path', type: 'string', description: 'Path to remove' },
{ name: 'recursive', type: 'boolean', description: 'Remove recursively', default: false },
{ name: 'force', type: 'boolean', description: 'Force removal', default: false },
{ name: 'verbose', type: 'boolean', description: 'Verbose output', default: false },
],
},
{
command: 'mkdir',
description: 'Create directories',
args: [
{ name: 'path', type: 'string', description: 'Directory path to create' },
{ name: 'verbose', type: 'boolean', description: 'Verbose output', default: false },
],
},
{
command: 'touch',
description: 'Create empty files or update timestamps',
args: [
{ name: 'path', type: 'string', description: 'File path to touch' },
{ name: 'access', type: 'boolean', description: 'Update access time only', default: false },
{
name: 'modified',
type: 'boolean',
description: 'Update modified time only',
default: false,
},
],
},
{
command: 'echo',
description: 'Display a string',
args: [
{ name: 'text', type: 'string', description: 'Text to display' },
{ name: 'no-newline', type: 'boolean', description: "Don't append newline", default: false },
],
},
{
command: 'cat',
description: 'Display file contents',
args: [
{ name: 'path', type: 'string', description: 'File to display' },
{ name: 'numbered', type: 'boolean', description: 'Show line numbers', default: false },
],
},
{
command: 'head',
description: 'Show first lines of input',
args: [
{ name: 'path', type: 'string', description: 'File to read from' },
{ name: 'lines', type: 'number', description: 'Number of lines', default: 10 },
],
},
{
command: 'tail',
description: 'Show last lines of input',
args: [
{ name: 'path', type: 'string', description: 'File to read from' },
{ name: 'lines', type: 'number', description: 'Number of lines', default: 10 },
{ name: 'follow', type: 'boolean', description: 'Follow file changes', default: false },
],
},
{
command: 'grep',
description: 'Search for patterns in text',
args: [
{ name: 'pattern', type: 'string', description: 'Pattern to search for' },
{
name: 'ignore-case',
type: 'boolean',
description: 'Case insensitive search',
default: false,
},
{ name: 'invert-match', type: 'boolean', description: 'Invert match', default: false },
{ name: 'line-number', type: 'boolean', description: 'Show line numbers', default: false },
],
},
{
command: 'sort',
description: 'Sort input',
args: [
{ name: 'reverse', type: 'boolean', description: 'Sort in reverse order', default: false },
{
name: 'ignore-case',
type: 'boolean',
description: 'Case insensitive sort',
default: false,
},
{ name: 'numeric', type: 'boolean', description: 'Numeric sort', default: false },
],
},
{
command: 'uniq',
description: 'Filter out repeated lines',
args: [
{ name: 'count', type: 'boolean', description: 'Show count of occurrences', default: false },
{
name: 'repeated',
type: 'boolean',
description: 'Show only repeated lines',
default: false,
},
{ name: 'unique', type: 'boolean', description: 'Show only unique lines', default: false },
],
},
{
command: 'select',
description: 'Select specific columns from data',
args: [{ name: 'columns', type: 'string', description: 'Columns to select' }],
},
{
command: 'where',
description: 'Filter data based on conditions',
args: [{ name: 'condition', type: 'string', description: 'Filter condition' }],
},
{
command: 'group-by',
description: 'Group data by column values',
args: [{ name: 'column', type: 'string', description: 'Column to group by' }],
},
{
command: 'ps',
description: 'List running processes',
args: [
{ name: 'long', type: 'boolean', description: 'Show detailed information', default: false },
],
},
{
command: 'sys',
description: 'Show system information',
args: [],
},
{
command: 'which',
description: 'Find the location of a command',
args: [
{ name: 'command', type: 'string', description: 'Command to locate' },
{ name: 'all', type: 'boolean', description: 'Show all matches', default: false },
],
},
] as const
let commandSource = () => commandShapes
export const setCommandSource = (fn: () => CommandShape[]) => {
commandSource = fn
}
export const resetCommandSource = () => {
commandSource = () => commandShapes
}
export const matchingCommands = (prefix: string) => {
const match = commandSource().find((cmd) => cmd.command === prefix)
const partialMatches = commandSource().filter((cmd) => cmd.command.startsWith(prefix))
return { match, partialMatches }
}

58
src/editor/editor.tsx Normal file
View File

@ -0,0 +1,58 @@
import { basicSetup } from 'codemirror'
import { EditorView } from '@codemirror/view'
import { shrimpTheme } from '#editor/theme'
import { shrimpLanguage } from '#/editor/shrimpLanguage'
import { shrimpHighlighting } from '#editor/theme'
import { shrimpKeymap } from '#editor/keymap'
import { log } from '#utils/utils'
import { Signal } from '#utils/signal'
import { shrimpErrors } from '#editor/plugins/errors'
import { inlineHints } from '#editor/plugins/inlineHints'
import { debugTags } from '#editor/plugins/debugTags'
export const outputSignal = new Signal<{ output: string } | { error: string }>()
outputSignal.connect((output) => {
const outputEl = document.querySelector('#output')
if (!outputEl) {
log.error('Output element not found')
return
}
if ('error' in output) {
outputEl.innerHTML = `<div class="error">${output.error}</div>`
} else {
outputEl.textContent = output.output
}
})
export const Editor = () => {
return (
<>
<div
ref={(ref: Element) => {
if (ref?.querySelector('.cm-editor')) return
const view = new EditorView({
doc: defaultCode,
parent: ref,
extensions: [
shrimpKeymap,
basicSetup,
shrimpTheme,
shrimpLanguage(),
shrimpHighlighting,
shrimpErrors,
inlineHints,
debugTags,
],
})
requestAnimationFrame(() => view.focus())
}}
/>
<div id="status-bar"></div>
<div id="output"></div>
</>
)
}
const defaultCode = ``

24
src/editor/keymap.ts Normal file
View File

@ -0,0 +1,24 @@
import { outputSignal } from '#editor/editor'
import { evaluate } from '#evaluator/evaluator'
import { parser } from '#parser/shrimp'
import { errorMessage, log } from '#utils/utils'
import { keymap } from '@codemirror/view'
export const shrimpKeymap = keymap.of([
{
key: 'Cmd-Enter',
run: (view) => {
const input = view.state.doc.toString()
const context = new Map<string, any>()
try {
const tree = parser.parse(input)
const output = evaluate(input, tree, context)
outputSignal.emit({ output: String(output) })
} catch (error) {
log.error(error)
outputSignal.emit({ error: `${errorMessage(error)}` })
}
return true
},
},
])

View File

@ -0,0 +1,32 @@
import { EditorView, ViewPlugin, ViewUpdate } from '@codemirror/view'
import { syntaxTree } from '@codemirror/language'
export const debugTags = ViewPlugin.fromClass(
class {
update(update: ViewUpdate) {
if (update.docChanged || update.selectionSet || update.geometryChanged) {
this.updateStatusBar(update.view)
}
}
updateStatusBar(view: EditorView) {
const pos = view.state.selection.main.head
const tree = syntaxTree(view.state)
let tags: string[] = []
let node = tree.resolveInner(pos, -1)
while (node) {
tags.push(node.type.name)
node = node.parent!
if (!node) break
}
const debugText = tags.length ? tags.reverse().slice(1).join(' > ') : 'No nodes'
const statusBar = document.querySelector('#status-bar')
if (statusBar) {
statusBar.textContent = debugText
}
}
}
)

View File

@ -0,0 +1,62 @@
import { parser } from '#parser/shrimp'
import type { Timeout } from '#utils/utils'
import { Range } from '@codemirror/state'
import {
Decoration,
EditorView,
ViewPlugin,
ViewUpdate,
type DecorationSet,
} from '@codemirror/view'
export const shrimpErrors = ViewPlugin.fromClass(
class {
timeout?: Timeout
decorations: DecorationSet = Decoration.none
constructor(view: EditorView) {
this.updateErrors(view)
}
update(update: ViewUpdate) {
if (update.docChanged) {
this.debounceUpdate(update.view)
}
}
updateErrors(view: EditorView) {
this.decorations = Decoration.none
try {
const decorations: Range<Decoration>[] = []
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,
}
)

View File

@ -0,0 +1,240 @@
import {
ViewPlugin,
ViewUpdate,
EditorView,
Decoration,
type DecorationSet,
} from '@codemirror/view'
import { syntaxTree } from '@codemirror/language'
import { type SyntaxNode } from '@lezer/common'
import { WidgetType } from '@codemirror/view'
import { toElement } from '#utils/utils'
import { matchingCommands } from '#editor/commands'
import * as Terms from '#parser/shrimp.terms'
const ghostTextTheme = EditorView.theme({
'.ghost-text': {
color: '#666',
opacity: '0.6',
fontStyle: 'italic',
},
})
type Hint = { cursor: number; hintText?: string; completionText?: string }
export const inlineHints = [
ViewPlugin.fromClass(
class {
decorations: DecorationSet = Decoration.none
currentHint?: Hint
update(update: ViewUpdate) {
if (!update.docChanged && !update.selectionSet) return
this.clearHints()
let hint = this.getContext(update.view)
this.currentHint = hint
this.showHint(hint)
}
handleTab(view: EditorView) {
if (!this.currentHint?.completionText) return false
this.decorations = Decoration.none
view.dispatch({
changes: {
from: this.currentHint.cursor,
insert: this.currentHint.completionText,
},
selection: {
anchor: this.currentHint.cursor + this.currentHint.completionText.length,
},
})
this.currentHint = undefined
return true
}
clearHints() {
this.currentHint = undefined
this.decorations = Decoration.none
}
getContext(view: EditorView): Hint {
const cursor = view.state.selection.main.head
const isCursorAtEnd = cursor === view.state.doc.length
if (!isCursorAtEnd) return { cursor }
const token = this.getCommandContextToken(view, cursor)
if (!token) return { cursor }
const text = view.state.doc.sliceString(token.from, token.to)
const tokenId = token.type.id
let completionText = ''
let hintText = ''
const justSpaces = view.state.doc.sliceString(cursor - 1, cursor) === ' '
if (tokenId === Terms.CommandPartial) {
const { partialMatches } = matchingCommands(text)
const match = partialMatches[0]
if (match) {
completionText = match.command.slice(text.length) + ' '
hintText = completionText
}
} else if (
tokenId === Terms.Identifier &&
token.parent?.type.id === Terms.Arg &&
!justSpaces
) {
const { availableArgs } = this.getCommandContext(view, token)
const matchingArgs = availableArgs.filter((arg) => arg.name.startsWith(text))
const match = matchingArgs[0]
if (match) {
hintText = `${match.name.slice(text.length)}=<${match.type}>`
completionText = `${match.name.slice(text.length)}=`
}
} else if (this.containedBy(token, Terms.PartialNamedArg)) {
const { availableArgs } = this.getCommandContext(view, token)
const textWithoutEquals = text.slice(0, -1)
const matchingArgs = availableArgs.filter((arg) => arg.name == textWithoutEquals)
const match = matchingArgs[0]
if (match) {
hintText = `<${match.type}>`
completionText = 'default' in match ? `${match.default}` : ''
}
} else {
const { availableArgs } = this.getCommandContext(view, token)
const nextArg = Array.from(availableArgs)[0]
const space = justSpaces ? '' : ' '
if (nextArg) {
hintText = `${space}${nextArg.name}=<${nextArg.type}>`
if (nextArg) {
completionText = `${space}${nextArg.name}=`
}
}
}
return { completionText, hintText, cursor }
}
getCommandContextToken(view: EditorView, cursor: number) {
const tree = syntaxTree(view.state)
let node = tree.resolveInner(cursor, -1)
// If we're in a CommandCall, return the token before cursor
if (this.containedBy(node, Terms.CommandCall)) {
return tree.resolveInner(cursor, -1)
}
// If we're in Program, look backward
while (node.name === 'Program' && cursor > 0) {
cursor -= 1
node = tree.resolveInner(cursor, -1)
if (this.containedBy(node, Terms.CommandCall)) {
return tree.resolveInner(cursor, -1)
}
}
}
containedBy(node: SyntaxNode, nodeId: number): SyntaxNode | undefined {
let current: SyntaxNode | undefined = node
while (current) {
if (current.type.id === nodeId) {
return current
}
current = current.parent ?? undefined
}
}
showHint(hint: Hint) {
if (!hint.hintText) return
const widget = new GhostTextWidget(hint.hintText)
const afterCursor = 1
const decoration = Decoration.widget({ widget, side: afterCursor }).range(hint.cursor)
this.decorations = Decoration.set([decoration])
}
getCommandContext(view: EditorView, currentToken: SyntaxNode) {
let commandCallNode = currentToken.parent
while (commandCallNode?.type.name !== 'CommandCall') {
if (!commandCallNode) {
throw new Error('No CommandCall parent found, must be an error in the grammar')
}
commandCallNode = commandCallNode.parent
}
const commandToken = commandCallNode.firstChild
if (!commandToken) {
throw new Error('CommandCall has no children, must be an error in the grammar')
}
const commandText = view.state.doc.sliceString(commandToken.from, commandToken.to)
const { match: commandShape } = matchingCommands(commandText)
if (!commandShape) {
throw new Error(`No command shape found for command "${commandText}"`)
}
let availableArgs = [...commandShape.args]
// Walk through all NamedArg children
let child = commandToken.nextSibling
while (child) {
console.log('child', child.type.name, child.to - child.from)
if (child.type.id === Terms.NamedArg) {
const argName = child.firstChild // Should be the Identifier
if (argName) {
const argText = view.state.doc.sliceString(argName.from, argName.to - 1)
availableArgs = availableArgs.filter((arg) => arg.name !== argText)
}
} else if (child.type.id == Terms.Arg) {
const hasSpaceAfter = view.state.doc.sliceString(child.to, child.to + 1) === ' '
if (hasSpaceAfter) {
availableArgs.shift()
}
}
child = child.nextSibling
}
console.log(
`🌭`,
availableArgs.map((a) => a.name)
)
return {
commandShape,
availableArgs,
}
}
},
{
decorations: (v) => v.decorations,
eventHandlers: {
keydown(event, view) {
if (event.key === 'Tab') {
event.preventDefault()
const plugin = view.plugin(inlineHints[0]! as ViewPlugin<any>)
plugin?.handleTab(view)
}
},
},
}
),
ghostTextTheme,
]
class GhostTextWidget extends WidgetType {
constructor(private text: string) {
super()
}
toDOM() {
const el = <span className="ghost-text">{this.text}</span>
return toElement(el)
}
}

View File

@ -3,17 +3,19 @@ import { HighlightStyle, syntaxHighlighting } from '@codemirror/language'
import { tags } from '@lezer/highlight'
const highlightStyle = HighlightStyle.define([
{ tag: tags.keyword, color: '#C792EA' }, // fn - soft purple (Night Owl inspired)
{ tag: tags.name, color: '#82AAFF' }, // identifiers - bright blue (Night Owl style)
{ tag: tags.string, color: '#C3E88D' }, // strings - soft green
{ tag: tags.number, color: '#F78C6C' }, // numbers - warm orange
{ tag: tags.bool, color: '#FF5370' }, // booleans - coral red
{ tag: tags.operator, color: '#89DDFF' }, // operators - cyan blue
{ tag: tags.paren, color: '#676E95' }, // parens - muted blue-gray
{ tag: tags.keyword, color: '#C792EA' },
{ tag: tags.name, color: '#82AAFF' },
{ tag: tags.string, color: '#C3E88D' },
{ tag: tags.number, color: '#F78C6C' },
{ tag: tags.bool, color: '#FF5370' },
{ tag: tags.operator, color: '#89DDFF' },
{ tag: tags.paren, color: '#676E95' },
{ tag: tags.function(tags.variableName), color: '#FF9CAC' },
{ tag: tags.function(tags.invalid), color: 'white' },
{
tag: tags.definition(tags.variableName),
color: '#FFCB6B', // warm yellow
backgroundColor: '#1E2A4A', // dark blue background
color: '#FFCB6B',
backgroundColor: '#1E2A4A',
padding: '1px 2px',
borderRadius: '2px',
fontWeight: '500',
@ -22,20 +24,19 @@ const highlightStyle = HighlightStyle.define([
export const shrimpHighlighting = syntaxHighlighting(highlightStyle)
export const editorTheme = EditorView.theme(
export const shrimpTheme = EditorView.theme(
{
'&': {
color: '#D6DEEB', // Night Owl text color
backgroundColor: '#011627', // Night Owl dark blue
fontFamily: '"Pixeloid Mono", "Courier New", monospace',
fontSize: '18px',
height: '100%',
fontSize: '18px',
},
'.cm-content': {
fontFamily: '"Pixeloid Mono", "Courier New", monospace',
caretColor: '#80A4C2', // soft blue caret
padding: '0px',
minHeight: '100px',
borderBottom: '3px solid #1E2A4A',
},
'.cm-activeLine': {
backgroundColor: 'transparent',

View File

@ -0,0 +1,8 @@
import { expect, test } from 'bun:test'
test('parses simple assignments', () => {
expect('number = 5').toEvaluateTo(5)
expect('number = -5.3').toEvaluateTo(-5.3)
expect(`string = 'abc'`).toEvaluateTo('abc')
expect('boolean = true').toEvaluateTo(true)
})

View File

@ -1,6 +1,7 @@
import { nodeToString } from '@/evaluator/treeHelper'
import { nodeToString } from '#/evaluator/treeHelper'
import { Tree, type SyntaxNode } from '@lezer/common'
import * as terms from '../parser/shrimp.terms.ts'
import { errorMessage } from '#utils/utils.ts'
type Context = Map<string, any>
@ -14,13 +15,35 @@ function getChildren(node: SyntaxNode): SyntaxNode[] {
return children
}
class RuntimeError extends Error {
constructor(message: string, private input: string, private from: number, private to: number) {
super(message)
this.name = 'RuntimeError'
this.message = `${message} at "${input.slice(from, to)}" (${from}:${to})`
}
toReadableString(code: string) {
const pointer = ' '.repeat(this.from) + '^'.repeat(this.to - this.from)
const context = code.split('\n').slice(-2).join('\n')
return `${context}\n${pointer}\n${this.message}`
}
}
export const evaluate = (input: string, tree: Tree, context: Context) => {
// Just evaluate the top-level children, don't use iterate()
let result = undefined
let child = tree.topNode.firstChild
while (child) {
result = evaluateNode(child, input, context)
child = child.nextSibling
try {
while (child) {
result = evaluateNode(child, input, context)
child = child.nextSibling
}
} catch (error) {
if (error instanceof RuntimeError) {
throw new Error(error.toReadableString(input))
} else {
throw new RuntimeError('Unknown error during evaluation', input, 0, input.length)
}
}
return result
@ -29,86 +52,105 @@ export const evaluate = (input: string, tree: Tree, context: Context) => {
const evaluateNode = (node: SyntaxNode, input: string, context: Context): any => {
const value = input.slice(node.from, node.to)
try {
switch (node.type.id) {
case terms.Number: {
return parseFloat(value)
}
switch (node.type.id) {
case terms.Number: {
return parseFloat(value)
}
case terms.Identifier: {
if (!context.has(value)) {
throw new Error(`Undefined identifier: ${value}`)
case terms.String: {
return value.slice(1, -1) // Remove quotes
}
case terms.Boolean: {
return value === 'true'
}
case terms.Identifier: {
if (!context.has(value)) {
throw new RuntimeError(`Undefined identifier: ${value}`, input, node.from, node.to)
}
return context.get(value)
}
case terms.BinOp: {
let [left, op, right] = getChildren(node)
left = assertNode(left, 'LeftOperand')
op = assertNode(op, 'Operator')
right = assertNode(right, 'RightOperand')
const leftValue = evaluateNode(left, input, context)
const opValue = input.slice(op.from, op.to)
const rightValue = evaluateNode(right, input, context)
switch (opValue) {
case '+':
return leftValue + rightValue
case '-':
return leftValue - rightValue
case '*':
return leftValue * rightValue
case '/':
return leftValue / rightValue
default:
throw new RuntimeError(`Unknown operator: ${opValue}`, input, op.from, op.to)
}
}
case terms.Assignment: {
const [identifier, _operator, expr] = getChildren(node)
const identifierNode = assertNode(identifier, 'Identifier')
const exprNode = assertNode(expr, 'Expression')
const name = input.slice(identifierNode.from, identifierNode.to)
const value = evaluateNode(exprNode, input, context)
context.set(name, value)
return value
}
case terms.Function: {
const [params, body] = getChildren(node)
const paramNodes = getChildren(assertNode(params, 'Parameters'))
const bodyNode = assertNode(body, 'Body')
const paramNames = paramNodes.map((param) => {
const paramNode = assertNode(param, 'Identifier')
return input.slice(paramNode.from, paramNode.to)
})
return (...args: any[]) => {
if (args.length !== paramNames.length) {
throw new RuntimeError(
`Expected ${paramNames.length} arguments, but got ${args.length}`,
input,
node.from,
node.to
)
}
return context.get(value)
}
case terms.BinOp: {
let [left, op, right] = getChildren(node)
left = assertNode(left, 'LeftOperand')
op = assertNode(op, 'Operator')
right = assertNode(right, 'RightOperand')
const leftValue = evaluateNode(left, input, context)
const opValue = input.slice(op.from, op.to)
const rightValue = evaluateNode(right, input, context)
switch (opValue) {
case '+':
return leftValue + rightValue
case '-':
return leftValue - rightValue
case '*':
return leftValue * rightValue
case '/':
return leftValue / rightValue
default:
throw new Error(`Unsupported operator: ${opValue}`)
}
}
case terms.Assignment: {
const [identifier, expr] = getChildren(node)
const identifierNode = assertNode(identifier, 'Identifier')
const exprNode = assertNode(expr, 'Expression')
const name = input.slice(identifierNode.from, identifierNode.to)
const value = evaluateNode(exprNode, input, context)
context.set(name, value)
return value
}
case terms.Function: {
const [params, body] = getChildren(node)
const paramNodes = getChildren(assertNode(params, 'Parameters'))
const bodyNode = assertNode(body, 'Body')
const paramNames = paramNodes.map((param) => {
const paramNode = assertNode(param, 'Identifier')
return input.slice(paramNode.from, paramNode.to)
const localContext = new Map(context)
paramNames.forEach((param, index) => {
localContext.set(param, args[index])
})
return (...args: any[]) => {
if (args.length !== paramNames.length) {
throw new Error(`Expected ${paramNames.length} arguments, but got ${args.length}`)
}
const localContext = new Map(context)
paramNames.forEach((param, index) => {
localContext.set(param, args[index])
})
return evaluateNode(bodyNode, input, localContext)
}
return evaluateNode(bodyNode, input, localContext)
}
default:
throw new Error(`Unsupported node type: ${node.name}`)
}
} catch (error) {
throw new Error(`Error evaluating node "${value}"\n${error.message}`)
default:
const isLowerCase = node.type.name[0] == node.type.name[0]?.toLowerCase()
// Ignore nodes with lowercase names, those are for syntax only
if (!isLowerCase) {
throw new RuntimeError(
`Unsupported node type "${node.type.name}"`,
input,
node.from,
node.to
)
}
}
}

View File

@ -11,7 +11,6 @@ export const nodeToString = (nodeRef: SyntaxNodeRef, input: string, maxDepth = 1
const indent = ' '.repeat(depth)
const text = input.slice(currentNodeRef.from, currentNodeRef.to)
const singleTokens = ['+', '-', '*', '/', '->', 'fn', '=', 'equals']
let child = currentNodeRef.node.firstChild
if (child) {
@ -22,11 +21,7 @@ export const nodeToString = (nodeRef: SyntaxNodeRef, input: string, maxDepth = 1
}
} else {
const cleanText = currentNodeRef.name === 'String' ? text.slice(1, -1) : text
if (singleTokens.includes(currentNodeRef.name)) {
lines.push(`${indent}${currentNodeRef.name}`)
} else {
lines.push(`${indent}${currentNodeRef.name} ${cleanText}`)
}
lines.push(`${indent}${currentNodeRef.name} ${cleanText}`)
}
}

View File

@ -1,5 +1,5 @@
import { Identifier, Params } from '@/parser/shrimp.terms'
import { styleTags, tags } from '@lezer/highlight'
import { Command, Identifier, Params } from '#/parser/shrimp.terms'
export const highlighting = styleTags({
Identifier: tags.name,
@ -8,6 +8,8 @@ export const highlighting = styleTags({
Boolean: tags.bool,
Keyword: tags.keyword,
Operator: tags.operator,
Command: tags.function(tags.variableName),
CommandPartial: tags.function(tags.invalid),
// Params: tags.definition(tags.variableName),
'Params/Identifier': tags.definition(tags.variableName),
Paren: tags.paren,

View File

@ -1,32 +1,48 @@
@external propSource highlighting from "./highlight.js"
@top Program { expr* }
@top Program { line }
line {
CommandCall semi |
expr semi
}
@skip { space }
@tokens {
@precedence { Number "-"}
space { @whitespace+ }
Number { $[0-9]+ ('.' $[0-9]+)? }
Number { "-"? $[0-9]+ ('.' $[0-9]+)? }
Boolean { "true" | "false" }
String { '"' !["]* '"' }
fn[@name=Keyword] { "fn" }
equals[@name=Operator] { "=" }
":"[@name=Colon]
"+"[@name=Operator]
"-"[@name=Operator]
"*"[@name=Operator]
"/"[@name=Operator]
leftParen[@name=Paren] { "(" }
rightParen[@name=Paren] { ")" }
String { '\'' !["]* '\'' }
NamedArgPrefix { $[a-z]+ $[a-z0-9\-]* "=" } // matches "lines=", "follow=", etc.
fn[@name=keyword] { "fn" }
equals[@name=operator] { "=" }
":"[@name=colon]
"+"[@name=operator]
"-"[@name=operator]
"*"[@name=operator]
"/"[@name=operator]
leftParen[@name=paren] { "(" }
rightParen[@name=paren] { ")" }
}
@external tokens identifierTokenizer from "./tokenizers" {
Identifier
@external tokens tokenizer from "./tokenizers" {
Identifier,
Command,
CommandPartial
}
@external tokens argTokenizer from "./tokenizers" {
UnquotedArg
}
@external tokens insertSemicolon from "./tokenizers" { insertedSemi }
@precedence {
multiplicative @left,
additive @left,
namedComplete @left,
function @right
assignment @right
}
@ -38,6 +54,20 @@ expr {
atom
}
semi { insertedSemi | ";" }
argValue { atom | UnquotedArg }
CommandCall { (Command | CommandPartial) (NamedArg | PartialNamedArg | Arg)* }
Arg { !namedComplete argValue }
NamedArg { NamedArgPrefix !namedComplete argValue } // Required atom, higher precedence
PartialNamedArg { NamedArgPrefix } // Just the prefix
Assignment { Identifier !assignment equals expr }
Function { !function fn Params ":" expr }
Params { Identifier* }
BinOp {
expr !multiplicative "*" expr |
expr !multiplicative "/" expr |
@ -45,9 +75,4 @@ BinOp {
expr !additive "-" expr
}
Params { Identifier* }
Function { !function fn Params ":" expr }
atom { Identifier | Number | String | Boolean | leftParen expr rightParen }
Assignment { Identifier !assignment equals expr }
atom { Identifier ~command | Number | String | Boolean | leftParen expr rightParen }

View File

@ -1,15 +1,24 @@
// This file was generated by lezer-generator. You probably shouldn't edit it.
export const
Identifier = 1,
Program = 2,
Assignment = 3,
equals = 4,
Function = 5,
fn = 6,
Params = 7,
BinOp = 9,
Number = 14,
String = 15,
Boolean = 16,
leftParen = 17,
rightParen = 18
Command = 2,
CommandPartial = 3,
UnquotedArg = 4,
insertedSemi = 30,
Program = 5,
CommandCall = 6,
NamedArg = 7,
NamedArgPrefix = 8,
Number = 9,
String = 10,
Boolean = 11,
leftParen = 12,
Assignment = 13,
equals = 14,
Function = 15,
fn = 16,
Params = 17,
BinOp = 19,
rightParen = 24,
PartialNamedArg = 25,
Arg = 26

View File

@ -1,4 +1,135 @@
import { expect, describe, test } from 'bun:test'
import { afterEach } from 'bun:test'
import { resetCommandSource, setCommandSource } from '#editor/commands'
import { beforeEach } from 'bun:test'
import './shrimp.grammar' // Importing this so changes cause it to retest!
describe('calling commands', () => {
beforeEach(() => {
setCommandSource(() => [
{ command: 'tail', args: [{ name: 'path', type: 'string' }] },
{ command: 'head', args: [{ name: 'path', type: 'string' }] },
{ command: 'echo', args: [{ name: 'path', type: 'string' }] },
])
})
afterEach(() => {
resetCommandSource()
})
test('basic', () => {
expect('tail path').toMatchTree(`
CommandCall
Command tail
Arg
Identifier path
`)
expect('tai').toMatchTree(`
CommandCall
CommandPartial tai
`)
})
test('command with arg that is also a command', () => {
expect('tail tail').toMatchTree(`
CommandCall
Command tail
Arg
Identifier tail
`)
expect('tai').toMatchTree(`
CommandCall
CommandPartial tai
`)
})
test('when no commands match, falls back to Identifier', () => {
expect('omgwtf').toMatchTree(`
Identifier omgwtf
`)
})
// In shrimp.test.ts, add to the 'calling commands' section
test('arg', () => {
expect('tail l').toMatchTree(`
CommandCall
Command tail
Arg
Identifier l
`)
})
test('partial namedArg', () => {
expect('tail lines=').toMatchTree(`
CommandCall
Command tail
PartialNamedArg
NamedArgPrefix lines=
`)
})
test('complete namedArg', () => {
expect('tail lines=10').toMatchTree(`
CommandCall
Command tail
NamedArg
NamedArgPrefix lines=
Number 10
`)
})
test('mixed positional and named args', () => {
expect('tail ../file.txt lines=5').toMatchTree(`
CommandCall
Command tail
Arg
UnquotedArg ../file.txt
NamedArg
NamedArgPrefix lines=
Number 5
`)
})
test('named args', () => {
expect(`tail lines='5' path`).toMatchTree(`
CommandCall
Command tail
NamedArg
NamedArgPrefix lines=
String 5
Arg
Identifier path
`)
})
test('complex args', () => {
expect(`tail lines=(2 + 3) filter='error' (a + b)`).toMatchTree(`
CommandCall
Command tail
NamedArg
NamedArgPrefix lines=
paren (
BinOp
Number 2
operator +
Number 3
paren )
NamedArg
NamedArgPrefix filter=
String error
Arg
paren (
BinOp
Identifier a
operator +
Identifier b
paren )
`)
})
})
describe('Identifier', () => {
test('parses simple identifiers', () => {
@ -26,7 +157,7 @@ describe('BinOp', () => {
expect('2 + 3').toMatchTree(`
BinOp
Number 2
Operator +
operator +
Number 3
`)
})
@ -35,7 +166,7 @@ describe('BinOp', () => {
expect('5 - 2').toMatchTree(`
BinOp
Number 5
Operator -
operator -
Number 2
`)
})
@ -44,7 +175,7 @@ describe('BinOp', () => {
expect('4 * 3').toMatchTree(`
BinOp
Number 4
Operator *
operator *
Number 3
`)
})
@ -53,7 +184,7 @@ describe('BinOp', () => {
expect('8 / 2').toMatchTree(`
BinOp
Number 8
Operator /
operator /
Number 2
`)
})
@ -63,15 +194,15 @@ describe('BinOp', () => {
BinOp
BinOp
Number 2
Operator +
operator +
BinOp
Number 3
Operator *
operator *
Number 4
Operator -
operator -
BinOp
Number 5
Operator /
operator /
Number 1
`)
})
@ -81,68 +212,53 @@ describe('Fn', () => {
test('parses function with single parameter', () => {
expect('fn x: x + 1').toMatchTree(`
Function
Keyword fn
keyword fn
Params
Identifier x
Colon :
colon :
BinOp
Identifier x
Operator +
operator +
Number 1`)
})
test('parses function with multiple parameters', () => {
expect('fn x y: x * y').toMatchTree(`
Function
Keyword fn
keyword fn
Params
Identifier x
Identifier y
Colon :
colon :
BinOp
Identifier x
Operator *
operator *
Identifier y`)
})
test('parses nested functions', () => {
expect('fn x: fn y: x + y').toMatchTree(`
Function
Keyword fn
keyword fn
Params
Identifier x
Colon :
colon :
Function
Keyword fn
keyword fn
Params
Identifier y
Colon :
colon :
BinOp
Identifier x
Operator +
operator +
Identifier y`)
})
})
describe('Identifier', () => {
test('parses hyphenated identifiers correctly', () => {
expect('my-var - another-var').toMatchTree(`
BinOp
Identifier my-var
Operator -
Identifier another-var`)
expect('double--trouble - another-var').toMatchTree(`
BinOp
Identifier double--trouble
Operator -
Identifier another-var`)
expect('tail-- - another-var').toMatchTree(`
BinOp
Identifier tail--
Operator -
Identifier another-var`)
expect('my-var').toMatchTree(`Identifier my-var`)
expect('double--trouble').toMatchTree(`Identifier double--trouble`)
})
})
@ -151,10 +267,10 @@ describe('Assignment', () => {
expect('x = 5 + 3').toMatchTree(`
Assignment
Identifier x
Operator =
operator =
BinOp
Number 5
Operator +
operator +
Number 3`)
})
@ -162,16 +278,16 @@ describe('Assignment', () => {
expect('add = fn a b: a + b').toMatchTree(`
Assignment
Identifier add
Operator =
operator =
Function
Keyword fn
keyword fn
Params
Identifier a
Identifier b
Colon :
colon :
BinOp
Identifier a
Operator +
operator +
Identifier b`)
})
})
@ -180,59 +296,36 @@ describe('Parentheses', () => {
test('parses expressions with parentheses correctly', () => {
expect('(2 + 3) * 4').toMatchTree(`
BinOp
Paren (
paren (
BinOp
Number 2
Operator +
operator +
Number 3
Paren )
Operator *
paren )
operator *
Number 4`)
})
test('parses nested parentheses correctly', () => {
expect('((1 + 2) * (3 - 4)) / 5').toMatchTree(`
BinOp
Paren (
paren (
BinOp
Paren (
paren (
BinOp
Number 1
Operator +
operator +
Number 2
Paren )
Operator *
Paren (
paren )
operator *
paren (
BinOp
Number 3
Operator -
operator -
Number 4
Paren )
Paren )
Operator /
paren )
paren )
operator /
Number 5`)
})
})
describe('multiline', () => {
test('parses multiline expressions', () => {
expect(`
5 + 4
fn x: x - 1
`).toMatchTree(`
BinOp
Number 5
Operator +
Number 4
Function
Keyword fn
Params
Identifier x
Colon :
BinOp
Identifier x
Operator -
Number 1
`)
})
})

View File

@ -1,19 +1,19 @@
// This file was generated by lezer-generator. You probably shouldn't edit it.
import {LRParser} from "@lezer/lr"
import {identifierTokenizer} from "./tokenizers"
import {tokenizer, argTokenizer, insertSemicolon} from "./tokenizers"
import {highlighting} from "./highlight.js"
export const parser = LRParser.deserialize({
version: 14,
states: "$OQVQROOOkQRO'#CuO!fQRO'#CaO!nQRO'#CoOOQQ'#Cu'#CuOVQRO'#CuOOQQ'#Ct'#CtQVQROOOVQRO,58yOOQQ'#Cp'#CpO#cQRO'#CcO#kQPO,58{OVQRO,59POVQRO,59PO#pQPO,59aOOQQ-E6m-E6mO$RQRO1G.eOOQQ-E6n-E6nOVQRO1G.gOOQQ1G.k1G.kO$yQRO1G.kOOQQ1G.{1G.{O%qQRO7+$R",
stateData: "&i~OgOS~OPPOUQO^SO_SO`SOaTO~OSWOPiXUiXYiXZiX[iX]iX^iX_iX`iXaiXeiXbiX~OPXOWVP~OY[OZ[O[]O]]OPcXUcX^cX_cX`cXacXecX~OPXOWVX~OWbO~OY[OZ[O[]O]]ObeO~OY[OZ[O[]O]]OPRiURi^Ri_Ri`RiaRieRibRi~OY[OZ[OPXiUXi[Xi]Xi^Xi_Xi`XiaXieXibXi~OY[OZ[O[]O]]OPTqUTq^Tq_Tq`TqaTqeTqbTq~O",
goto: "!hjPPPkPkPtPkPPPPPPPPPw}PPP!Tk_UOTVW[]bRZQQVOR_VQYQRaYSROVQ^TQ`WQc[Qd]Rfb",
nodeNames: "⚠ Identifier Program Assignment Operator Function Keyword Params Colon BinOp Operator Operator Operator Operator Number String Boolean Paren Paren",
maxTerm: 25,
states: "%^OkQTOOOuQaO'#DPO!aQTO'#CkO!iQaO'#C}OOQ`'#DQ'#DQOOQl'#DP'#DPOVQTO'#DPO#fQnO'#CbO!uQaO'#C}QOQPOOOVQTO,59TOOQS'#Cx'#CxO#pQTO'#CmO#xQPO,59VOVQTO,59ZOVQTO,59ZOOQO'#DR'#DROOQO,59i,59iO#}QPO,59kOOQl'#DO'#DOO$tQnO'#CuOOQl'#Cv'#CvOOQl'#Cw'#CwO%RQnO,58|O%]QaO1G.oOOQS-E6v-E6vOVQTO1G.qOOQ`1G.u1G.uO%tQaO1G.uOOQl1G/V1G/VOOQl,58},58}OOQl-E6u-E6uO&]QaO7+$]",
stateData: "&w~OpOS~OPPOXTOYTOZTO[UO`QO~OQVORVO~PVO^YOdsXesXfsXgsXnsXvsXhsX~OPZObaP~Od^Oe^Of_Og_On`Ov`O~OPTOScOWdOXTOYTOZTO[UO~OnUXvUX~P!}OPZObaX~ObjO~Od^Oe^Of_Og_OhmO~OPTOScOXTOYTOZTO[UO~OWiXniXviX~P$`OnUavUa~P!}Od^Oe^Of_Og_On]iv]ih]i~Od^Oe^Ofcigcincivcihci~Od^Oe^Of_Og_On_qv_qh_q~OXg~",
goto: "#fvPPPPPPwzPPPPP!OP!OP!WP!OPPPPPzz!Z!aPPPP!g!j!q#O#bRWOTfVg]SOUY^_jR]QQgVRogQ[QRi[RXOSeVgRnd[SOUY^_jVcVdgQROQbUQhYQk^Ql_RpjTaRW",
nodeNames: "⚠ Identifier Command CommandPartial UnquotedArg Program CommandCall NamedArg NamedArgPrefix Number String Boolean paren Assignment operator Function keyword Params colon BinOp operator operator operator operator paren PartialNamedArg Arg",
maxTerm: 38,
propSources: [highlighting],
skippedNodes: [0],
repeatNodeCount: 2,
tokenData: "&a~RfX^!gpq!grs#[xy#yyz$Oz{$T{|$Y}!O$_!P!Q$d!Q![$i![!]%S!_!`%X#Y#Z%^#h#i&T#y#z!g$f$g!g#BY#BZ!g$IS$I_!g$I|$JO!g$JT$JU!g$KV$KW!g&FU&FV!g~!lYg~X^!gpq!g#y#z!g$f$g!g#BY#BZ!g$IS$I_!g$I|$JO!g$JT$JU!g$KV$KW!g&FU&FV!g~#_TOr#[rs#ns;'S#[;'S;=`#s<%lO#[~#sO_~~#vP;=`<%l#[~$OOa~~$TOb~~$YOY~~$_O[~~$dO]~~$iOZ~~$nQ^~!O!P$t!Q![$i~$wP!Q![$z~%PP^~!Q![$z~%XOW~~%^OS~~%aQ#T#U%g#b#c&O~%jP#`#a%m~%pP#g#h%s~%vP#X#Y%y~&OO`~~&TOU~~&WP#f#g&Z~&^P#i#j%s",
tokenizers: [0, identifierTokenizer],
topRules: {"Program":[0,2]},
tokenPrec: 0
tokenData: "*W~RjX^!spq!swx#hxy$lyz$qz{$v{|${}!O%Q!P!Q%s!Q![%Y![!]%x!]!^%}!_!`&S#T#Y&X#Y#Z&m#Z#h&X#h#i)[#i#o&X#y#z!s$f$g!s#BY#BZ!s$IS$I_!s$I|$JO!s$JT$JU!s$KV$KW!s&FU&FV!s~!xYp~X^!spq!s#y#z!s$f$g!s#BY#BZ!s$IS$I_!s$I|$JO!s$JT$JU!s$KV$KW!s&FU&FV!s~#kUOr#hsw#hwx#}x;'S#h;'S;=`$f<%lO#h~$SUY~Or#hsw#hwx#}x;'S#h;'S;=`$f<%lO#h~$iP;=`<%l#h~$qO[~~$vOh~~${Od~~%QOf~~%VPg~!Q![%Y~%_QX~!O!P%e!Q![%Y~%hP!Q![%k~%pPX~!Q![%k~%xOe~~%}Ob~~&SOv~~&XO^~Q&[S}!O&X!Q![&X!_!`&h#T#o&XQ&mOWQ~&pV}!O&X!Q![&X!_!`&h#T#U'V#U#b&X#b#c(y#c#o&X~'YU}!O&X!Q![&X!_!`&h#T#`&X#`#a'l#a#o&X~'oU}!O&X!Q![&X!_!`&h#T#g&X#g#h(R#h#o&X~(UU}!O&X!Q![&X!_!`&h#T#X&X#X#Y(h#Y#o&X~(mSZ~}!O&X!Q![&X!_!`&h#T#o&XR)OS`P}!O&X!Q![&X!_!`&h#T#o&X~)_U}!O&X!Q![&X!_!`&h#T#f&X#f#g)q#g#o&X~)tU}!O&X!Q![&X!_!`&h#T#i&X#i#j(R#j#o&X",
tokenizers: [0, 1, tokenizer, argTokenizer, insertSemicolon],
topRules: {"Program":[0,5]},
tokenPrec: 266
})

View File

@ -1,5 +1,92 @@
import { ExternalTokenizer, InputStream } from '@lezer/lr'
import { Identifier } from './shrimp.terms'
import { CommandPartial, Command, Identifier, UnquotedArg, insertedSemi } from './shrimp.terms'
import { matchingCommands } from '#editor/commands'
export const tokenizer = new ExternalTokenizer((input: InputStream, stack: Stack) => {
let ch = getFullCodePoint(input, 0)
if (!isLowercaseLetter(ch) && !isEmoji(ch)) return
let pos = getCharSize(ch)
let text = String.fromCodePoint(ch)
// Continue consuming identifier characters
while (true) {
ch = getFullCodePoint(input, pos)
if (isLowercaseLetter(ch) || isDigit(ch) || ch === 45 /* - */ || isEmoji(ch)) {
text += String.fromCodePoint(ch)
pos += getCharSize(ch)
} else {
break
}
}
input.advance(pos)
if (!stack.canShift(Command) && !stack.canShift(CommandPartial)) {
input.acceptToken(Identifier)
return
}
const { match, partialMatches } = matchingCommands(text)
if (match) {
input.acceptToken(Command)
} else if (partialMatches.length > 0) {
input.acceptToken(CommandPartial)
} else {
input.acceptToken(Identifier)
}
})
export const argTokenizer = new ExternalTokenizer((input: InputStream, stack: Stack) => {
// Only match if we're in a command argument position
if (!stack.canShift(UnquotedArg)) return
const firstCh = input.peek(0)
// Don't match if it starts with tokens we handle elsewhere
if (
firstCh === 39 /* ' */ ||
firstCh === 40 /* ( */ ||
firstCh === 45 /* - (for negative numbers) */ ||
(firstCh >= 48 && firstCh <= 57) /* 0-9 (numbers) */
)
return
// Read everything that's not a space, newline, or paren
let pos = 0
while (true) {
const ch = input.peek(pos)
if (
ch === -1 ||
ch === 32 /* space */ ||
ch === 10 /* \n */ ||
ch === 40 /* ( */ ||
ch === 41 /* ) */ ||
ch === 61 /* = */
)
break
pos++
}
if (pos > 0) {
input.advance(pos)
input.acceptToken(UnquotedArg)
}
})
export const insertSemicolon = new ExternalTokenizer((input: InputStream, stack: Stack) => {
const next = input.peek(0)
// We're at a newline or end of file
if (next === 10 /* \n */ || next === -1 /* EOF */) {
// Check if insertedSemi would be valid here
if (stack.canShift(insertedSemi)) {
// Don't advance! Virtual token has zero width
input.acceptToken(insertedSemi, 0)
}
}
})
function isLowercaseLetter(ch: number): boolean {
return ch >= 97 && ch <= 122 // a-z
@ -54,29 +141,4 @@ function isEmoji(ch: number): boolean {
)
}
export const identifierTokenizer = new ExternalTokenizer((input: InputStream) => {
const ch = getFullCodePoint(input, 0)
if (isLowercaseLetter(ch) || isEmoji(ch)) {
let pos = ch > 0xffff ? 2 : 1 // emoji takes 2 UTF-16 code units
// Continue consuming identifier characters
while (true) {
const nextCh = getFullCodePoint(input, pos)
if (
isLowercaseLetter(nextCh) ||
isDigit(nextCh) ||
nextCh === 45 /* - */ ||
isEmoji(nextCh)
) {
pos += nextCh > 0xffff ? 2 : 1 // advance by 1 or 2 UTF-16 code units
} else {
break
}
}
input.advance(pos) // advance by total length
input.acceptToken(Identifier)
}
})
const getCharSize = (ch: number) => (ch > 0xffff ? 2 : 1) // emoji takes 2 UTF-16 code units

View File

@ -1,13 +1,9 @@
import { Editor } from '@/server/editor'
import { Editor } from '#/editor/editor'
import { render } from 'hono/jsx/dom'
import './index.css'
const App = () => {
return (
<div className="">
<Editor />
</div>
)
return <Editor />
}
const root = document.getElementById('root')!

View File

@ -1,71 +0,0 @@
import {
EditorView,
Decoration,
ViewPlugin,
ViewUpdate,
WidgetType,
type DecorationSet,
} from '@codemirror/view'
import { syntaxTree } from '@codemirror/language'
const createDebugWidget = (tags: string) =>
Decoration.widget({
widget: new (class extends WidgetType {
toDOM() {
const div = document.createElement('div')
div.style.cssText = `
position: fixed;
top: 10px;
right: 10px;
background: #000;
color: #00ff00;
padding: 8px;
font-family: monospace;
font-size: 12px;
border: 1px solid #333;
z-index: 1000;
max-width: 300px;
word-wrap: break-word;
white-space: pre-wrap;
`
div.textContent = tags
return div
}
})(),
})
export const debugTags = ViewPlugin.fromClass(
class {
decorations: DecorationSet = Decoration.none
constructor(view: EditorView) {
this.updateDecorations(view)
}
update(update: ViewUpdate) {
if (update.docChanged || update.selectionSet) {
this.updateDecorations(update.view)
}
}
updateDecorations(view: EditorView) {
const pos = view.state.selection.main.head
const tree = syntaxTree(view.state)
let tags: string[] = []
let node = tree.resolveInner(pos, -1)
while (node) {
tags.push(node.type.name)
node = node.parent!
if (!node) break
}
const debugText = tags.length ? tags.join(' > ') : 'No nodes'
this.decorations = Decoration.set([createDebugWidget(debugText).range(pos)])
}
},
{
decorations: (v) => v.decorations,
}
)

View File

@ -1,32 +0,0 @@
import { basicSetup } from 'codemirror'
import { EditorView } from '@codemirror/view'
import { editorTheme } from './editorTheme'
import { shrimpLanguage } from './shrimpLanguage'
import { shrimpHighlighting } from './editorTheme'
import { debugTags } from '@/server/debugPlugin'
export const Editor = () => {
return (
<div
ref={(ref: Element) => {
if (ref?.querySelector('.cm-editor')) return
console.log('init editor')
new EditorView({
doc: `a = 3
fn x y: x + y
aa = fn radius: 3.14 * radius * radius
b = true
c = "cyan"
`,
parent: ref,
extensions: [basicSetup, editorTheme, shrimpLanguage(), shrimpHighlighting],
})
}}
/>
)
}
export const Output = ({ children }: { children: string }) => {
return <div className="terminal-output">{children}</div>
}

View File

@ -20,7 +20,7 @@ body {
flex-direction: column;
}
.terminal-output {
#output {
flex: 1;
background: #40318D;
color: #7C70DA;
@ -29,4 +29,24 @@ body {
white-space: pre-wrap;
font-family: 'Pixeloid Mono', 'Courier New', monospace;
font-size: 18px;
}
#output .error {
color: #FF6E6E;
}
#status-bar {
height: 30px;
background: #1E2A4A;
color: #B3A9FF;
display: flex;
align-items: center;
padding: 0 10px;
font-size: 14px;
border-top: 3px solid #0E1A3A;
border-bottom: 3px solid #0E1A3A;
}
.syntax-error {
text-decoration: underline dotted #FF6E6E;
}

View File

@ -1,7 +1,9 @@
import { expect } from 'bun:test'
import { Tree, TreeCursor } from '@lezer/common'
import { parser } from './parser/shrimp.ts'
import { parser } from '#parser/shrimp'
import { $ } from 'bun'
import { assert } from '#utils/utils'
import { evaluate } from '#evaluator/evaluator'
const regenerateParser = async () => {
let generate = true
@ -29,21 +31,18 @@ declare module 'bun:test' {
interface Matchers<T> {
toMatchTree(expected: string): T
toFailParse(): T
toEvaluateTo(expected: unknown): T
}
}
expect.extend({
toMatchTree(received: unknown, expected: string) {
if (typeof received !== 'string') {
return {
message: () => 'toMatchTree can only be used with string values',
pass: false,
}
}
assert(typeof received === 'string', 'toMatchTree can only be used with string values')
const tree = parser.parse(received)
const actual = treeToString(tree, received)
const normalizedExpected = trimWhitespace(expected)
try {
// A hacky way to show the colorized diff in the test output
expect(actual).toEqual(normalizedExpected)
@ -55,13 +54,9 @@ expect.extend({
}
}
},
toFailParse(received: unknown) {
if (typeof received !== 'string') {
return {
message: () => 'toMatchTree can only be used with string values',
pass: false,
}
}
assert(typeof received === 'string', 'toFailParse can only be used with string values')
try {
const tree = parser.parse(received)
@ -94,6 +89,50 @@ expect.extend({
}
}
},
toEvaluateTo(received: unknown, expected: unknown) {
assert(typeof received === 'string', 'toEvaluateTo can only be used with string values')
try {
const tree = parser.parse(received)
let hasErrors = false
tree.iterate({
enter(n) {
if (n.type.isError) {
hasErrors = true
return false
}
},
})
if (hasErrors) {
const actual = treeToString(tree, received)
return {
message: () =>
`Expected input to evaluate successfully, but it had syntax errors:\n${actual}`,
pass: false,
}
} else {
const context = new Map<string, unknown>()
const result = evaluate(received, tree, context)
if (Object.is(result, expected)) {
return { pass: true }
} else {
const expectedStr = JSON.stringify(expected)
const resultStr = JSON.stringify(result)
return {
message: () => `Expected evaluation to be ${expectedStr}, but got ${resultStr}`,
pass: false,
}
}
}
} catch (error) {
return {
message: () => `Evaluation threw an error: ${(error as Error).message}`,
pass: false,
}
}
},
})
const treeToString = (tree: Tree, input: string): string => {
@ -152,3 +191,10 @@ const trimWhitespace = (str: string): string => {
})
.join('\n')
}
const expectString = (value: unknown): string => {
if (typeof value !== 'string') {
throw new Error('Expected a string input')
}
return value
}

57
src/utils/signal.ts Normal file
View File

@ -0,0 +1,57 @@
/**
* How to use a Signal:
*
* Create a signal:
* const chatSignal = new Signal<{ username: string, message: string }>()
*
* Connect to the signal:
* const disconnect = chatSignal.connect((data) => {
* const {username, message} = data;
* console.log(`${username} said "${message}"`);
* })
*
* Emit a signal:
* chatSignal.emit({ username: "Chad", message: "Hey everyone, how's it going?" });
*
* Forward a signal:
* const relaySignal = new Signal<{ username: string, message: string }>()
* const disconnectRelay = chatSignal.connect(relaySignal)
* // Now, when chatSignal emits, relaySignal will also emit the same data
*
* Disconnect a single listener:
* disconnect(); // The disconnect function is returned when you connect to a signal
*
* Disconnect all listeners:
* chatSignal.disconnect()
*/
export class Signal<T extends object | void> {
private listeners: Array<(data: T) => void> = []
connect(listenerOrSignal: Signal<T> | ((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 = []
}
}

25
src/utils/utils.tsx Normal file
View File

@ -0,0 +1,25 @@
import { render } from 'hono/jsx/dom'
export type Timeout = ReturnType<typeof setTimeout>
export const log = (...args: any[]) => console.log(...args)
log.error = (...args: any[]) => console.error(`💥 ${args.join(' ')}`)
export const errorMessage = (error: unknown) => {
if (error instanceof Error) {
return error.message
}
return String(error)
}
export function assert(condition: boolean, message: string): asserts condition {
if (!condition) {
throw new Error(message)
}
}
export const toElement = (node: any): HTMLElement => {
const c = document.createElement('div')
render(node, c)
return c.firstElementChild as HTMLElement
}

View File

@ -24,7 +24,7 @@
"baseUrl": ".",
"paths": {
"@/*": ["./src/*"]
"#*": ["./src/*"]
},
// Some stricter flags (disabled by default)