Compare commits
No commits in common. "main" and "prettier" have entirely different histories.
|
|
@ -1,15 +1,11 @@
|
||||||
{
|
{
|
||||||
"name": "shrimp",
|
"name": "shrimp",
|
||||||
"version": "0.1.0",
|
"version": "0.1.0",
|
||||||
"exports": {
|
"exports": "./src/index.ts",
|
||||||
".": "./src/index.ts",
|
|
||||||
"./editor": "./src/editor/index.ts",
|
|
||||||
"./editor.css": "./src/editor/editor.css"
|
|
||||||
},
|
|
||||||
"private": true,
|
"private": true,
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"editor": "bun --hot src/editor/example/server.tsx",
|
"dev": "bun --hot src/server/server.tsx",
|
||||||
"repl": "bun bin/repl",
|
"repl": "bun bin/repl",
|
||||||
"update-reef": "rm -rf ~/.bun/install/cache/ && rm bun.lock && bun update reefvm",
|
"update-reef": "rm -rf ~/.bun/install/cache/ && rm bun.lock && bun update reefvm",
|
||||||
"cli:install": "ln -s \"$(pwd)/bin/shrimp\" ~/.bun/bin/shrimp",
|
"cli:install": "ln -s \"$(pwd)/bin/shrimp\" ~/.bun/bin/shrimp",
|
||||||
|
|
|
||||||
|
|
@ -57,7 +57,6 @@ export class Compiler {
|
||||||
loopLabelCount = 0
|
loopLabelCount = 0
|
||||||
bytecode: Bytecode
|
bytecode: Bytecode
|
||||||
pipeCounter = 0
|
pipeCounter = 0
|
||||||
pipeVarStack: string[] = [] // Stack of pipe variable names for nested pipes
|
|
||||||
|
|
||||||
constructor(
|
constructor(
|
||||||
public input: string,
|
public input: string,
|
||||||
|
|
@ -734,23 +733,11 @@ export class Compiler {
|
||||||
const instructions: ProgramItem[] = []
|
const instructions: ProgramItem[] = []
|
||||||
instructions.push(...this.#compileNode(pipedFunctionCall, input))
|
instructions.push(...this.#compileNode(pipedFunctionCall, input))
|
||||||
|
|
||||||
// Use a unique variable name for this pipe level to handle nested pipes correctly
|
|
||||||
this.pipeCounter++
|
this.pipeCounter++
|
||||||
const pipeVarName = `_pipe_${this.pipeCounter}`
|
const pipeValName = `_pipe_value_${this.pipeCounter}`
|
||||||
this.pipeVarStack.push(pipeVarName)
|
|
||||||
|
|
||||||
pipeReceivers.forEach((pipeReceiver) => {
|
pipeReceivers.forEach((pipeReceiver) => {
|
||||||
// Store the piped value in the current pipe's variable
|
instructions.push(['STORE', pipeValName])
|
||||||
// Also store as `_` for direct access in simple cases
|
|
||||||
instructions.push(['DUP'])
|
|
||||||
instructions.push(['STORE', pipeVarName])
|
|
||||||
instructions.push(['STORE', '_'])
|
|
||||||
|
|
||||||
const isFunctionCall =
|
|
||||||
pipeReceiver.type.is('FunctionCall') || pipeReceiver.type.is('FunctionCallOrIdentifier')
|
|
||||||
|
|
||||||
if (isFunctionCall) {
|
|
||||||
// Function call receiver: check for explicit _ usage to determine arg handling
|
|
||||||
const { identifierNode, namedArgs, positionalArgs } = getFunctionCallParts(
|
const { identifierNode, namedArgs, positionalArgs } = getFunctionCallParts(
|
||||||
pipeReceiver,
|
pipeReceiver,
|
||||||
input,
|
input,
|
||||||
|
|
@ -770,30 +757,32 @@ export class Compiler {
|
||||||
|
|
||||||
// If no underscore is explicitly used, add the piped value as the first positional arg
|
// If no underscore is explicitly used, add the piped value as the first positional arg
|
||||||
if (shouldPushPositionalArg) {
|
if (shouldPushPositionalArg) {
|
||||||
instructions.push(['LOAD', pipeVarName])
|
instructions.push(['LOAD', pipeValName])
|
||||||
}
|
}
|
||||||
|
|
||||||
positionalArgs.forEach((arg) => {
|
positionalArgs.forEach((arg) => {
|
||||||
|
if (arg.type.is('Underscore')) {
|
||||||
|
instructions.push(['LOAD', pipeValName])
|
||||||
|
} else {
|
||||||
instructions.push(...this.#compileNode(arg, input))
|
instructions.push(...this.#compileNode(arg, input))
|
||||||
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
namedArgs.forEach((arg) => {
|
namedArgs.forEach((arg) => {
|
||||||
const { name, valueNode } = getNamedArgParts(arg, input)
|
const { name, valueNode } = getNamedArgParts(arg, input)
|
||||||
instructions.push(['PUSH', name])
|
instructions.push(['PUSH', name])
|
||||||
|
if (valueNode.type.is('Underscore')) {
|
||||||
|
instructions.push(['LOAD', pipeValName])
|
||||||
|
} else {
|
||||||
instructions.push(...this.#compileNode(valueNode, input))
|
instructions.push(...this.#compileNode(valueNode, input))
|
||||||
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
instructions.push(['PUSH', positionalArgs.length + (shouldPushPositionalArg ? 1 : 0)])
|
instructions.push(['PUSH', positionalArgs.length + (shouldPushPositionalArg ? 1 : 0)])
|
||||||
instructions.push(['PUSH', namedArgs.length])
|
instructions.push(['PUSH', namedArgs.length])
|
||||||
instructions.push(['CALL'])
|
instructions.push(['CALL'])
|
||||||
} else {
|
|
||||||
// Non-function-call receiver (Array, ParenExpr, etc.): compile directly
|
|
||||||
// The `_` variable is available for use in nested expressions
|
|
||||||
instructions.push(...this.#compileNode(pipeReceiver, input))
|
|
||||||
}
|
|
||||||
})
|
})
|
||||||
|
|
||||||
this.pipeVarStack.pop()
|
|
||||||
return instructions
|
return instructions
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -880,13 +869,6 @@ export class Compiler {
|
||||||
return [] // ignore comments
|
return [] // ignore comments
|
||||||
}
|
}
|
||||||
|
|
||||||
case 'Underscore': {
|
|
||||||
// _ refers to the piped value for the current (innermost) pipe
|
|
||||||
// Use the stack to handle nested pipes correctly
|
|
||||||
const pipeVar = this.pipeVarStack.at(-1) ?? '_'
|
|
||||||
return [['LOAD', pipeVar]]
|
|
||||||
}
|
|
||||||
|
|
||||||
default:
|
default:
|
||||||
throw new CompilerError(
|
throw new CompilerError(
|
||||||
`Compiler doesn't know how to handle a "${node.type.name}" node.`,
|
`Compiler doesn't know how to handle a "${node.type.name}" node.`,
|
||||||
|
|
|
||||||
|
|
@ -117,42 +117,4 @@ describe('pipe expressions', () => {
|
||||||
test('dict literals can be piped', () => {
|
test('dict literals can be piped', () => {
|
||||||
expect(`[a=1 b=2 c=3] | dict.values | list.sort | str.join '-'`).toEvaluateTo('1-2-3')
|
expect(`[a=1 b=2 c=3] | dict.values | list.sort | str.join '-'`).toEvaluateTo('1-2-3')
|
||||||
})
|
})
|
||||||
|
|
||||||
test('pipe to array literal using _ in nested expressions', () => {
|
|
||||||
// _ should be accessible inside nested function calls within array literals
|
|
||||||
const code = `
|
|
||||||
double = do x: x * 2 end
|
|
||||||
triple = do x: x * 3 end
|
|
||||||
5 | [(double _) (triple _)]`
|
|
||||||
expect(code).toEvaluateTo([10, 15])
|
|
||||||
})
|
|
||||||
|
|
||||||
test('pipe to array literal using _ multiple times', () => {
|
|
||||||
expect(`10 | [_ _ _]`).toEvaluateTo([10, 10, 10])
|
|
||||||
})
|
|
||||||
|
|
||||||
test('pipe to parenthesized expression using _', () => {
|
|
||||||
const code = `
|
|
||||||
double = do x: x * 2 end
|
|
||||||
5 | (double _)`
|
|
||||||
expect(code).toEvaluateTo(10)
|
|
||||||
})
|
|
||||||
|
|
||||||
test('pipe chain with array literal receiver', () => {
|
|
||||||
// Pipe to array, then pipe that array to a function
|
|
||||||
const code = `
|
|
||||||
double = do x: x * 2 end
|
|
||||||
5 | [(double _) _] | list.sum`
|
|
||||||
expect(code).toEvaluateTo(15) // [10, 5] -> 15
|
|
||||||
})
|
|
||||||
|
|
||||||
test('_ in deeply nested expressions within pipe', () => {
|
|
||||||
// _ should work in nested function calls within function arguments
|
|
||||||
const code = `
|
|
||||||
add = do a b: a + b end
|
|
||||||
mul = do a b: a * b end
|
|
||||||
10 | add (mul _ 2) _`
|
|
||||||
// add(mul(10, 2), 10) = add(20, 10) = 30
|
|
||||||
expect(code).toEvaluateTo(30)
|
|
||||||
})
|
|
||||||
})
|
})
|
||||||
|
|
|
||||||
261
src/editor/commands.ts
Normal file
261
src/editor/commands.ts
Normal file
|
|
@ -0,0 +1,261 @@
|
||||||
|
export type CommandShape = {
|
||||||
|
command: string
|
||||||
|
description?: string
|
||||||
|
args: ArgShape[]
|
||||||
|
execute: string | ((...args: any[]) => any)
|
||||||
|
}
|
||||||
|
|
||||||
|
type ArgShape<T extends keyof ArgTypeMap = keyof ArgTypeMap> = {
|
||||||
|
name: string
|
||||||
|
type: T
|
||||||
|
description?: string
|
||||||
|
optional?: boolean
|
||||||
|
default?: ArgTypeMap[T]
|
||||||
|
}
|
||||||
|
|
||||||
|
type ArgTypeMap = {
|
||||||
|
string: string
|
||||||
|
number: number
|
||||||
|
boolean: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
const commandShapes: CommandShape[] = [
|
||||||
|
{
|
||||||
|
command: 'ls',
|
||||||
|
description: 'List the contents of a directory',
|
||||||
|
execute: './commands/ls.ts',
|
||||||
|
args: [
|
||||||
|
{ name: 'path', type: 'string', description: 'The path to list' },
|
||||||
|
{ name: 'all', type: 'boolean', description: 'Show hidden files', default: false },
|
||||||
|
{ name: 'long', type: 'boolean', description: 'List in long format', default: false },
|
||||||
|
{
|
||||||
|
name: 'short-names',
|
||||||
|
type: 'boolean',
|
||||||
|
description: 'Only print file names',
|
||||||
|
default: false,
|
||||||
|
},
|
||||||
|
{ name: 'full-paths', type: 'boolean', description: 'Display full paths', default: false },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
|
||||||
|
{
|
||||||
|
command: 'cd',
|
||||||
|
description: 'Change the current working directory',
|
||||||
|
execute: './commands/cd.ts',
|
||||||
|
args: [{ name: 'path', type: 'string', description: 'The path to change to' }],
|
||||||
|
},
|
||||||
|
|
||||||
|
{
|
||||||
|
command: 'cp',
|
||||||
|
description: 'Copy files or directories',
|
||||||
|
execute: './commands/cp.ts',
|
||||||
|
args: [
|
||||||
|
{ name: 'source', type: 'string', description: 'Source file or directory' },
|
||||||
|
{ name: 'destination', type: 'string', description: 'Destination path' },
|
||||||
|
{ name: 'recursive', type: 'boolean', description: 'Copy recursively', default: false },
|
||||||
|
{ name: 'verbose', type: 'boolean', description: 'Verbose output', default: false },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
|
||||||
|
{
|
||||||
|
command: 'mv',
|
||||||
|
description: 'Move files or directories',
|
||||||
|
execute: './commands/mv.ts',
|
||||||
|
args: [
|
||||||
|
{ name: 'source', type: 'string', description: 'Source file or directory' },
|
||||||
|
{ name: 'destination', type: 'string', description: 'Destination path' },
|
||||||
|
{ name: 'verbose', type: 'boolean', description: 'Verbose output', default: false },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
|
||||||
|
{
|
||||||
|
command: 'rm',
|
||||||
|
description: 'Remove files or directories',
|
||||||
|
execute: './commands/rm.ts',
|
||||||
|
args: [
|
||||||
|
{ name: 'path', type: 'string', description: 'Path to remove' },
|
||||||
|
{ name: 'recursive', type: 'boolean', description: 'Remove recursively', default: false },
|
||||||
|
{ name: 'force', type: 'boolean', description: 'Force removal', default: false },
|
||||||
|
{ name: 'verbose', type: 'boolean', description: 'Verbose output', default: false },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
|
||||||
|
{
|
||||||
|
command: 'mkdir',
|
||||||
|
description: 'Create directories',
|
||||||
|
execute: './commands/mkdir.ts',
|
||||||
|
args: [
|
||||||
|
{ name: 'path', type: 'string', description: 'Directory path to create' },
|
||||||
|
{ name: 'verbose', type: 'boolean', description: 'Verbose output', default: false },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
|
||||||
|
{
|
||||||
|
command: 'touch',
|
||||||
|
description: 'Create empty files or update timestamps',
|
||||||
|
execute: './commands/touch.ts',
|
||||||
|
args: [
|
||||||
|
{ name: 'path', type: 'string', description: 'File path to touch' },
|
||||||
|
{ name: 'access', type: 'boolean', description: 'Update access time only', default: false },
|
||||||
|
{
|
||||||
|
name: 'modified',
|
||||||
|
type: 'boolean',
|
||||||
|
description: 'Update modified time only',
|
||||||
|
default: false,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
|
||||||
|
{
|
||||||
|
command: 'echo',
|
||||||
|
description: 'Display a string',
|
||||||
|
execute: './commands/echo.ts',
|
||||||
|
args: [
|
||||||
|
{ name: 'text', type: 'string', description: 'Text to display' },
|
||||||
|
{ name: 'no-newline', type: 'boolean', description: "Don't append newline", default: false },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
|
||||||
|
{
|
||||||
|
command: 'cat',
|
||||||
|
description: 'Display file contents',
|
||||||
|
execute: './commands/cat.ts',
|
||||||
|
args: [
|
||||||
|
{ name: 'path', type: 'string', description: 'File to display' },
|
||||||
|
{ name: 'numbered', type: 'boolean', description: 'Show line numbers', default: false },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
|
||||||
|
{
|
||||||
|
command: 'head',
|
||||||
|
description: 'Show first lines of input',
|
||||||
|
execute: './commands/head.ts',
|
||||||
|
args: [
|
||||||
|
{ name: 'path', type: 'string', description: 'File to read from' },
|
||||||
|
{ name: 'lines', type: 'number', description: 'Number of lines', default: 10 },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
|
||||||
|
{
|
||||||
|
command: 'tail',
|
||||||
|
description: 'Show last lines of input',
|
||||||
|
execute: './commands/tail.ts',
|
||||||
|
args: [
|
||||||
|
{ name: 'path', type: 'string', description: 'File to read from' },
|
||||||
|
{ name: 'lines', type: 'number', description: 'Number of lines', default: 10 },
|
||||||
|
{ name: 'follow', type: 'boolean', description: 'Follow file changes', default: false },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
|
||||||
|
{
|
||||||
|
command: 'grep',
|
||||||
|
description: 'Search for patterns in text',
|
||||||
|
execute: './commands/grep.ts',
|
||||||
|
args: [
|
||||||
|
{ name: 'pattern', type: 'string', description: 'Pattern to search for' },
|
||||||
|
{
|
||||||
|
name: 'ignore-case',
|
||||||
|
type: 'boolean',
|
||||||
|
description: 'Case insensitive search',
|
||||||
|
default: false,
|
||||||
|
},
|
||||||
|
{ name: 'invert-match', type: 'boolean', description: 'Invert match', default: false },
|
||||||
|
{ name: 'line-number', type: 'boolean', description: 'Show line numbers', default: false },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
|
||||||
|
{
|
||||||
|
command: 'sort',
|
||||||
|
description: 'Sort input',
|
||||||
|
execute: './commands/sort.ts',
|
||||||
|
args: [
|
||||||
|
{ name: 'reverse', type: 'boolean', description: 'Sort in reverse order', default: false },
|
||||||
|
{
|
||||||
|
name: 'ignore-case',
|
||||||
|
type: 'boolean',
|
||||||
|
description: 'Case insensitive sort',
|
||||||
|
default: false,
|
||||||
|
},
|
||||||
|
{ name: 'numeric', type: 'boolean', description: 'Numeric sort', default: false },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
|
||||||
|
{
|
||||||
|
command: 'uniq',
|
||||||
|
description: 'Filter out repeated lines',
|
||||||
|
execute: './commands/uniq.ts',
|
||||||
|
args: [
|
||||||
|
{ name: 'count', type: 'boolean', description: 'Show count of occurrences', default: false },
|
||||||
|
{
|
||||||
|
name: 'repeated',
|
||||||
|
type: 'boolean',
|
||||||
|
description: 'Show only repeated lines',
|
||||||
|
default: false,
|
||||||
|
},
|
||||||
|
{ name: 'unique', type: 'boolean', description: 'Show only unique lines', default: false },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
|
||||||
|
{
|
||||||
|
command: 'select',
|
||||||
|
description: 'Select specific columns from data',
|
||||||
|
execute: './commands/select.ts',
|
||||||
|
args: [{ name: 'columns', type: 'string', description: 'Columns to select' }],
|
||||||
|
},
|
||||||
|
|
||||||
|
{
|
||||||
|
command: 'where',
|
||||||
|
description: 'Filter data based on conditions',
|
||||||
|
execute: './commands/where.ts',
|
||||||
|
args: [{ name: 'condition', type: 'string', description: 'Filter condition' }],
|
||||||
|
},
|
||||||
|
|
||||||
|
{
|
||||||
|
command: 'group-by',
|
||||||
|
description: 'Group data by column values',
|
||||||
|
execute: './commands/group-by.ts',
|
||||||
|
args: [{ name: 'column', type: 'string', description: 'Column to group by' }],
|
||||||
|
},
|
||||||
|
|
||||||
|
{
|
||||||
|
command: 'ps',
|
||||||
|
description: 'List running processes',
|
||||||
|
execute: './commands/ps.ts',
|
||||||
|
args: [
|
||||||
|
{ name: 'long', type: 'boolean', description: 'Show detailed information', default: false },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
|
||||||
|
{
|
||||||
|
command: 'sys',
|
||||||
|
description: 'Show system information',
|
||||||
|
execute: './commands/sys.ts',
|
||||||
|
args: [],
|
||||||
|
},
|
||||||
|
|
||||||
|
{
|
||||||
|
command: 'which',
|
||||||
|
description: 'Find the location of a command',
|
||||||
|
execute: './commands/which.ts',
|
||||||
|
args: [
|
||||||
|
{ name: 'command', type: 'string', description: 'Command to locate' },
|
||||||
|
{ name: 'all', type: 'boolean', description: 'Show all matches', default: false },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
] as const
|
||||||
|
|
||||||
|
let commandSource = () => commandShapes
|
||||||
|
export const setCommandSource = (fn: () => CommandShape[]) => {
|
||||||
|
commandSource = fn
|
||||||
|
}
|
||||||
|
|
||||||
|
export const resetCommandSource = () => {
|
||||||
|
commandSource = () => commandShapes
|
||||||
|
}
|
||||||
|
|
||||||
|
export const matchingCommands = (prefix: string) => {
|
||||||
|
const match = commandSource().find((cmd) => cmd.command === prefix)
|
||||||
|
const partialMatches = commandSource().filter((cmd) => cmd.command.startsWith(prefix))
|
||||||
|
|
||||||
|
return { match, partialMatches }
|
||||||
|
}
|
||||||
|
|
@ -1,87 +0,0 @@
|
||||||
import { autocompletion, type CompletionContext, type Completion } from '@codemirror/autocomplete'
|
|
||||||
import { Shrimp } from '#/index'
|
|
||||||
|
|
||||||
const keywords = [
|
|
||||||
'import',
|
|
||||||
'end',
|
|
||||||
'do',
|
|
||||||
'if',
|
|
||||||
'else',
|
|
||||||
'while',
|
|
||||||
'try',
|
|
||||||
'catch',
|
|
||||||
'finally',
|
|
||||||
'throw',
|
|
||||||
'not',
|
|
||||||
'and',
|
|
||||||
'or',
|
|
||||||
'true',
|
|
||||||
'false',
|
|
||||||
'null',
|
|
||||||
]
|
|
||||||
|
|
||||||
const keywordCompletions: Completion[] = keywords.map((k) => ({
|
|
||||||
label: k,
|
|
||||||
type: 'keyword',
|
|
||||||
boost: -1,
|
|
||||||
}))
|
|
||||||
|
|
||||||
export const createShrimpCompletions = (shrimp: Shrimp) => {
|
|
||||||
// Build completions from all names in the shrimp scope
|
|
||||||
const scopeNames = shrimp.vm.scope.vars()
|
|
||||||
console.log(`🌭`, shrimp.vm.vars())
|
|
||||||
|
|
||||||
const functionCompletions: Completion[] = scopeNames.map((name) => {
|
|
||||||
const value = shrimp.vm.scope.get(name)
|
|
||||||
let type
|
|
||||||
if (value?.type === 'function' || value?.type === 'native') {
|
|
||||||
type = 'function'
|
|
||||||
} else if (value?.type === 'dict') {
|
|
||||||
type = 'namespace'
|
|
||||||
} else {
|
|
||||||
type = 'variable'
|
|
||||||
}
|
|
||||||
|
|
||||||
return { type, label: name }
|
|
||||||
})
|
|
||||||
|
|
||||||
const allCompletions = [...keywordCompletions, ...functionCompletions]
|
|
||||||
|
|
||||||
// Get methods for a module (e.g., math, str, list)
|
|
||||||
const getModuleMethods = (moduleName: string): Completion[] => {
|
|
||||||
const module = shrimp.get(moduleName)
|
|
||||||
if (!module || typeof module !== 'object') return []
|
|
||||||
|
|
||||||
return Object.keys(module).map((m) => ({ label: m, type: 'function' }))
|
|
||||||
}
|
|
||||||
|
|
||||||
const shrimpCompletionSource = (context: CompletionContext) => {
|
|
||||||
// Check for module.method pattern (e.g., "math.")
|
|
||||||
const dotMatch = context.matchBefore(/[\w\-]+\.[\w\-\?]*/)
|
|
||||||
if (dotMatch) {
|
|
||||||
const [moduleName, methodPrefix] = dotMatch.text.split('.')
|
|
||||||
const methods = getModuleMethods(moduleName!)
|
|
||||||
|
|
||||||
if (methods.length > 0) {
|
|
||||||
const dotPos = dotMatch.from + moduleName!.length + 1
|
|
||||||
return {
|
|
||||||
from: dotPos,
|
|
||||||
options: methods.filter((m) => m.label.startsWith(methodPrefix ?? '')),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Regular completions
|
|
||||||
const word = context.matchBefore(/[\w\-\?\$]+/)
|
|
||||||
if (!word || (word.from === word.to && !context.explicit)) return null
|
|
||||||
|
|
||||||
return {
|
|
||||||
from: word.from,
|
|
||||||
options: allCompletions.filter((c) => c.label.startsWith(word.text)),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return autocompletion({
|
|
||||||
override: [shrimpCompletionSource],
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
@ -1,24 +0,0 @@
|
||||||
import { linter, type Diagnostic } from '@codemirror/lint'
|
|
||||||
import { Shrimp } from '#/index'
|
|
||||||
import { CompilerError } from '#compiler/compilerError'
|
|
||||||
|
|
||||||
export const createShrimpDiagnostics = (shrimp: Shrimp) =>
|
|
||||||
linter((view) => {
|
|
||||||
const code = view.state.doc.toString()
|
|
||||||
const diagnostics: Diagnostic[] = []
|
|
||||||
|
|
||||||
try {
|
|
||||||
shrimp.parse(code)
|
|
||||||
} catch (err) {
|
|
||||||
if (err instanceof CompilerError) {
|
|
||||||
diagnostics.push({
|
|
||||||
from: err.from,
|
|
||||||
to: err.to,
|
|
||||||
severity: 'error',
|
|
||||||
message: err.message,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return diagnostics
|
|
||||||
})
|
|
||||||
|
|
@ -1,78 +1,53 @@
|
||||||
:root {
|
#output {
|
||||||
/* Background colors */
|
flex: 1;
|
||||||
--bg-editor: #011627;
|
background: var(--bg-output);
|
||||||
--bg-output: #40318D;
|
color: var(--text-output);
|
||||||
--bg-status-bar: #1E2A4A;
|
padding: 20px;
|
||||||
--bg-status-border: #0E1A3A;
|
overflow-y: auto;
|
||||||
--bg-selection: #1D3B53;
|
white-space: pre-wrap;
|
||||||
|
|
||||||
/* Text colors */
|
|
||||||
--text-editor: #D6DEEB;
|
|
||||||
--text-output: #7C70DA;
|
|
||||||
--text-status: #B3A9FF55;
|
|
||||||
--caret: #80A4C2;
|
|
||||||
|
|
||||||
/* Syntax highlighting colors */
|
|
||||||
--color-keyword: #C792EA;
|
|
||||||
--color-function: #82AAFF;
|
|
||||||
--color-string: #C3E88D;
|
|
||||||
--color-number: #F78C6C;
|
|
||||||
--color-bool: #FF5370;
|
|
||||||
--color-operator: #89DDFF;
|
|
||||||
--color-paren: #676E95;
|
|
||||||
--color-function-call: #FF9CAC;
|
|
||||||
--color-variable-def: #FFCB6B;
|
|
||||||
--color-error: #FF6E6E;
|
|
||||||
--color-regex: #E1ACFF;
|
|
||||||
|
|
||||||
/* ANSI terminal colors */
|
|
||||||
--ansi-black: #011627;
|
|
||||||
--ansi-red: #FF5370;
|
|
||||||
--ansi-green: #C3E88D;
|
|
||||||
--ansi-yellow: #FFCB6B;
|
|
||||||
--ansi-blue: #82AAFF;
|
|
||||||
--ansi-magenta: #C792EA;
|
|
||||||
--ansi-cyan: #89DDFF;
|
|
||||||
--ansi-white: #D6DEEB;
|
|
||||||
|
|
||||||
/* ANSI bright colors (slightly more vibrant) */
|
|
||||||
--ansi-bright-black: #676E95;
|
|
||||||
--ansi-bright-red: #FF6E90;
|
|
||||||
--ansi-bright-green: #D4F6A8;
|
|
||||||
--ansi-bright-yellow: #FFE082;
|
|
||||||
--ansi-bright-blue: #A8C7FA;
|
|
||||||
--ansi-bright-magenta: #E1ACFF;
|
|
||||||
--ansi-bright-cyan: #A8F5FF;
|
|
||||||
--ansi-bright-white: #FFFFFF;
|
|
||||||
}
|
|
||||||
|
|
||||||
@font-face {
|
|
||||||
font-family: 'C64ProMono';
|
|
||||||
src: url('../../assets/C64_Pro_Mono-STYLE.woff2') format('woff2');
|
|
||||||
font-weight: normal;
|
|
||||||
font-style: normal;
|
|
||||||
}
|
|
||||||
|
|
||||||
@font-face {
|
|
||||||
font-family: 'Pixeloid Mono';
|
|
||||||
src: url('../../assets/PixeloidMono.ttf') format('truetype');
|
|
||||||
font-weight: normal;
|
|
||||||
font-style: normal;
|
|
||||||
}
|
|
||||||
|
|
||||||
* {
|
|
||||||
margin: 0;
|
|
||||||
padding: 0;
|
|
||||||
box-sizing: border-box;
|
|
||||||
}
|
|
||||||
|
|
||||||
body {
|
|
||||||
background-color: var(--bg-editor);
|
|
||||||
font-family: 'Pixeloid Mono', 'Courier New', monospace;
|
font-family: 'Pixeloid Mono', 'Courier New', monospace;
|
||||||
font-size: 18px;
|
font-size: 18px;
|
||||||
height: 100vh;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
#root {
|
#status-bar {
|
||||||
height: 100vh;
|
height: 30px;
|
||||||
|
background: var(--bg-status-bar);
|
||||||
|
color: var(--text-status);
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
padding: 0 10px;
|
||||||
|
font-size: 14px;
|
||||||
|
border-top: 3px solid var(--bg-status-border);
|
||||||
|
border-bottom: 3px solid var(--bg-status-border);
|
||||||
|
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
}
|
||||||
|
|
||||||
|
#status-bar .left,
|
||||||
|
#status-bar .right {
|
||||||
|
display: flex;
|
||||||
|
justify-content: center;
|
||||||
|
gap: 2rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
#status-bar .multiline {
|
||||||
|
display: flex;
|
||||||
|
|
||||||
|
.dot {
|
||||||
|
padding-top: 1px;
|
||||||
|
margin-right: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.active {
|
||||||
|
color: var(--color-string);
|
||||||
|
}
|
||||||
|
|
||||||
|
.inactive {
|
||||||
|
color: inherit;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.syntax-error {
|
||||||
|
text-decoration: underline dotted var(--color-error);
|
||||||
}
|
}
|
||||||
|
|
@ -1,70 +1,140 @@
|
||||||
import { EditorView, basicSetup } from 'codemirror'
|
import { EditorView } from '@codemirror/view'
|
||||||
import { Shrimp } from '#/index'
|
import { asciiEscapeToHtml, assertNever, log, toElement } from '#utils/utils'
|
||||||
|
import { Signal } from '#utils/signal'
|
||||||
|
import { getContent } from '#editor/plugins/persistence'
|
||||||
|
import type { HtmlEscapedString } from 'hono/utils/html'
|
||||||
|
import { connectToNose, noseSignals } from '#editor/noseClient'
|
||||||
|
import type { Value } from 'reefvm'
|
||||||
|
import { Compartment } from '@codemirror/state'
|
||||||
|
import { lineNumbers } from '@codemirror/view'
|
||||||
|
import { shrimpSetup } from '#editor/plugins/shrimpSetup'
|
||||||
|
|
||||||
import { shrimpTheme } from './theme'
|
import '#editor/editor.css'
|
||||||
import { createShrimpDiagnostics } from './diagnostics'
|
|
||||||
import { shrimpHighlighter } from './highlighter'
|
|
||||||
import { createShrimpCompletions } from './completions'
|
|
||||||
import { shrimpKeymap } from './keymap'
|
|
||||||
import { getContent, persistence } from './persistence'
|
|
||||||
|
|
||||||
type EditorProps = {
|
const lineNumbersCompartment = new Compartment()
|
||||||
initialCode?: string
|
|
||||||
onChange?: (code: string) => void
|
|
||||||
extensions?: import('@codemirror/state').Extension[]
|
|
||||||
shrimp?: Shrimp
|
|
||||||
}
|
|
||||||
|
|
||||||
export const Editor = ({
|
connectToNose()
|
||||||
initialCode = '',
|
|
||||||
onChange,
|
export const outputSignal = new Signal<Value | string>()
|
||||||
extensions: customExtensions = [],
|
export const errorSignal = new Signal<string>()
|
||||||
shrimp = new Shrimp(),
|
export const multilineModeSignal = new Signal<boolean>()
|
||||||
}: EditorProps) => {
|
|
||||||
|
export const Editor = () => {
|
||||||
return (
|
return (
|
||||||
|
<>
|
||||||
<div
|
<div
|
||||||
ref={(el: Element) => {
|
ref={(ref: Element) => {
|
||||||
if (!el?.querySelector('.cm-editor'))
|
if (ref?.querySelector('.cm-editor')) return
|
||||||
createEditorView(el, getContent() ?? initialCode, onChange, customExtensions, shrimp)
|
const view = new EditorView({
|
||||||
|
parent: ref,
|
||||||
|
doc: getContent(),
|
||||||
|
extensions: shrimpSetup(lineNumbersCompartment),
|
||||||
|
})
|
||||||
|
|
||||||
|
multilineModeSignal.connect((isMultiline) => {
|
||||||
|
view.dispatch({
|
||||||
|
effects: lineNumbersCompartment.reconfigure(isMultiline ? lineNumbers() : []),
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
requestAnimationFrame(() => view.focus())
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
|
<div id="status-bar">
|
||||||
|
<div className="left"></div>
|
||||||
|
<div className="right"></div>
|
||||||
|
</div>
|
||||||
|
<div id="output"></div>
|
||||||
|
<div id="error"></div>
|
||||||
|
</>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
const createEditorView = (
|
noseSignals.connect((message) => {
|
||||||
el: Element,
|
if (message.type === 'error') {
|
||||||
initialCode: string,
|
log.error(`Nose error: ${message.data}`)
|
||||||
onChange: ((code: string) => void) | undefined,
|
errorSignal.emit(`Nose error: ${message.data}`)
|
||||||
customExtensions: import('@codemirror/state').Extension[],
|
} else if (message.type === 'reef-output') {
|
||||||
shrimp: Shrimp
|
const x = outputSignal.emit(message.data)
|
||||||
) => {
|
} else if (message.type === 'connected') {
|
||||||
const extensions = [
|
outputSignal.emit(`╞ Connected to Nose VM`)
|
||||||
basicSetup,
|
|
||||||
shrimpTheme,
|
|
||||||
createShrimpDiagnostics(shrimp),
|
|
||||||
createShrimpCompletions(shrimp),
|
|
||||||
shrimpHighlighter,
|
|
||||||
shrimpKeymap,
|
|
||||||
persistence,
|
|
||||||
...customExtensions,
|
|
||||||
]
|
|
||||||
|
|
||||||
if (onChange) {
|
|
||||||
extensions.push(
|
|
||||||
EditorView.updateListener.of((update) => {
|
|
||||||
if (update.docChanged) {
|
|
||||||
onChange(update.state.doc.toString())
|
|
||||||
}
|
|
||||||
})
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
|
})
|
||||||
|
|
||||||
new EditorView({
|
outputSignal.connect((value) => {
|
||||||
parent: el,
|
const el = document.querySelector('#output')!
|
||||||
doc: initialCode,
|
el.innerHTML = ''
|
||||||
extensions,
|
el.innerHTML = asciiEscapeToHtml(valueToString(value))
|
||||||
})
|
})
|
||||||
|
|
||||||
// Trigger onChange with initial content
|
errorSignal.connect((error) => {
|
||||||
onChange?.(initialCode)
|
const el = document.querySelector('#output')!
|
||||||
|
el.innerHTML = ''
|
||||||
|
el.classList.add('error')
|
||||||
|
el.innerHTML = asciiEscapeToHtml(error)
|
||||||
|
})
|
||||||
|
|
||||||
|
type StatusBarMessage = {
|
||||||
|
side: 'left' | 'right'
|
||||||
|
message: string | Promise<HtmlEscapedString>
|
||||||
|
className: string
|
||||||
|
order?: number
|
||||||
|
}
|
||||||
|
export const statusBarSignal = new Signal<StatusBarMessage>()
|
||||||
|
statusBarSignal.connect(async ({ side, message, className, order }) => {
|
||||||
|
document.querySelector(`#status-bar .${className}`)?.remove()
|
||||||
|
|
||||||
|
const sideEl = document.querySelector(`#status-bar .${side}`)!
|
||||||
|
const messageEl = (
|
||||||
|
<div data-order={order ?? 0} className={className}>
|
||||||
|
{await message}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
|
||||||
|
// Now go through the nodes and put it in the right spot based on order. Higher number means further right
|
||||||
|
const nodes = Array.from(sideEl.childNodes)
|
||||||
|
const index = nodes.findIndex((node) => {
|
||||||
|
if (!(node instanceof HTMLElement)) return false
|
||||||
|
return Number(node.dataset.order) > (order ?? 0)
|
||||||
|
})
|
||||||
|
|
||||||
|
if (index === -1) {
|
||||||
|
sideEl.appendChild(toElement(messageEl))
|
||||||
|
} else {
|
||||||
|
sideEl.insertBefore(toElement(messageEl), nodes[index]!)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
const valueToString = (value: Value | string): string => {
|
||||||
|
if (typeof value === 'string') {
|
||||||
|
return value
|
||||||
|
}
|
||||||
|
|
||||||
|
switch (value.type) {
|
||||||
|
case 'null':
|
||||||
|
return 'null'
|
||||||
|
case 'boolean':
|
||||||
|
return value.value ? 'true' : 'false'
|
||||||
|
case 'number':
|
||||||
|
return value.value.toString()
|
||||||
|
case 'string':
|
||||||
|
return value.value
|
||||||
|
case 'array':
|
||||||
|
return `${value.value.map(valueToString).join('\n')}`
|
||||||
|
case 'dict': {
|
||||||
|
const entries = Array.from(value.value.entries()).map(
|
||||||
|
([key, val]) => `"${key}": ${valueToString(val)}`,
|
||||||
|
)
|
||||||
|
return `{${entries.join(', ')}}`
|
||||||
|
}
|
||||||
|
case 'regex':
|
||||||
|
return `/${value.value.source}/`
|
||||||
|
case 'function':
|
||||||
|
return `<function>`
|
||||||
|
case 'native':
|
||||||
|
return `<function ${value.fn.name}>`
|
||||||
|
default:
|
||||||
|
assertNever(value)
|
||||||
|
return `<unknown value type: ${(value as any).type}>`
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,6 +0,0 @@
|
||||||
import { Editor } from '#editor/editor'
|
|
||||||
import { render } from 'hono/jsx/dom'
|
|
||||||
|
|
||||||
if (typeof document !== 'undefined') {
|
|
||||||
render(<Editor initialCode={'# type some code'} />, document.getElementById('root')!)
|
|
||||||
}
|
|
||||||
|
|
@ -1,11 +0,0 @@
|
||||||
<!DOCTYPE html>
|
|
||||||
<html>
|
|
||||||
<head>
|
|
||||||
<meta charset="UTF-8" />
|
|
||||||
<link rel="stylesheet" href="../editor.css" />
|
|
||||||
</head>
|
|
||||||
<body>
|
|
||||||
<div id="root"></div>
|
|
||||||
<script type="module" src="./frontend.tsx"></script>
|
|
||||||
</body>
|
|
||||||
</html>
|
|
||||||
|
|
@ -1,14 +0,0 @@
|
||||||
import index from './index.html'
|
|
||||||
|
|
||||||
const server = Bun.serve({
|
|
||||||
port: 3000,
|
|
||||||
routes: {
|
|
||||||
'/': index,
|
|
||||||
},
|
|
||||||
development: {
|
|
||||||
hmr: true,
|
|
||||||
console: true,
|
|
||||||
},
|
|
||||||
})
|
|
||||||
|
|
||||||
console.log(`Editor running at ${server.url}`)
|
|
||||||
|
|
@ -1,73 +0,0 @@
|
||||||
import { EditorView, Decoration, DecorationSet, ViewPlugin, ViewUpdate } from '@codemirror/view'
|
|
||||||
import { RangeSetBuilder } from '@codemirror/state'
|
|
||||||
import { Shrimp } from '#/index'
|
|
||||||
import { type SyntaxNode } from '#parser/node'
|
|
||||||
import { log } from '#utils/utils'
|
|
||||||
|
|
||||||
const shrimp = new Shrimp()
|
|
||||||
|
|
||||||
export const shrimpHighlighter = ViewPlugin.fromClass(
|
|
||||||
class {
|
|
||||||
decorations: DecorationSet
|
|
||||||
|
|
||||||
constructor(view: EditorView) {
|
|
||||||
this.decorations = this.highlight(view)
|
|
||||||
}
|
|
||||||
|
|
||||||
update(update: ViewUpdate) {
|
|
||||||
if (!update.docChanged) return
|
|
||||||
|
|
||||||
this.decorations = this.highlight(update.view)
|
|
||||||
}
|
|
||||||
|
|
||||||
highlight(view: EditorView): DecorationSet {
|
|
||||||
const builder = new RangeSetBuilder<Decoration>()
|
|
||||||
const code = view.state.doc.toString()
|
|
||||||
|
|
||||||
try {
|
|
||||||
const tree = shrimp.parse(code)
|
|
||||||
const decorations: { from: number; to: number; class: string }[] = []
|
|
||||||
|
|
||||||
tree.iterate({
|
|
||||||
enter: (node) => {
|
|
||||||
const cls = tokenStyles[node.type.name]
|
|
||||||
const isLeaf = node.children.length === 0
|
|
||||||
if (cls && isLeaf) {
|
|
||||||
decorations.push({ from: node.from, to: node.to, class: cls })
|
|
||||||
}
|
|
||||||
},
|
|
||||||
})
|
|
||||||
|
|
||||||
// Sort by position (required by RangeSetBuilder)
|
|
||||||
decorations.sort((a, b) => a.from - b.from)
|
|
||||||
|
|
||||||
for (const d of decorations) {
|
|
||||||
builder.add(d.from, d.to, Decoration.mark({ class: d.class }))
|
|
||||||
}
|
|
||||||
} catch (error) {
|
|
||||||
log('Parsing error in highlighter', error)
|
|
||||||
}
|
|
||||||
|
|
||||||
return builder.finish()
|
|
||||||
}
|
|
||||||
},
|
|
||||||
{
|
|
||||||
decorations: (v) => v.decorations,
|
|
||||||
}
|
|
||||||
)
|
|
||||||
|
|
||||||
// Map node types to CSS classes
|
|
||||||
const tokenStyles: Record<string, string> = {
|
|
||||||
keyword: 'tok-keyword',
|
|
||||||
String: 'tok-string',
|
|
||||||
StringFragment: 'tok-string',
|
|
||||||
CurlyString: 'tok-string',
|
|
||||||
Number: 'tok-number',
|
|
||||||
Boolean: 'tok-bool',
|
|
||||||
Null: 'tok-null',
|
|
||||||
Identifier: 'tok-identifier',
|
|
||||||
AssignableIdentifier: 'tok-variable-def',
|
|
||||||
Comment: 'tok-comment',
|
|
||||||
operator: 'tok-operator',
|
|
||||||
Regex: 'tok-regex',
|
|
||||||
}
|
|
||||||
|
|
@ -1 +0,0 @@
|
||||||
export { Editor } from './editor'
|
|
||||||
|
|
@ -1,8 +0,0 @@
|
||||||
import { keymap } from '@codemirror/view'
|
|
||||||
import { acceptCompletion } from '@codemirror/autocomplete'
|
|
||||||
import { indentWithTab } from '@codemirror/commands'
|
|
||||||
|
|
||||||
export const shrimpKeymap = keymap.of([
|
|
||||||
{ key: 'Tab', run: acceptCompletion },
|
|
||||||
indentWithTab,
|
|
||||||
])
|
|
||||||
59
src/editor/noseClient.ts
Normal file
59
src/editor/noseClient.ts
Normal file
|
|
@ -0,0 +1,59 @@
|
||||||
|
import { Signal } from '#utils/signal'
|
||||||
|
import type { Bytecode, Value } from 'reefvm'
|
||||||
|
let ws: WebSocket
|
||||||
|
|
||||||
|
type IncomingMessage =
|
||||||
|
| { type: 'connected' }
|
||||||
|
| { type: 'ping'; data: number }
|
||||||
|
| { type: 'commands'; data: number }
|
||||||
|
| {
|
||||||
|
type: 'apps'
|
||||||
|
data: {
|
||||||
|
name: string
|
||||||
|
type: 'browser' | 'server'
|
||||||
|
}[]
|
||||||
|
}
|
||||||
|
| {
|
||||||
|
type: 'session:start'
|
||||||
|
data: {
|
||||||
|
NOSE_DIR: string
|
||||||
|
cwd: string
|
||||||
|
hostname: string
|
||||||
|
mode: string
|
||||||
|
project: string
|
||||||
|
}
|
||||||
|
}
|
||||||
|
| { type: 'reef-output'; data: Value }
|
||||||
|
| { type: 'error'; data: string }
|
||||||
|
|
||||||
|
export const noseSignals = new Signal<IncomingMessage>()
|
||||||
|
|
||||||
|
export const connectToNose = (url: string = 'ws://localhost:3000/ws') => {
|
||||||
|
ws = new WebSocket(url)
|
||||||
|
ws.onopen = () => noseSignals.emit({ type: 'connected' })
|
||||||
|
|
||||||
|
ws.onmessage = (event) => {
|
||||||
|
const message = JSON.parse(event.data)
|
||||||
|
noseSignals.emit(message)
|
||||||
|
}
|
||||||
|
|
||||||
|
ws.onerror = (event) => {
|
||||||
|
console.error(`💥WebSocket error:`, event)
|
||||||
|
}
|
||||||
|
|
||||||
|
ws.onclose = () => {
|
||||||
|
console.log(`🚪 Connection closed`)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let id = 0
|
||||||
|
export const sendToNose = (code: Bytecode) => {
|
||||||
|
if (!ws) {
|
||||||
|
throw new Error('WebSocket is not connected.')
|
||||||
|
} else if (ws.readyState !== WebSocket.OPEN) {
|
||||||
|
throw new Error(`WebSocket is not open, current status is ${ws.readyState}.`)
|
||||||
|
}
|
||||||
|
|
||||||
|
id += 1
|
||||||
|
ws.send(JSON.stringify({ type: 'reef-bytecode', data: code, id }))
|
||||||
|
}
|
||||||
9
src/editor/plugins/catchErrors.ts
Normal file
9
src/editor/plugins/catchErrors.ts
Normal file
|
|
@ -0,0 +1,9 @@
|
||||||
|
import { errorSignal } from '#editor/editor'
|
||||||
|
import { EditorView } from '@codemirror/view'
|
||||||
|
|
||||||
|
export const catchErrors = EditorView.exceptionSink.of((exception) => {
|
||||||
|
console.error('CodeMirror error:', exception)
|
||||||
|
errorSignal.emit(
|
||||||
|
`Editor error: ${exception instanceof Error ? exception.message : String(exception)}`,
|
||||||
|
)
|
||||||
|
})
|
||||||
35
src/editor/plugins/debugTags.ts
Normal file
35
src/editor/plugins/debugTags.ts
Normal file
|
|
@ -0,0 +1,35 @@
|
||||||
|
import { EditorView, ViewPlugin, ViewUpdate } from '@codemirror/view'
|
||||||
|
import { syntaxTree } from '@codemirror/language'
|
||||||
|
import { statusBarSignal } from '#editor/editor'
|
||||||
|
|
||||||
|
export const debugTags = ViewPlugin.fromClass(
|
||||||
|
class {
|
||||||
|
update(update: ViewUpdate) {
|
||||||
|
if (update.docChanged || update.selectionSet || update.geometryChanged) {
|
||||||
|
this.updateStatusBar(update.view)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
updateStatusBar(view: EditorView) {
|
||||||
|
const pos = view.state.selection.main.head + 1
|
||||||
|
const tree = syntaxTree(view.state)
|
||||||
|
|
||||||
|
let tags: string[] = []
|
||||||
|
let node = tree.resolveInner(pos, -1)
|
||||||
|
|
||||||
|
while (node) {
|
||||||
|
tags.push(node.type.name)
|
||||||
|
node = node.parent!
|
||||||
|
if (!node) break
|
||||||
|
}
|
||||||
|
|
||||||
|
const debugText = tags.length ? tags.reverse().slice(1).join(' > ') : 'No nodes'
|
||||||
|
statusBarSignal.emit({
|
||||||
|
side: 'right',
|
||||||
|
message: debugText,
|
||||||
|
className: 'debug-tags',
|
||||||
|
order: -1,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
},
|
||||||
|
)
|
||||||
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,
|
||||||
|
},
|
||||||
|
)
|
||||||
232
src/editor/plugins/inlineHints.tsx
Normal file
232
src/editor/plugins/inlineHints.tsx
Normal file
|
|
@ -0,0 +1,232 @@
|
||||||
|
import {
|
||||||
|
ViewPlugin,
|
||||||
|
ViewUpdate,
|
||||||
|
EditorView,
|
||||||
|
Decoration,
|
||||||
|
type DecorationSet,
|
||||||
|
} from '@codemirror/view'
|
||||||
|
import { syntaxTree } from '@codemirror/language'
|
||||||
|
import { type SyntaxNode } from '@lezer/common'
|
||||||
|
import { WidgetType } from '@codemirror/view'
|
||||||
|
import { toElement } from '#utils/utils'
|
||||||
|
import { matchingCommands } from '#editor/commands'
|
||||||
|
import * as Terms from '#parser/shrimp.terms'
|
||||||
|
|
||||||
|
const ghostTextTheme = EditorView.theme({
|
||||||
|
'.ghost-text': {
|
||||||
|
color: '#666',
|
||||||
|
opacity: '0.6',
|
||||||
|
fontStyle: 'italic',
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
type Hint = { cursor: number; hintText?: string; completionText?: string }
|
||||||
|
|
||||||
|
export const inlineHints = [
|
||||||
|
ViewPlugin.fromClass(
|
||||||
|
class {
|
||||||
|
decorations: DecorationSet = Decoration.none
|
||||||
|
currentHint?: Hint
|
||||||
|
|
||||||
|
update(update: ViewUpdate) {
|
||||||
|
if (!update.docChanged && !update.selectionSet) return
|
||||||
|
|
||||||
|
this.clearHints()
|
||||||
|
let hint = this.getContext(update.view)
|
||||||
|
this.currentHint = hint
|
||||||
|
this.showHint(hint)
|
||||||
|
}
|
||||||
|
|
||||||
|
handleTab(view: EditorView) {
|
||||||
|
if (!this.currentHint?.completionText) return false
|
||||||
|
|
||||||
|
this.decorations = Decoration.none
|
||||||
|
view.dispatch({
|
||||||
|
changes: {
|
||||||
|
from: this.currentHint.cursor,
|
||||||
|
insert: this.currentHint.completionText,
|
||||||
|
},
|
||||||
|
selection: {
|
||||||
|
anchor: this.currentHint.cursor + this.currentHint.completionText.length,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
this.currentHint = undefined
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
clearHints() {
|
||||||
|
this.currentHint = undefined
|
||||||
|
this.decorations = Decoration.none
|
||||||
|
}
|
||||||
|
|
||||||
|
getContext(view: EditorView): Hint {
|
||||||
|
const cursor = view.state.selection.main.head
|
||||||
|
|
||||||
|
const isCursorAtEnd = cursor === view.state.doc.length
|
||||||
|
if (!isCursorAtEnd) return { cursor }
|
||||||
|
|
||||||
|
const token = this.getCommandContextToken(view, cursor)
|
||||||
|
if (!token) return { cursor }
|
||||||
|
|
||||||
|
const text = view.state.doc.sliceString(token.from, token.to)
|
||||||
|
const tokenId = token.type.id
|
||||||
|
|
||||||
|
let completionText = ''
|
||||||
|
let hintText = ''
|
||||||
|
const justSpaces = view.state.doc.sliceString(cursor - 1, cursor) === ' '
|
||||||
|
|
||||||
|
if (tokenId === Terms.CommandPartial) {
|
||||||
|
const { partialMatches } = matchingCommands(text)
|
||||||
|
const match = partialMatches[0]
|
||||||
|
if (match) {
|
||||||
|
completionText = match.command.slice(text.length) + ' '
|
||||||
|
hintText = completionText
|
||||||
|
}
|
||||||
|
} else if (
|
||||||
|
tokenId === Terms.Identifier &&
|
||||||
|
token.parent?.type.id === Terms.Arg &&
|
||||||
|
!justSpaces
|
||||||
|
) {
|
||||||
|
const { availableArgs } = this.getCommandContext(view, token)
|
||||||
|
const matchingArgs = availableArgs.filter((arg) => arg.name.startsWith(text))
|
||||||
|
const match = matchingArgs[0]
|
||||||
|
if (match) {
|
||||||
|
hintText = `${match.name.slice(text.length)}=<${match.type}>`
|
||||||
|
completionText = `${match.name.slice(text.length)}=`
|
||||||
|
}
|
||||||
|
} else if (this.containedBy(token, Terms.PartialNamedArg)) {
|
||||||
|
const { availableArgs } = this.getCommandContext(view, token)
|
||||||
|
const textWithoutEquals = text.slice(0, -1)
|
||||||
|
const matchingArgs = availableArgs.filter((arg) => arg.name == textWithoutEquals)
|
||||||
|
const match = matchingArgs[0]
|
||||||
|
if (match) {
|
||||||
|
hintText = `<${match.type}>`
|
||||||
|
completionText = 'default' in match ? `${match.default}` : ''
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
const { availableArgs } = this.getCommandContext(view, token)
|
||||||
|
const nextArg = Array.from(availableArgs)[0]
|
||||||
|
const space = justSpaces ? '' : ' '
|
||||||
|
if (nextArg) {
|
||||||
|
hintText = `${space}${nextArg.name}=<${nextArg.type}>`
|
||||||
|
if (nextArg) {
|
||||||
|
completionText = `${space}${nextArg.name}=`
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return { completionText, hintText, cursor }
|
||||||
|
}
|
||||||
|
|
||||||
|
getCommandContextToken(view: EditorView, cursor: number) {
|
||||||
|
const tree = syntaxTree(view.state)
|
||||||
|
let node = tree.resolveInner(cursor, -1)
|
||||||
|
|
||||||
|
// If we're in a CommandCall, return the token before cursor
|
||||||
|
if (this.containedBy(node, Terms.CommandCall)) {
|
||||||
|
return tree.resolveInner(cursor, -1)
|
||||||
|
}
|
||||||
|
|
||||||
|
// If we're in Program, look backward
|
||||||
|
while (node.name === 'Program' && cursor > 0) {
|
||||||
|
cursor -= 1
|
||||||
|
node = tree.resolveInner(cursor, -1)
|
||||||
|
if (this.containedBy(node, Terms.CommandCall)) {
|
||||||
|
return tree.resolveInner(cursor, -1)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
containedBy(node: SyntaxNode, nodeId: number): SyntaxNode | undefined {
|
||||||
|
let current: SyntaxNode | undefined = node
|
||||||
|
|
||||||
|
while (current) {
|
||||||
|
if (current.type.id === nodeId) {
|
||||||
|
return current
|
||||||
|
}
|
||||||
|
|
||||||
|
current = current.parent ?? undefined
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
showHint(hint: Hint) {
|
||||||
|
if (!hint.hintText) return
|
||||||
|
|
||||||
|
const widget = new GhostTextWidget(hint.hintText)
|
||||||
|
const afterCursor = 1
|
||||||
|
const decoration = Decoration.widget({ widget, side: afterCursor }).range(hint.cursor)
|
||||||
|
this.decorations = Decoration.set([decoration])
|
||||||
|
}
|
||||||
|
|
||||||
|
getCommandContext(view: EditorView, currentToken: SyntaxNode) {
|
||||||
|
let commandCallNode = currentToken.parent
|
||||||
|
while (commandCallNode?.type.name !== 'CommandCall') {
|
||||||
|
if (!commandCallNode) {
|
||||||
|
throw new Error('No CommandCall parent found, must be an error in the grammar')
|
||||||
|
}
|
||||||
|
commandCallNode = commandCallNode.parent
|
||||||
|
}
|
||||||
|
|
||||||
|
const commandToken = commandCallNode.firstChild
|
||||||
|
if (!commandToken) {
|
||||||
|
throw new Error('CommandCall has no children, must be an error in the grammar')
|
||||||
|
}
|
||||||
|
|
||||||
|
const commandText = view.state.doc.sliceString(commandToken.from, commandToken.to)
|
||||||
|
const { match: commandShape } = matchingCommands(commandText)
|
||||||
|
if (!commandShape) {
|
||||||
|
throw new Error(`No command shape found for command "${commandText}"`)
|
||||||
|
}
|
||||||
|
|
||||||
|
let availableArgs = [...commandShape.args]
|
||||||
|
|
||||||
|
// Walk through all NamedArg children
|
||||||
|
let child = commandToken.nextSibling
|
||||||
|
|
||||||
|
while (child) {
|
||||||
|
console.log('child', child.type.name, child.to - child.from)
|
||||||
|
if (child.type.id === Terms.NamedArg) {
|
||||||
|
const argName = child.firstChild // Should be the Identifier
|
||||||
|
if (argName) {
|
||||||
|
const argText = view.state.doc.sliceString(argName.from, argName.to - 1)
|
||||||
|
availableArgs = availableArgs.filter((arg) => arg.name !== argText)
|
||||||
|
}
|
||||||
|
} else if (child.type.id == Terms.Arg) {
|
||||||
|
const hasSpaceAfter = view.state.doc.sliceString(child.to, child.to + 1) === ' '
|
||||||
|
if (hasSpaceAfter) {
|
||||||
|
availableArgs.shift()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
child = child.nextSibling
|
||||||
|
}
|
||||||
|
|
||||||
|
return { commandShape, availableArgs }
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
decorations: (v) => v.decorations,
|
||||||
|
eventHandlers: {
|
||||||
|
keydown(event, view) {
|
||||||
|
if (event.key === 'Tab') {
|
||||||
|
event.preventDefault()
|
||||||
|
const plugin = view.plugin(inlineHints[0]! as ViewPlugin<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)
|
||||||
|
}
|
||||||
|
}
|
||||||
184
src/editor/plugins/keymap.tsx
Normal file
184
src/editor/plugins/keymap.tsx
Normal file
|
|
@ -0,0 +1,184 @@
|
||||||
|
import { multilineModeSignal, outputSignal } from '#editor/editor'
|
||||||
|
import { printBytecodeOutput, printParserOutput, runCode } from '#editor/runCode'
|
||||||
|
import { EditorState } from '@codemirror/state'
|
||||||
|
import { keymap } from '@codemirror/view'
|
||||||
|
|
||||||
|
let multilineMode = false
|
||||||
|
|
||||||
|
const customKeymap = keymap.of([
|
||||||
|
{
|
||||||
|
key: 'Enter',
|
||||||
|
run: (view) => {
|
||||||
|
if (multilineMode) return false
|
||||||
|
|
||||||
|
const input = view.state.doc.toString()
|
||||||
|
history.push(input)
|
||||||
|
runCode(input)
|
||||||
|
view.dispatch({
|
||||||
|
changes: { from: 0, to: view.state.doc.length, insert: '' },
|
||||||
|
selection: { anchor: 0 },
|
||||||
|
})
|
||||||
|
return true
|
||||||
|
},
|
||||||
|
},
|
||||||
|
|
||||||
|
{
|
||||||
|
key: 'Shift-Enter',
|
||||||
|
run: (view) => {
|
||||||
|
if (multilineMode) {
|
||||||
|
const input = view.state.doc.toString()
|
||||||
|
runCode(input)
|
||||||
|
|
||||||
|
return true
|
||||||
|
} else {
|
||||||
|
outputSignal.emit('Press Shift+Enter to insert run the code.')
|
||||||
|
}
|
||||||
|
|
||||||
|
multilineModeSignal.emit(true)
|
||||||
|
multilineMode = true
|
||||||
|
view.dispatch({
|
||||||
|
changes: { from: view.state.doc.length, insert: '\n' },
|
||||||
|
selection: { anchor: view.state.doc.length + 1 },
|
||||||
|
})
|
||||||
|
|
||||||
|
return true
|
||||||
|
},
|
||||||
|
},
|
||||||
|
|
||||||
|
{
|
||||||
|
key: 'Tab',
|
||||||
|
preventDefault: true,
|
||||||
|
run: (view) => {
|
||||||
|
view.dispatch({
|
||||||
|
changes: { from: view.state.selection.main.from, insert: ' ' },
|
||||||
|
selection: { anchor: view.state.selection.main.from + 2 },
|
||||||
|
})
|
||||||
|
return true
|
||||||
|
},
|
||||||
|
},
|
||||||
|
|
||||||
|
{
|
||||||
|
key: 'ArrowUp',
|
||||||
|
run: (view) => {
|
||||||
|
if (multilineMode) return false
|
||||||
|
|
||||||
|
const command = history.previous()
|
||||||
|
if (command === undefined) return false
|
||||||
|
view.dispatch({
|
||||||
|
changes: { from: 0, to: view.state.doc.length, insert: command },
|
||||||
|
selection: { anchor: command.length },
|
||||||
|
})
|
||||||
|
return true
|
||||||
|
},
|
||||||
|
},
|
||||||
|
|
||||||
|
{
|
||||||
|
key: 'ArrowDown',
|
||||||
|
run: (view) => {
|
||||||
|
if (multilineMode) return false
|
||||||
|
|
||||||
|
const command = history.next()
|
||||||
|
if (command === undefined) return false
|
||||||
|
view.dispatch({
|
||||||
|
changes: { from: 0, to: view.state.doc.length, insert: command },
|
||||||
|
selection: { anchor: command.length },
|
||||||
|
})
|
||||||
|
return true
|
||||||
|
},
|
||||||
|
},
|
||||||
|
|
||||||
|
{
|
||||||
|
key: 'Mod-k 1',
|
||||||
|
preventDefault: true,
|
||||||
|
run: (view) => {
|
||||||
|
const input = view.state.doc.toString()
|
||||||
|
printParserOutput(input)
|
||||||
|
|
||||||
|
return true
|
||||||
|
},
|
||||||
|
},
|
||||||
|
|
||||||
|
{
|
||||||
|
key: 'Mod-k 2',
|
||||||
|
preventDefault: true,
|
||||||
|
run: (view) => {
|
||||||
|
const input = view.state.doc.toString()
|
||||||
|
printBytecodeOutput(input)
|
||||||
|
|
||||||
|
return true
|
||||||
|
},
|
||||||
|
},
|
||||||
|
])
|
||||||
|
|
||||||
|
let firstTime = true
|
||||||
|
const singleLineFilter = EditorState.transactionFilter.of((transaction) => {
|
||||||
|
if (multilineMode) return transaction // Allow everything in multiline mode
|
||||||
|
|
||||||
|
if (firstTime) {
|
||||||
|
firstTime = false
|
||||||
|
if (transaction.newDoc.toString().includes('\n')) {
|
||||||
|
multilineMode = true
|
||||||
|
return transaction
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
transaction.changes.iterChanges((fromA, toA, fromB, toB, inserted) => {
|
||||||
|
if (inserted.toString().includes('\n')) {
|
||||||
|
multilineMode = true
|
||||||
|
return
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
return transaction
|
||||||
|
})
|
||||||
|
|
||||||
|
export const shrimpKeymap = [customKeymap, singleLineFilter]
|
||||||
|
|
||||||
|
class History {
|
||||||
|
private commands: string[] = []
|
||||||
|
private index: number | undefined
|
||||||
|
private storageKey = 'shrimp-command-history'
|
||||||
|
|
||||||
|
constructor() {
|
||||||
|
try {
|
||||||
|
this.commands = JSON.parse(localStorage.getItem(this.storageKey) || '[]')
|
||||||
|
} catch {
|
||||||
|
console.warn('Failed to load command history from localStorage')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
push(command: string) {
|
||||||
|
this.commands.push(command)
|
||||||
|
|
||||||
|
// Limit to last 50 commands
|
||||||
|
this.commands = this.commands.slice(-50)
|
||||||
|
localStorage.setItem(this.storageKey, JSON.stringify(this.commands))
|
||||||
|
this.index = undefined
|
||||||
|
}
|
||||||
|
|
||||||
|
previous(): string | undefined {
|
||||||
|
if (this.commands.length === 0) return
|
||||||
|
|
||||||
|
if (this.index === undefined) {
|
||||||
|
this.index = this.commands.length - 1
|
||||||
|
} else if (this.index > 0) {
|
||||||
|
this.index -= 1
|
||||||
|
}
|
||||||
|
|
||||||
|
return this.commands[this.index]
|
||||||
|
}
|
||||||
|
|
||||||
|
next(): string | undefined {
|
||||||
|
if (this.commands.length === 0 || this.index === undefined) return
|
||||||
|
|
||||||
|
if (this.index < this.commands.length - 1) {
|
||||||
|
this.index += 1
|
||||||
|
return this.commands[this.index]
|
||||||
|
} else {
|
||||||
|
this.index = undefined
|
||||||
|
return ''
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const history = new History()
|
||||||
|
|
@ -1,6 +1,6 @@
|
||||||
import { ViewPlugin, ViewUpdate } from '@codemirror/view'
|
import { ViewPlugin, ViewUpdate } from '@codemirror/view'
|
||||||
|
|
||||||
export const persistence = ViewPlugin.fromClass(
|
export const persistencePlugin = ViewPlugin.fromClass(
|
||||||
class {
|
class {
|
||||||
saveTimeout?: ReturnType<typeof setTimeout>
|
saveTimeout?: ReturnType<typeof setTimeout>
|
||||||
|
|
||||||
|
|
@ -17,13 +17,13 @@ export const persistence = ViewPlugin.fromClass(
|
||||||
destroy() {
|
destroy() {
|
||||||
if (this.saveTimeout) clearTimeout(this.saveTimeout)
|
if (this.saveTimeout) clearTimeout(this.saveTimeout)
|
||||||
}
|
}
|
||||||
}
|
},
|
||||||
)
|
)
|
||||||
|
|
||||||
export const getContent = () => {
|
export const getContent = () => {
|
||||||
return localStorage.getItem('shrimp-editor-content') || ''
|
return localStorage.getItem('shrimp-editor-content') || ''
|
||||||
}
|
}
|
||||||
|
|
||||||
export const setContent = (data: string) => {
|
const setContent = (data: string) => {
|
||||||
localStorage.setItem('shrimp-editor-content', data)
|
localStorage.setItem('shrimp-editor-content', data)
|
||||||
}
|
}
|
||||||
9
src/editor/plugins/shrimpLanguage.ts
Normal file
9
src/editor/plugins/shrimpLanguage.ts
Normal file
|
|
@ -0,0 +1,9 @@
|
||||||
|
import { parser } from '#/parser/shrimp'
|
||||||
|
import { LRLanguage, LanguageSupport } from '@codemirror/language'
|
||||||
|
import { highlighting } from '#/parser/highlight.js'
|
||||||
|
|
||||||
|
const language = LRLanguage.define({
|
||||||
|
parser: parser.configure({ props: [highlighting] }),
|
||||||
|
})
|
||||||
|
|
||||||
|
export const shrimpLanguage = new LanguageSupport(language)
|
||||||
35
src/editor/plugins/shrimpSetup.ts
Normal file
35
src/editor/plugins/shrimpSetup.ts
Normal file
|
|
@ -0,0 +1,35 @@
|
||||||
|
import { history, defaultKeymap, historyKeymap } from '@codemirror/commands'
|
||||||
|
import { bracketMatching, indentOnInput } from '@codemirror/language'
|
||||||
|
import { highlightSpecialChars, drawSelection, dropCursor, keymap } from '@codemirror/view'
|
||||||
|
import { closeBrackets, autocompletion, completionKeymap } from '@codemirror/autocomplete'
|
||||||
|
import { EditorState, Compartment } from '@codemirror/state'
|
||||||
|
import { searchKeymap } from '@codemirror/search'
|
||||||
|
import { shrimpKeymap } from './keymap'
|
||||||
|
import { shrimpTheme, shrimpHighlighting } from './theme'
|
||||||
|
import { shrimpLanguage } from './shrimpLanguage'
|
||||||
|
import { shrimpErrors } from './errors'
|
||||||
|
import { persistencePlugin } from './persistence'
|
||||||
|
import { catchErrors } from './catchErrors'
|
||||||
|
|
||||||
|
export const shrimpSetup = (lineNumbersCompartment: Compartment) => {
|
||||||
|
return [
|
||||||
|
catchErrors,
|
||||||
|
shrimpKeymap,
|
||||||
|
highlightSpecialChars(),
|
||||||
|
history(),
|
||||||
|
drawSelection(),
|
||||||
|
dropCursor(),
|
||||||
|
EditorState.allowMultipleSelections.of(true),
|
||||||
|
bracketMatching(),
|
||||||
|
closeBrackets(),
|
||||||
|
autocompletion(),
|
||||||
|
indentOnInput(),
|
||||||
|
keymap.of([...defaultKeymap, ...historyKeymap, ...searchKeymap, ...completionKeymap]),
|
||||||
|
lineNumbersCompartment.of([]),
|
||||||
|
shrimpTheme,
|
||||||
|
shrimpLanguage,
|
||||||
|
shrimpHighlighting,
|
||||||
|
shrimpErrors,
|
||||||
|
persistencePlugin,
|
||||||
|
]
|
||||||
|
}
|
||||||
60
src/editor/plugins/theme.tsx
Normal file
60
src/editor/plugins/theme.tsx
Normal file
|
|
@ -0,0 +1,60 @@
|
||||||
|
import { EditorView } from '@codemirror/view'
|
||||||
|
import { HighlightStyle, syntaxHighlighting } from '@codemirror/language'
|
||||||
|
import { tags } from '@lezer/highlight'
|
||||||
|
|
||||||
|
const highlightStyle = HighlightStyle.define([
|
||||||
|
{ tag: tags.keyword, color: 'var(--color-keyword)' },
|
||||||
|
{ tag: tags.name, color: 'var(--color-function)' },
|
||||||
|
{ tag: tags.string, color: 'var(--color-string)' },
|
||||||
|
{ tag: tags.number, color: 'var(--color-number)' },
|
||||||
|
{ tag: tags.bool, color: 'var(--color-bool)' },
|
||||||
|
{ tag: tags.operator, color: 'var(--color-operator)' },
|
||||||
|
{ tag: tags.paren, color: 'var(--color-paren)' },
|
||||||
|
{ tag: tags.regexp, color: 'var(--color-regex)' },
|
||||||
|
{ tag: tags.function(tags.variableName), color: 'var(--color-function-call)' },
|
||||||
|
{ tag: tags.function(tags.invalid), color: 'white' },
|
||||||
|
{
|
||||||
|
tag: tags.definition(tags.variableName),
|
||||||
|
color: 'var(--color-variable-def)',
|
||||||
|
backgroundColor: 'var(--bg-variable-def)',
|
||||||
|
padding: '1px 2px',
|
||||||
|
borderRadius: '2px',
|
||||||
|
fontWeight: '500',
|
||||||
|
},
|
||||||
|
])
|
||||||
|
|
||||||
|
export const shrimpHighlighting = syntaxHighlighting(highlightStyle)
|
||||||
|
|
||||||
|
export const shrimpTheme = EditorView.theme(
|
||||||
|
{
|
||||||
|
'&': {
|
||||||
|
color: 'var(--text-editor)',
|
||||||
|
backgroundColor: 'var(--bg-editor)',
|
||||||
|
height: '100%',
|
||||||
|
fontSize: '18px',
|
||||||
|
},
|
||||||
|
'.cm-content': {
|
||||||
|
fontFamily: '"Pixeloid Mono", "Courier New", monospace',
|
||||||
|
caretColor: 'var(--caret)',
|
||||||
|
padding: '0px',
|
||||||
|
},
|
||||||
|
'&.cm-focused .cm-cursor': {
|
||||||
|
borderLeftColor: 'var(--caret)',
|
||||||
|
},
|
||||||
|
'&.cm-focused .cm-selectionBackground, ::selection': {
|
||||||
|
backgroundColor: 'var(--bg-selection)',
|
||||||
|
},
|
||||||
|
'.cm-editor': {
|
||||||
|
border: 'none',
|
||||||
|
outline: 'none',
|
||||||
|
height: '100%',
|
||||||
|
},
|
||||||
|
'.cm-matchingBracket': {
|
||||||
|
backgroundColor: 'var(--color-bool)',
|
||||||
|
},
|
||||||
|
'.cm-nonmatchingBracket': {
|
||||||
|
backgroundColor: 'var(--color-string)',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{ dark: true },
|
||||||
|
)
|
||||||
38
src/editor/runCode.tsx
Normal file
38
src/editor/runCode.tsx
Normal file
|
|
@ -0,0 +1,38 @@
|
||||||
|
import { outputSignal, errorSignal } from '#editor/editor'
|
||||||
|
import { Compiler } from '#compiler/compiler'
|
||||||
|
import { errorMessage, log } from '#utils/utils'
|
||||||
|
import { bytecodeToString } from 'reefvm'
|
||||||
|
import { parser } from '#parser/shrimp'
|
||||||
|
import { sendToNose } from '#editor/noseClient'
|
||||||
|
import { treeToString } from '#utils/tree'
|
||||||
|
|
||||||
|
export const runCode = async (input: string) => {
|
||||||
|
try {
|
||||||
|
const compiler = new Compiler(input)
|
||||||
|
sendToNose(compiler.bytecode)
|
||||||
|
} catch (error) {
|
||||||
|
log.error(error)
|
||||||
|
errorSignal.emit(`${errorMessage(error)}`)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export const printParserOutput = (input: string) => {
|
||||||
|
try {
|
||||||
|
const cst = parser.parse(input)
|
||||||
|
const string = treeToString(cst, input)
|
||||||
|
outputSignal.emit(string)
|
||||||
|
} catch (error) {
|
||||||
|
log.error(error)
|
||||||
|
errorSignal.emit(`${errorMessage(error)}`)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export const printBytecodeOutput = (input: string) => {
|
||||||
|
try {
|
||||||
|
const compiler = new Compiler(input)
|
||||||
|
outputSignal.emit(bytecodeToString(compiler.bytecode))
|
||||||
|
} catch (error) {
|
||||||
|
log.error(error)
|
||||||
|
errorSignal.emit(`${errorMessage(error)}`)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -1,51 +0,0 @@
|
||||||
import { EditorView } from '@codemirror/view'
|
|
||||||
|
|
||||||
export const shrimpTheme = EditorView.theme(
|
|
||||||
{
|
|
||||||
'&': {
|
|
||||||
color: 'var(--text-editor)',
|
|
||||||
backgroundColor: 'var(--bg-editor)',
|
|
||||||
height: '100%',
|
|
||||||
fontSize: '18px',
|
|
||||||
},
|
|
||||||
'.cm-content': {
|
|
||||||
fontFamily: '"Pixeloid Mono", "Courier New", monospace',
|
|
||||||
caretColor: 'var(--caret)',
|
|
||||||
padding: '0px',
|
|
||||||
borderTop: '1px solid var(--bg-editor)',
|
|
||||||
},
|
|
||||||
'&.cm-focused .cm-cursor': {
|
|
||||||
borderLeftColor: 'var(--caret)',
|
|
||||||
},
|
|
||||||
'.cm-selectionLayer .cm-selectionBackground': {
|
|
||||||
backgroundColor: 'var(--bg-selection)',
|
|
||||||
},
|
|
||||||
'.cm-editor': {
|
|
||||||
border: 'none',
|
|
||||||
outline: 'none',
|
|
||||||
height: '100%',
|
|
||||||
},
|
|
||||||
'.cm-matchingBracket': {
|
|
||||||
backgroundColor: 'var(--color-bool)',
|
|
||||||
},
|
|
||||||
'.cm-nonmatchingBracket': {
|
|
||||||
backgroundColor: 'var(--color-string)',
|
|
||||||
},
|
|
||||||
'.cm-activeLine': {
|
|
||||||
backgroundColor: 'rgba(255, 255, 255, 0.03)',
|
|
||||||
},
|
|
||||||
|
|
||||||
// Token highlighting
|
|
||||||
'.tok-keyword': { color: 'var(--color-keyword)' },
|
|
||||||
'.tok-string': { color: 'var(--color-string)' },
|
|
||||||
'.tok-number': { color: 'var(--color-number)' },
|
|
||||||
'.tok-bool': { color: 'var(--color-bool)' },
|
|
||||||
'.tok-null': { color: 'var(--color-number)', fontStyle: 'italic' },
|
|
||||||
'.tok-identifier': { color: 'var(--color-function)' },
|
|
||||||
'.tok-variable-def': { color: 'var(--color-variable-def)' },
|
|
||||||
'.tok-comment': { color: 'var(--ansi-bright-black)', fontStyle: 'italic' },
|
|
||||||
'.tok-operator': { color: 'var(--color-operator)' },
|
|
||||||
'.tok-regex': { color: 'var(--color-regex)' },
|
|
||||||
},
|
|
||||||
{ dark: true }
|
|
||||||
)
|
|
||||||
|
|
@ -1,4 +1,4 @@
|
||||||
import { isDebug } from '#utils/utils'
|
const DEBUG = process.env.DEBUG || false
|
||||||
|
|
||||||
export type Token = {
|
export type Token = {
|
||||||
type: TokenType
|
type: TokenType
|
||||||
|
|
@ -169,11 +169,11 @@ export class Scanner {
|
||||||
from,
|
from,
|
||||||
to,
|
to,
|
||||||
},
|
},
|
||||||
valueTokens.has(type) ? { value: this.input.slice(from, to) } : {}
|
valueTokens.has(type) ? { value: this.input.slice(from, to) } : {},
|
||||||
)
|
),
|
||||||
)
|
)
|
||||||
|
|
||||||
if (isDebug()) {
|
if (DEBUG) {
|
||||||
const tok = this.tokens.at(-1)
|
const tok = this.tokens.at(-1)
|
||||||
console.log(`≫ PUSH(${from},${to})`, TokenType[tok?.type || 0], '—', tok?.value)
|
console.log(`≫ PUSH(${from},${to})`, TokenType[tok?.type || 0], '—', tok?.value)
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -19,7 +19,6 @@ import { list } from './list'
|
||||||
import { math } from './math'
|
import { math } from './math'
|
||||||
import { str } from './str'
|
import { str } from './str'
|
||||||
import { types } from './types'
|
import { types } from './types'
|
||||||
import { runningInBrowser } from '#utils/utils'
|
|
||||||
|
|
||||||
export const globals: Record<string, any> = {
|
export const globals: Record<string, any> = {
|
||||||
date,
|
date,
|
||||||
|
|
@ -32,19 +31,7 @@ export const globals: Record<string, any> = {
|
||||||
str,
|
str,
|
||||||
|
|
||||||
// shrimp runtime info
|
// shrimp runtime info
|
||||||
$: runningInBrowser
|
$: {
|
||||||
? {
|
|
||||||
args: [],
|
|
||||||
argv: [],
|
|
||||||
env: {},
|
|
||||||
pid: 0,
|
|
||||||
cwd: '',
|
|
||||||
script: {
|
|
||||||
name: '',
|
|
||||||
path: '.',
|
|
||||||
},
|
|
||||||
}
|
|
||||||
: {
|
|
||||||
args: Bun.argv.slice(3),
|
args: Bun.argv.slice(3),
|
||||||
argv: Bun.argv.slice(1),
|
argv: Bun.argv.slice(1),
|
||||||
env: process.env,
|
env: process.env,
|
||||||
|
|
@ -62,7 +49,7 @@ export const globals: Record<string, any> = {
|
||||||
...args.map((a) => {
|
...args.map((a) => {
|
||||||
const v = toValue(a)
|
const v = toValue(a)
|
||||||
return ['array', 'dict'].includes(v.type) ? formatValue(v, true) : v.value
|
return ['array', 'dict'].includes(v.type) ? formatValue(v, true) : v.value
|
||||||
})
|
}),
|
||||||
)
|
)
|
||||||
return toValue(null)
|
return toValue(null)
|
||||||
},
|
},
|
||||||
|
|
@ -133,7 +120,7 @@ export const globals: Record<string, any> = {
|
||||||
const idx = typeof index === 'number' ? index : parseInt(index as string)
|
const idx = typeof index === 'number' ? index : parseInt(index as string)
|
||||||
if (idx < 0 || idx >= value.value.length) {
|
if (idx < 0 || idx >= value.value.length) {
|
||||||
throw new Error(
|
throw new Error(
|
||||||
`at: index ${idx} out of bounds for ${value.type} of length ${value.value.length}`
|
`at: index ${idx} out of bounds for ${value.type} of length ${value.value.length}`,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
return value.value[idx]
|
return value.value[idx]
|
||||||
|
|
|
||||||
10
src/server/app.tsx
Normal file
10
src/server/app.tsx
Normal file
|
|
@ -0,0 +1,10 @@
|
||||||
|
import { Editor } from '#/editor/editor'
|
||||||
|
import { render } from 'hono/jsx/dom'
|
||||||
|
import './index.css'
|
||||||
|
|
||||||
|
const App = () => {
|
||||||
|
return <Editor />
|
||||||
|
}
|
||||||
|
|
||||||
|
const root = document.getElementById('root')!
|
||||||
|
render(<App />, root)
|
||||||
84
src/server/index.css
Normal file
84
src/server/index.css
Normal file
|
|
@ -0,0 +1,84 @@
|
||||||
|
:root {
|
||||||
|
/* Background colors */
|
||||||
|
--bg-editor: #011627;
|
||||||
|
--bg-output: #40318d;
|
||||||
|
--bg-status-bar: #1e2a4a;
|
||||||
|
--bg-status-border: #0e1a3a;
|
||||||
|
--bg-selection: #1d3b53;
|
||||||
|
--bg-variable-def: #1e2a4a;
|
||||||
|
|
||||||
|
/* Text colors */
|
||||||
|
--text-editor: #d6deeb;
|
||||||
|
--text-output: #7c70da;
|
||||||
|
--text-status: #b3a9ff55;
|
||||||
|
--caret: #80a4c2;
|
||||||
|
|
||||||
|
/* Syntax highlighting colors */
|
||||||
|
--color-keyword: #c792ea;
|
||||||
|
--color-function: #82aaff;
|
||||||
|
--color-string: #c3e88d;
|
||||||
|
--color-number: #f78c6c;
|
||||||
|
--color-bool: #ff5370;
|
||||||
|
--color-operator: #89ddff;
|
||||||
|
--color-paren: #676e95;
|
||||||
|
--color-function-call: #ff9cac;
|
||||||
|
--color-variable-def: #ffcb6b;
|
||||||
|
--color-error: #ff6e6e;
|
||||||
|
--color-regex: #e1acff;
|
||||||
|
|
||||||
|
/* ANSI terminal colors */
|
||||||
|
--ansi-black: #011627;
|
||||||
|
--ansi-red: #ff5370;
|
||||||
|
--ansi-green: #c3e88d;
|
||||||
|
--ansi-yellow: #ffcb6b;
|
||||||
|
--ansi-blue: #82aaff;
|
||||||
|
--ansi-magenta: #c792ea;
|
||||||
|
--ansi-cyan: #89ddff;
|
||||||
|
--ansi-white: #d6deeb;
|
||||||
|
|
||||||
|
/* ANSI bright colors (slightly more vibrant) */
|
||||||
|
--ansi-bright-black: #676e95;
|
||||||
|
--ansi-bright-red: #ff6e90;
|
||||||
|
--ansi-bright-green: #d4f6a8;
|
||||||
|
--ansi-bright-yellow: #ffe082;
|
||||||
|
--ansi-bright-blue: #a8c7fa;
|
||||||
|
--ansi-bright-magenta: #e1acff;
|
||||||
|
--ansi-bright-cyan: #a8f5ff;
|
||||||
|
--ansi-bright-white: #ffffff;
|
||||||
|
}
|
||||||
|
|
||||||
|
@font-face {
|
||||||
|
font-family: 'C64ProMono';
|
||||||
|
src: url('../../assets/C64_Pro_Mono-STYLE.woff2') format('woff2');
|
||||||
|
font-weight: normal;
|
||||||
|
font-style: normal;
|
||||||
|
}
|
||||||
|
|
||||||
|
@font-face {
|
||||||
|
font-family: 'Pixeloid Mono';
|
||||||
|
src: url('../../assets/PixeloidMono.ttf') format('truetype');
|
||||||
|
font-weight: normal;
|
||||||
|
font-style: normal;
|
||||||
|
}
|
||||||
|
|
||||||
|
* {
|
||||||
|
margin: 0;
|
||||||
|
padding: 0;
|
||||||
|
box-sizing: border-box;
|
||||||
|
}
|
||||||
|
|
||||||
|
body {
|
||||||
|
background: var(--bg-output);
|
||||||
|
color: var(--text-output);
|
||||||
|
font-family: 'Pixeloid Mono', 'Courier New', monospace;
|
||||||
|
font-size: 18px;
|
||||||
|
height: 100vh;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
#root {
|
||||||
|
height: 100vh;
|
||||||
|
background: var(--bg-output);
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
}
|
||||||
12
src/server/index.html
Normal file
12
src/server/index.html
Normal file
|
|
@ -0,0 +1,12 @@
|
||||||
|
<!doctype html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8" />
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
|
<title>Shrimp</title>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div id="root"></div>
|
||||||
|
<script type="module" src="./app.tsx"></script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
29
src/server/server.tsx
Normal file
29
src/server/server.tsx
Normal file
|
|
@ -0,0 +1,29 @@
|
||||||
|
import index from './index.html'
|
||||||
|
|
||||||
|
const server = Bun.serve({
|
||||||
|
port: process.env.PORT ? Number(process.env.PORT) : 3001,
|
||||||
|
routes: {
|
||||||
|
'/*': index,
|
||||||
|
|
||||||
|
'/api/hello': {
|
||||||
|
async GET(req) {
|
||||||
|
return Response.json({
|
||||||
|
message: 'Hello, world!',
|
||||||
|
method: 'GET',
|
||||||
|
})
|
||||||
|
},
|
||||||
|
async PUT(req) {
|
||||||
|
return Response.json({
|
||||||
|
message: 'Hello, world!',
|
||||||
|
method: 'PUT',
|
||||||
|
})
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
development: process.env.NODE_ENV !== 'production' && {
|
||||||
|
hmr: true,
|
||||||
|
console: true,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
console.log(`🚀 Server running at ${server.url}`)
|
||||||
68
src/utils/signal.ts
Normal file
68
src/utils/signal.ts
Normal file
|
|
@ -0,0 +1,68 @@
|
||||||
|
/**
|
||||||
|
* How to use a Signal:
|
||||||
|
*
|
||||||
|
* Create a signal with primitives:
|
||||||
|
* const nameSignal = new Signal<string>()
|
||||||
|
* const countSignal = new Signal<number>()
|
||||||
|
*
|
||||||
|
* Create a signal with objects:
|
||||||
|
* const chatSignal = new Signal<{ username: string, message: string }>()
|
||||||
|
*
|
||||||
|
* Create a signal with no data (void):
|
||||||
|
* const clickSignal = new Signal<void>()
|
||||||
|
* const clickSignal2 = new Signal() // Defaults to void
|
||||||
|
*
|
||||||
|
* Connect to the signal:
|
||||||
|
* const disconnect = chatSignal.connect((data) => {
|
||||||
|
* const {username, message} = data;
|
||||||
|
* console.log(`${username} said "${message}"`);
|
||||||
|
* })
|
||||||
|
*
|
||||||
|
* Emit a signal:
|
||||||
|
* nameSignal.emit("Alice")
|
||||||
|
* countSignal.emit(42)
|
||||||
|
* chatSignal.emit({ username: "Chad", message: "Hey everyone, how's it going?" });
|
||||||
|
* clickSignal.emit() // No argument for void signals
|
||||||
|
*
|
||||||
|
* Forward a signal:
|
||||||
|
* const relaySignal = new Signal<{ username: string, message: string }>()
|
||||||
|
* const disconnectRelay = chatSignal.connect(relaySignal)
|
||||||
|
* // Now, when chatSignal emits, relaySignal will also emit the same data
|
||||||
|
*
|
||||||
|
* Disconnect a single listener:
|
||||||
|
* disconnect(); // The disconnect function is returned when you connect to a signal
|
||||||
|
*
|
||||||
|
* Disconnect all listeners:
|
||||||
|
* chatSignal.disconnect()
|
||||||
|
*/
|
||||||
|
|
||||||
|
export class Signal<T = 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 = []
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -136,12 +136,3 @@ export const asciiEscapeToHtml = (str: string): HtmlEscapedString => {
|
||||||
|
|
||||||
return result as HtmlEscapedString
|
return result as HtmlEscapedString
|
||||||
}
|
}
|
||||||
|
|
||||||
export const isDebug = (): boolean => {
|
|
||||||
if (typeof process !== 'undefined' && process.env) {
|
|
||||||
return !!process.env.DEBUG
|
|
||||||
}
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
|
|
||||||
export const runningInBrowser = typeof window !== 'undefined'
|
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue
Block a user