OMG hairy
This commit is contained in:
parent
0168d7f933
commit
d89130b169
247
src/editor/commands.ts
Normal file
247
src/editor/commands.ts
Normal 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
58
src/editor/editor.tsx
Normal 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
24
src/editor/keymap.ts
Normal 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
|
||||
},
|
||||
},
|
||||
])
|
||||
32
src/editor/plugins/debugTags.ts
Normal file
32
src/editor/plugins/debugTags.ts
Normal 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
|
||||
}
|
||||
}
|
||||
}
|
||||
)
|
||||
62
src/editor/plugins/errors.ts
Normal file
62
src/editor/plugins/errors.ts
Normal 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,
|
||||
}
|
||||
)
|
||||
240
src/editor/plugins/inlineHints.tsx
Normal file
240
src/editor/plugins/inlineHints.tsx
Normal 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)
|
||||
}
|
||||
}
|
||||
|
|
@ -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',
|
||||
8
src/evaluator/evaluator.test.ts
Normal file
8
src/evaluator/evaluator.test.ts
Normal 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)
|
||||
})
|
||||
|
|
@ -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,14 +15,36 @@ 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
|
||||
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,15 +52,21 @@ 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)
|
||||
}
|
||||
|
||||
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 Error(`Undefined identifier: ${value}`)
|
||||
throw new RuntimeError(`Undefined identifier: ${value}`, input, node.from, node.to)
|
||||
}
|
||||
return context.get(value)
|
||||
}
|
||||
|
|
@ -63,12 +92,12 @@ const evaluateNode = (node: SyntaxNode, input: string, context: Context): any =>
|
|||
case '/':
|
||||
return leftValue / rightValue
|
||||
default:
|
||||
throw new Error(`Unsupported operator: ${opValue}`)
|
||||
throw new RuntimeError(`Unknown operator: ${opValue}`, input, op.from, op.to)
|
||||
}
|
||||
}
|
||||
|
||||
case terms.Assignment: {
|
||||
const [identifier, expr] = getChildren(node)
|
||||
const [identifier, _operator, expr] = getChildren(node)
|
||||
|
||||
const identifierNode = assertNode(identifier, 'Identifier')
|
||||
const exprNode = assertNode(expr, 'Expression')
|
||||
|
|
@ -76,6 +105,7 @@ const evaluateNode = (node: SyntaxNode, input: string, context: Context): any =>
|
|||
const name = input.slice(identifierNode.from, identifierNode.to)
|
||||
const value = evaluateNode(exprNode, input, context)
|
||||
context.set(name, value)
|
||||
|
||||
return value
|
||||
}
|
||||
|
||||
|
|
@ -92,7 +122,12 @@ const evaluateNode = (node: SyntaxNode, input: string, context: Context): any =>
|
|||
|
||||
return (...args: any[]) => {
|
||||
if (args.length !== paramNames.length) {
|
||||
throw new Error(`Expected ${paramNames.length} arguments, but got ${args.length}`)
|
||||
throw new RuntimeError(
|
||||
`Expected ${paramNames.length} arguments, but got ${args.length}`,
|
||||
input,
|
||||
node.from,
|
||||
node.to
|
||||
)
|
||||
}
|
||||
|
||||
const localContext = new Map(context)
|
||||
|
|
@ -105,10 +140,17 @@ const evaluateNode = (node: SyntaxNode, input: string, context: Context): any =>
|
|||
}
|
||||
|
||||
default:
|
||||
throw new Error(`Unsupported node type: ${node.name}`)
|
||||
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
|
||||
)
|
||||
}
|
||||
} catch (error) {
|
||||
throw new Error(`Error evaluating node "${value}"\n${error.message}`)
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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,13 +21,9 @@ 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}`)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
addNode(nodeRef)
|
||||
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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 { '"' !["]* '"' }
|
||||
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] { ")" }
|
||||
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 }
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
`)
|
||||
})
|
||||
})
|
||||
|
|
|
|||
|
|
@ -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
|
||||
})
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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')!
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
}
|
||||
)
|
||||
|
|
@ -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>
|
||||
}
|
||||
|
|
@ -20,7 +20,7 @@ body {
|
|||
flex-direction: column;
|
||||
}
|
||||
|
||||
.terminal-output {
|
||||
#output {
|
||||
flex: 1;
|
||||
background: #40318D;
|
||||
color: #7C70DA;
|
||||
|
|
@ -30,3 +30,23 @@ body {
|
|||
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;
|
||||
}
|
||||
|
|
@ -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
57
src/utils/signal.ts
Normal 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
25
src/utils/utils.tsx
Normal 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
|
||||
}
|
||||
|
|
@ -24,7 +24,7 @@
|
|||
|
||||
"baseUrl": ".",
|
||||
"paths": {
|
||||
"@/*": ["./src/*"]
|
||||
"#*": ["./src/*"]
|
||||
},
|
||||
|
||||
// Some stricter flags (disabled by default)
|
||||
|
|
|
|||
Loading…
Reference in New Issue
Block a user