Merge pull request 'A bunch of changes relating to the codemirror editor component' (#54) from editor-fixes into main
Reviewed-on: #54
This commit is contained in:
commit
b8bff6f129
10
package.json
10
package.json
|
|
@ -1,11 +1,15 @@
|
||||||
{
|
{
|
||||||
"name": "shrimp",
|
"name": "shrimp",
|
||||||
"version": "0.1.0",
|
"version": "0.1.0",
|
||||||
"exports": "./src/index.ts",
|
"exports": {
|
||||||
|
".": "./src/index.ts",
|
||||||
|
"./editor": "./src/editor/index.ts",
|
||||||
|
"./editor.css": "./src/editor/editor.css"
|
||||||
|
},
|
||||||
"private": true,
|
"private": true,
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "bun --hot src/server/server.tsx",
|
"editor": "bun --hot src/editor/example/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",
|
||||||
|
|
@ -33,4 +37,4 @@
|
||||||
"singleQuote": true,
|
"singleQuote": true,
|
||||||
"printWidth": 100
|
"printWidth": 100
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -1,261 +0,0 @@
|
||||||
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 }
|
|
||||||
}
|
|
||||||
87
src/editor/completions.ts
Normal file
87
src/editor/completions.ts
Normal file
|
|
@ -0,0 +1,87 @@
|
||||||
|
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],
|
||||||
|
})
|
||||||
|
}
|
||||||
24
src/editor/diagnostics.ts
Normal file
24
src/editor/diagnostics.ts
Normal file
|
|
@ -0,0 +1,24 @@
|
||||||
|
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,53 +1,78 @@
|
||||||
#output {
|
:root {
|
||||||
flex: 1;
|
/* Background colors */
|
||||||
background: var(--bg-output);
|
--bg-editor: #011627;
|
||||||
color: var(--text-output);
|
--bg-output: #40318D;
|
||||||
padding: 20px;
|
--bg-status-bar: #1E2A4A;
|
||||||
overflow-y: auto;
|
--bg-status-border: #0E1A3A;
|
||||||
white-space: pre-wrap;
|
--bg-selection: #1D3B53;
|
||||||
|
|
||||||
|
/* Text colors */
|
||||||
|
--text-editor: #D6DEEB;
|
||||||
|
--text-output: #7C70DA;
|
||||||
|
--text-status: #B3A9FF55;
|
||||||
|
--caret: #80A4C2;
|
||||||
|
|
||||||
|
/* Syntax highlighting colors */
|
||||||
|
--color-keyword: #C792EA;
|
||||||
|
--color-function: #82AAFF;
|
||||||
|
--color-string: #C3E88D;
|
||||||
|
--color-number: #F78C6C;
|
||||||
|
--color-bool: #FF5370;
|
||||||
|
--color-operator: #89DDFF;
|
||||||
|
--color-paren: #676E95;
|
||||||
|
--color-function-call: #FF9CAC;
|
||||||
|
--color-variable-def: #FFCB6B;
|
||||||
|
--color-error: #FF6E6E;
|
||||||
|
--color-regex: #E1ACFF;
|
||||||
|
|
||||||
|
/* ANSI terminal colors */
|
||||||
|
--ansi-black: #011627;
|
||||||
|
--ansi-red: #FF5370;
|
||||||
|
--ansi-green: #C3E88D;
|
||||||
|
--ansi-yellow: #FFCB6B;
|
||||||
|
--ansi-blue: #82AAFF;
|
||||||
|
--ansi-magenta: #C792EA;
|
||||||
|
--ansi-cyan: #89DDFF;
|
||||||
|
--ansi-white: #D6DEEB;
|
||||||
|
|
||||||
|
/* ANSI bright colors (slightly more vibrant) */
|
||||||
|
--ansi-bright-black: #676E95;
|
||||||
|
--ansi-bright-red: #FF6E90;
|
||||||
|
--ansi-bright-green: #D4F6A8;
|
||||||
|
--ansi-bright-yellow: #FFE082;
|
||||||
|
--ansi-bright-blue: #A8C7FA;
|
||||||
|
--ansi-bright-magenta: #E1ACFF;
|
||||||
|
--ansi-bright-cyan: #A8F5FF;
|
||||||
|
--ansi-bright-white: #FFFFFF;
|
||||||
|
}
|
||||||
|
|
||||||
|
@font-face {
|
||||||
|
font-family: 'C64ProMono';
|
||||||
|
src: url('../../assets/C64_Pro_Mono-STYLE.woff2') format('woff2');
|
||||||
|
font-weight: normal;
|
||||||
|
font-style: normal;
|
||||||
|
}
|
||||||
|
|
||||||
|
@font-face {
|
||||||
|
font-family: 'Pixeloid Mono';
|
||||||
|
src: url('../../assets/PixeloidMono.ttf') format('truetype');
|
||||||
|
font-weight: normal;
|
||||||
|
font-style: normal;
|
||||||
|
}
|
||||||
|
|
||||||
|
* {
|
||||||
|
margin: 0;
|
||||||
|
padding: 0;
|
||||||
|
box-sizing: border-box;
|
||||||
|
}
|
||||||
|
|
||||||
|
body {
|
||||||
|
background-color: var(--bg-editor);
|
||||||
font-family: 'Pixeloid Mono', 'Courier New', monospace;
|
font-family: 'Pixeloid Mono', 'Courier New', monospace;
|
||||||
font-size: 18px;
|
font-size: 18px;
|
||||||
|
height: 100vh;
|
||||||
}
|
}
|
||||||
|
|
||||||
#status-bar {
|
#root {
|
||||||
height: 30px;
|
height: 100vh;
|
||||||
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,140 +1,70 @@
|
||||||
import { EditorView } from '@codemirror/view'
|
import { EditorView, basicSetup } from 'codemirror'
|
||||||
import { asciiEscapeToHtml, assertNever, log, toElement } from '#utils/utils'
|
import { Shrimp } from '#/index'
|
||||||
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 '#editor/editor.css'
|
import { shrimpTheme } from './theme'
|
||||||
|
import { createShrimpDiagnostics } from './diagnostics'
|
||||||
|
import { shrimpHighlighter } from './highlighter'
|
||||||
|
import { createShrimpCompletions } from './completions'
|
||||||
|
import { shrimpKeymap } from './keymap'
|
||||||
|
import { getContent, persistence } from './persistence'
|
||||||
|
|
||||||
const lineNumbersCompartment = new Compartment()
|
type EditorProps = {
|
||||||
|
initialCode?: string
|
||||||
|
onChange?: (code: string) => void
|
||||||
|
extensions?: import('@codemirror/state').Extension[]
|
||||||
|
shrimp?: Shrimp
|
||||||
|
}
|
||||||
|
|
||||||
connectToNose()
|
export const Editor = ({
|
||||||
|
initialCode = '',
|
||||||
export const outputSignal = new Signal<Value | string>()
|
onChange,
|
||||||
export const errorSignal = new Signal<string>()
|
extensions: customExtensions = [],
|
||||||
export const multilineModeSignal = new Signal<boolean>()
|
shrimp = new Shrimp(),
|
||||||
|
}: 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>
|
|
||||||
</>
|
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
noseSignals.connect((message) => {
|
const createEditorView = (
|
||||||
if (message.type === 'error') {
|
el: Element,
|
||||||
log.error(`Nose error: ${message.data}`)
|
initialCode: string,
|
||||||
errorSignal.emit(`Nose error: ${message.data}`)
|
onChange: ((code: string) => void) | undefined,
|
||||||
} else if (message.type === 'reef-output') {
|
customExtensions: import('@codemirror/state').Extension[],
|
||||||
const x = outputSignal.emit(message.data)
|
shrimp: Shrimp
|
||||||
} else if (message.type === 'connected') {
|
) => {
|
||||||
outputSignal.emit(`╞ Connected to Nose VM`)
|
const extensions = [
|
||||||
|
basicSetup,
|
||||||
|
shrimpTheme,
|
||||||
|
createShrimpDiagnostics(shrimp),
|
||||||
|
createShrimpCompletions(shrimp),
|
||||||
|
shrimpHighlighter,
|
||||||
|
shrimpKeymap,
|
||||||
|
persistence,
|
||||||
|
...customExtensions,
|
||||||
|
]
|
||||||
|
|
||||||
|
if (onChange) {
|
||||||
|
extensions.push(
|
||||||
|
EditorView.updateListener.of((update) => {
|
||||||
|
if (update.docChanged) {
|
||||||
|
onChange(update.state.doc.toString())
|
||||||
|
}
|
||||||
|
})
|
||||||
|
)
|
||||||
}
|
}
|
||||||
})
|
|
||||||
|
|
||||||
outputSignal.connect((value) => {
|
new EditorView({
|
||||||
const el = document.querySelector('#output')!
|
parent: el,
|
||||||
el.innerHTML = ''
|
doc: initialCode,
|
||||||
el.innerHTML = asciiEscapeToHtml(valueToString(value))
|
extensions,
|
||||||
})
|
|
||||||
|
|
||||||
errorSignal.connect((error) => {
|
|
||||||
const el = document.querySelector('#output')!
|
|
||||||
el.innerHTML = ''
|
|
||||||
el.classList.add('error')
|
|
||||||
el.innerHTML = asciiEscapeToHtml(error)
|
|
||||||
})
|
|
||||||
|
|
||||||
type StatusBarMessage = {
|
|
||||||
side: 'left' | 'right'
|
|
||||||
message: string | Promise<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) {
|
// Trigger onChange with initial content
|
||||||
sideEl.appendChild(toElement(messageEl))
|
onChange?.(initialCode)
|
||||||
} 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}>`
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
|
||||||
6
src/editor/example/frontend.tsx
Normal file
6
src/editor/example/frontend.tsx
Normal file
|
|
@ -0,0 +1,6 @@
|
||||||
|
import { Editor } from '#editor/editor'
|
||||||
|
import { render } from 'hono/jsx/dom'
|
||||||
|
|
||||||
|
if (typeof document !== 'undefined') {
|
||||||
|
render(<Editor initialCode={'# type some code'} />, document.getElementById('root')!)
|
||||||
|
}
|
||||||
11
src/editor/example/index.html
Normal file
11
src/editor/example/index.html
Normal file
|
|
@ -0,0 +1,11 @@
|
||||||
|
<!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>
|
||||||
14
src/editor/example/server.tsx
Normal file
14
src/editor/example/server.tsx
Normal file
|
|
@ -0,0 +1,14 @@
|
||||||
|
import index from './index.html'
|
||||||
|
|
||||||
|
const server = Bun.serve({
|
||||||
|
port: 3000,
|
||||||
|
routes: {
|
||||||
|
'/': index,
|
||||||
|
},
|
||||||
|
development: {
|
||||||
|
hmr: true,
|
||||||
|
console: true,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
console.log(`Editor running at ${server.url}`)
|
||||||
73
src/editor/highlighter.ts
Normal file
73
src/editor/highlighter.ts
Normal file
|
|
@ -0,0 +1,73 @@
|
||||||
|
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
src/editor/index.ts
Normal file
1
src/editor/index.ts
Normal file
|
|
@ -0,0 +1 @@
|
||||||
|
export { Editor } from './editor'
|
||||||
8
src/editor/keymap.ts
Normal file
8
src/editor/keymap.ts
Normal file
|
|
@ -0,0 +1,8 @@
|
||||||
|
import { keymap } from '@codemirror/view'
|
||||||
|
import { acceptCompletion } from '@codemirror/autocomplete'
|
||||||
|
import { indentWithTab } from '@codemirror/commands'
|
||||||
|
|
||||||
|
export const shrimpKeymap = keymap.of([
|
||||||
|
{ key: 'Tab', run: acceptCompletion },
|
||||||
|
indentWithTab,
|
||||||
|
])
|
||||||
|
|
@ -1,59 +0,0 @@
|
||||||
import { Signal } from '#utils/signal'
|
|
||||||
import type { Bytecode, Value } from 'reefvm'
|
|
||||||
let ws: WebSocket
|
|
||||||
|
|
||||||
type IncomingMessage =
|
|
||||||
| { type: 'connected' }
|
|
||||||
| { type: 'ping'; data: number }
|
|
||||||
| { type: 'commands'; data: number }
|
|
||||||
| {
|
|
||||||
type: 'apps'
|
|
||||||
data: {
|
|
||||||
name: string
|
|
||||||
type: 'browser' | 'server'
|
|
||||||
}[]
|
|
||||||
}
|
|
||||||
| {
|
|
||||||
type: 'session:start'
|
|
||||||
data: {
|
|
||||||
NOSE_DIR: string
|
|
||||||
cwd: string
|
|
||||||
hostname: string
|
|
||||||
mode: string
|
|
||||||
project: string
|
|
||||||
}
|
|
||||||
}
|
|
||||||
| { type: 'reef-output'; data: Value }
|
|
||||||
| { type: 'error'; data: string }
|
|
||||||
|
|
||||||
export const noseSignals = new Signal<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 }))
|
|
||||||
}
|
|
||||||
|
|
@ -1,6 +1,6 @@
|
||||||
import { ViewPlugin, ViewUpdate } from '@codemirror/view'
|
import { ViewPlugin, ViewUpdate } from '@codemirror/view'
|
||||||
|
|
||||||
export const persistencePlugin = ViewPlugin.fromClass(
|
export const persistence = ViewPlugin.fromClass(
|
||||||
class {
|
class {
|
||||||
saveTimeout?: ReturnType<typeof setTimeout>
|
saveTimeout?: ReturnType<typeof setTimeout>
|
||||||
|
|
||||||
|
|
@ -17,13 +17,13 @@ export const persistencePlugin = 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') || ''
|
||||||
}
|
}
|
||||||
|
|
||||||
const setContent = (data: string) => {
|
export const setContent = (data: string) => {
|
||||||
localStorage.setItem('shrimp-editor-content', data)
|
localStorage.setItem('shrimp-editor-content', data)
|
||||||
}
|
}
|
||||||
|
|
@ -1,9 +0,0 @@
|
||||||
import { errorSignal } from '#editor/editor'
|
|
||||||
import { EditorView } from '@codemirror/view'
|
|
||||||
|
|
||||||
export const catchErrors = EditorView.exceptionSink.of((exception) => {
|
|
||||||
console.error('CodeMirror error:', exception)
|
|
||||||
errorSignal.emit(
|
|
||||||
`Editor error: ${exception instanceof Error ? exception.message : String(exception)}`,
|
|
||||||
)
|
|
||||||
})
|
|
||||||
|
|
@ -1,35 +0,0 @@
|
||||||
import { EditorView, ViewPlugin, ViewUpdate } from '@codemirror/view'
|
|
||||||
import { syntaxTree } from '@codemirror/language'
|
|
||||||
import { statusBarSignal } from '#editor/editor'
|
|
||||||
|
|
||||||
export const debugTags = ViewPlugin.fromClass(
|
|
||||||
class {
|
|
||||||
update(update: ViewUpdate) {
|
|
||||||
if (update.docChanged || update.selectionSet || update.geometryChanged) {
|
|
||||||
this.updateStatusBar(update.view)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
updateStatusBar(view: EditorView) {
|
|
||||||
const pos = view.state.selection.main.head + 1
|
|
||||||
const tree = syntaxTree(view.state)
|
|
||||||
|
|
||||||
let tags: string[] = []
|
|
||||||
let node = tree.resolveInner(pos, -1)
|
|
||||||
|
|
||||||
while (node) {
|
|
||||||
tags.push(node.type.name)
|
|
||||||
node = node.parent!
|
|
||||||
if (!node) break
|
|
||||||
}
|
|
||||||
|
|
||||||
const debugText = tags.length ? tags.reverse().slice(1).join(' > ') : 'No nodes'
|
|
||||||
statusBarSignal.emit({
|
|
||||||
side: 'right',
|
|
||||||
message: debugText,
|
|
||||||
className: 'debug-tags',
|
|
||||||
order: -1,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
},
|
|
||||||
)
|
|
||||||
|
|
@ -1,62 +0,0 @@
|
||||||
import { parser } from '#parser/shrimp'
|
|
||||||
import type { Timeout } from '#utils/utils'
|
|
||||||
import { Range } from '@codemirror/state'
|
|
||||||
import {
|
|
||||||
Decoration,
|
|
||||||
EditorView,
|
|
||||||
ViewPlugin,
|
|
||||||
ViewUpdate,
|
|
||||||
type DecorationSet,
|
|
||||||
} from '@codemirror/view'
|
|
||||||
|
|
||||||
export const shrimpErrors = ViewPlugin.fromClass(
|
|
||||||
class {
|
|
||||||
timeout?: Timeout
|
|
||||||
decorations: DecorationSet = Decoration.none
|
|
||||||
|
|
||||||
constructor(view: EditorView) {
|
|
||||||
this.updateErrors(view)
|
|
||||||
}
|
|
||||||
|
|
||||||
update(update: ViewUpdate) {
|
|
||||||
if (update.docChanged) {
|
|
||||||
this.debounceUpdate(update.view)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
updateErrors(view: EditorView) {
|
|
||||||
this.decorations = Decoration.none
|
|
||||||
try {
|
|
||||||
const decorations: Range<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,
|
|
||||||
},
|
|
||||||
)
|
|
||||||
|
|
@ -1,232 +0,0 @@
|
||||||
import {
|
|
||||||
ViewPlugin,
|
|
||||||
ViewUpdate,
|
|
||||||
EditorView,
|
|
||||||
Decoration,
|
|
||||||
type DecorationSet,
|
|
||||||
} from '@codemirror/view'
|
|
||||||
import { syntaxTree } from '@codemirror/language'
|
|
||||||
import { type SyntaxNode } from '@lezer/common'
|
|
||||||
import { WidgetType } from '@codemirror/view'
|
|
||||||
import { toElement } from '#utils/utils'
|
|
||||||
import { matchingCommands } from '#editor/commands'
|
|
||||||
import * as Terms from '#parser/shrimp.terms'
|
|
||||||
|
|
||||||
const ghostTextTheme = EditorView.theme({
|
|
||||||
'.ghost-text': {
|
|
||||||
color: '#666',
|
|
||||||
opacity: '0.6',
|
|
||||||
fontStyle: 'italic',
|
|
||||||
},
|
|
||||||
})
|
|
||||||
|
|
||||||
type Hint = { cursor: number; hintText?: string; completionText?: string }
|
|
||||||
|
|
||||||
export const inlineHints = [
|
|
||||||
ViewPlugin.fromClass(
|
|
||||||
class {
|
|
||||||
decorations: DecorationSet = Decoration.none
|
|
||||||
currentHint?: Hint
|
|
||||||
|
|
||||||
update(update: ViewUpdate) {
|
|
||||||
if (!update.docChanged && !update.selectionSet) return
|
|
||||||
|
|
||||||
this.clearHints()
|
|
||||||
let hint = this.getContext(update.view)
|
|
||||||
this.currentHint = hint
|
|
||||||
this.showHint(hint)
|
|
||||||
}
|
|
||||||
|
|
||||||
handleTab(view: EditorView) {
|
|
||||||
if (!this.currentHint?.completionText) return false
|
|
||||||
|
|
||||||
this.decorations = Decoration.none
|
|
||||||
view.dispatch({
|
|
||||||
changes: {
|
|
||||||
from: this.currentHint.cursor,
|
|
||||||
insert: this.currentHint.completionText,
|
|
||||||
},
|
|
||||||
selection: {
|
|
||||||
anchor: this.currentHint.cursor + this.currentHint.completionText.length,
|
|
||||||
},
|
|
||||||
})
|
|
||||||
this.currentHint = undefined
|
|
||||||
return true
|
|
||||||
}
|
|
||||||
|
|
||||||
clearHints() {
|
|
||||||
this.currentHint = undefined
|
|
||||||
this.decorations = Decoration.none
|
|
||||||
}
|
|
||||||
|
|
||||||
getContext(view: EditorView): Hint {
|
|
||||||
const cursor = view.state.selection.main.head
|
|
||||||
|
|
||||||
const isCursorAtEnd = cursor === view.state.doc.length
|
|
||||||
if (!isCursorAtEnd) return { cursor }
|
|
||||||
|
|
||||||
const token = this.getCommandContextToken(view, cursor)
|
|
||||||
if (!token) return { cursor }
|
|
||||||
|
|
||||||
const text = view.state.doc.sliceString(token.from, token.to)
|
|
||||||
const tokenId = token.type.id
|
|
||||||
|
|
||||||
let completionText = ''
|
|
||||||
let hintText = ''
|
|
||||||
const justSpaces = view.state.doc.sliceString(cursor - 1, cursor) === ' '
|
|
||||||
|
|
||||||
if (tokenId === Terms.CommandPartial) {
|
|
||||||
const { partialMatches } = matchingCommands(text)
|
|
||||||
const match = partialMatches[0]
|
|
||||||
if (match) {
|
|
||||||
completionText = match.command.slice(text.length) + ' '
|
|
||||||
hintText = completionText
|
|
||||||
}
|
|
||||||
} else if (
|
|
||||||
tokenId === Terms.Identifier &&
|
|
||||||
token.parent?.type.id === Terms.Arg &&
|
|
||||||
!justSpaces
|
|
||||||
) {
|
|
||||||
const { availableArgs } = this.getCommandContext(view, token)
|
|
||||||
const matchingArgs = availableArgs.filter((arg) => arg.name.startsWith(text))
|
|
||||||
const match = matchingArgs[0]
|
|
||||||
if (match) {
|
|
||||||
hintText = `${match.name.slice(text.length)}=<${match.type}>`
|
|
||||||
completionText = `${match.name.slice(text.length)}=`
|
|
||||||
}
|
|
||||||
} else if (this.containedBy(token, Terms.PartialNamedArg)) {
|
|
||||||
const { availableArgs } = this.getCommandContext(view, token)
|
|
||||||
const textWithoutEquals = text.slice(0, -1)
|
|
||||||
const matchingArgs = availableArgs.filter((arg) => arg.name == textWithoutEquals)
|
|
||||||
const match = matchingArgs[0]
|
|
||||||
if (match) {
|
|
||||||
hintText = `<${match.type}>`
|
|
||||||
completionText = 'default' in match ? `${match.default}` : ''
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
const { availableArgs } = this.getCommandContext(view, token)
|
|
||||||
const nextArg = Array.from(availableArgs)[0]
|
|
||||||
const space = justSpaces ? '' : ' '
|
|
||||||
if (nextArg) {
|
|
||||||
hintText = `${space}${nextArg.name}=<${nextArg.type}>`
|
|
||||||
if (nextArg) {
|
|
||||||
completionText = `${space}${nextArg.name}=`
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return { completionText, hintText, cursor }
|
|
||||||
}
|
|
||||||
|
|
||||||
getCommandContextToken(view: EditorView, cursor: number) {
|
|
||||||
const tree = syntaxTree(view.state)
|
|
||||||
let node = tree.resolveInner(cursor, -1)
|
|
||||||
|
|
||||||
// If we're in a CommandCall, return the token before cursor
|
|
||||||
if (this.containedBy(node, Terms.CommandCall)) {
|
|
||||||
return tree.resolveInner(cursor, -1)
|
|
||||||
}
|
|
||||||
|
|
||||||
// If we're in Program, look backward
|
|
||||||
while (node.name === 'Program' && cursor > 0) {
|
|
||||||
cursor -= 1
|
|
||||||
node = tree.resolveInner(cursor, -1)
|
|
||||||
if (this.containedBy(node, Terms.CommandCall)) {
|
|
||||||
return tree.resolveInner(cursor, -1)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
containedBy(node: SyntaxNode, nodeId: number): SyntaxNode | undefined {
|
|
||||||
let current: SyntaxNode | undefined = node
|
|
||||||
|
|
||||||
while (current) {
|
|
||||||
if (current.type.id === nodeId) {
|
|
||||||
return current
|
|
||||||
}
|
|
||||||
|
|
||||||
current = current.parent ?? undefined
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
showHint(hint: Hint) {
|
|
||||||
if (!hint.hintText) return
|
|
||||||
|
|
||||||
const widget = new GhostTextWidget(hint.hintText)
|
|
||||||
const afterCursor = 1
|
|
||||||
const decoration = Decoration.widget({ widget, side: afterCursor }).range(hint.cursor)
|
|
||||||
this.decorations = Decoration.set([decoration])
|
|
||||||
}
|
|
||||||
|
|
||||||
getCommandContext(view: EditorView, currentToken: SyntaxNode) {
|
|
||||||
let commandCallNode = currentToken.parent
|
|
||||||
while (commandCallNode?.type.name !== 'CommandCall') {
|
|
||||||
if (!commandCallNode) {
|
|
||||||
throw new Error('No CommandCall parent found, must be an error in the grammar')
|
|
||||||
}
|
|
||||||
commandCallNode = commandCallNode.parent
|
|
||||||
}
|
|
||||||
|
|
||||||
const commandToken = commandCallNode.firstChild
|
|
||||||
if (!commandToken) {
|
|
||||||
throw new Error('CommandCall has no children, must be an error in the grammar')
|
|
||||||
}
|
|
||||||
|
|
||||||
const commandText = view.state.doc.sliceString(commandToken.from, commandToken.to)
|
|
||||||
const { match: commandShape } = matchingCommands(commandText)
|
|
||||||
if (!commandShape) {
|
|
||||||
throw new Error(`No command shape found for command "${commandText}"`)
|
|
||||||
}
|
|
||||||
|
|
||||||
let availableArgs = [...commandShape.args]
|
|
||||||
|
|
||||||
// Walk through all NamedArg children
|
|
||||||
let child = commandToken.nextSibling
|
|
||||||
|
|
||||||
while (child) {
|
|
||||||
console.log('child', child.type.name, child.to - child.from)
|
|
||||||
if (child.type.id === Terms.NamedArg) {
|
|
||||||
const argName = child.firstChild // Should be the Identifier
|
|
||||||
if (argName) {
|
|
||||||
const argText = view.state.doc.sliceString(argName.from, argName.to - 1)
|
|
||||||
availableArgs = availableArgs.filter((arg) => arg.name !== argText)
|
|
||||||
}
|
|
||||||
} else if (child.type.id == Terms.Arg) {
|
|
||||||
const hasSpaceAfter = view.state.doc.sliceString(child.to, child.to + 1) === ' '
|
|
||||||
if (hasSpaceAfter) {
|
|
||||||
availableArgs.shift()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
child = child.nextSibling
|
|
||||||
}
|
|
||||||
|
|
||||||
return { commandShape, availableArgs }
|
|
||||||
}
|
|
||||||
},
|
|
||||||
{
|
|
||||||
decorations: (v) => v.decorations,
|
|
||||||
eventHandlers: {
|
|
||||||
keydown(event, view) {
|
|
||||||
if (event.key === 'Tab') {
|
|
||||||
event.preventDefault()
|
|
||||||
const plugin = view.plugin(inlineHints[0]! as ViewPlugin<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)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -1,184 +0,0 @@
|
||||||
import { multilineModeSignal, outputSignal } from '#editor/editor'
|
|
||||||
import { printBytecodeOutput, printParserOutput, runCode } from '#editor/runCode'
|
|
||||||
import { EditorState } from '@codemirror/state'
|
|
||||||
import { keymap } from '@codemirror/view'
|
|
||||||
|
|
||||||
let multilineMode = false
|
|
||||||
|
|
||||||
const customKeymap = keymap.of([
|
|
||||||
{
|
|
||||||
key: 'Enter',
|
|
||||||
run: (view) => {
|
|
||||||
if (multilineMode) return false
|
|
||||||
|
|
||||||
const input = view.state.doc.toString()
|
|
||||||
history.push(input)
|
|
||||||
runCode(input)
|
|
||||||
view.dispatch({
|
|
||||||
changes: { from: 0, to: view.state.doc.length, insert: '' },
|
|
||||||
selection: { anchor: 0 },
|
|
||||||
})
|
|
||||||
return true
|
|
||||||
},
|
|
||||||
},
|
|
||||||
|
|
||||||
{
|
|
||||||
key: 'Shift-Enter',
|
|
||||||
run: (view) => {
|
|
||||||
if (multilineMode) {
|
|
||||||
const input = view.state.doc.toString()
|
|
||||||
runCode(input)
|
|
||||||
|
|
||||||
return true
|
|
||||||
} else {
|
|
||||||
outputSignal.emit('Press Shift+Enter to insert run the code.')
|
|
||||||
}
|
|
||||||
|
|
||||||
multilineModeSignal.emit(true)
|
|
||||||
multilineMode = true
|
|
||||||
view.dispatch({
|
|
||||||
changes: { from: view.state.doc.length, insert: '\n' },
|
|
||||||
selection: { anchor: view.state.doc.length + 1 },
|
|
||||||
})
|
|
||||||
|
|
||||||
return true
|
|
||||||
},
|
|
||||||
},
|
|
||||||
|
|
||||||
{
|
|
||||||
key: 'Tab',
|
|
||||||
preventDefault: true,
|
|
||||||
run: (view) => {
|
|
||||||
view.dispatch({
|
|
||||||
changes: { from: view.state.selection.main.from, insert: ' ' },
|
|
||||||
selection: { anchor: view.state.selection.main.from + 2 },
|
|
||||||
})
|
|
||||||
return true
|
|
||||||
},
|
|
||||||
},
|
|
||||||
|
|
||||||
{
|
|
||||||
key: 'ArrowUp',
|
|
||||||
run: (view) => {
|
|
||||||
if (multilineMode) return false
|
|
||||||
|
|
||||||
const command = history.previous()
|
|
||||||
if (command === undefined) return false
|
|
||||||
view.dispatch({
|
|
||||||
changes: { from: 0, to: view.state.doc.length, insert: command },
|
|
||||||
selection: { anchor: command.length },
|
|
||||||
})
|
|
||||||
return true
|
|
||||||
},
|
|
||||||
},
|
|
||||||
|
|
||||||
{
|
|
||||||
key: 'ArrowDown',
|
|
||||||
run: (view) => {
|
|
||||||
if (multilineMode) return false
|
|
||||||
|
|
||||||
const command = history.next()
|
|
||||||
if (command === undefined) return false
|
|
||||||
view.dispatch({
|
|
||||||
changes: { from: 0, to: view.state.doc.length, insert: command },
|
|
||||||
selection: { anchor: command.length },
|
|
||||||
})
|
|
||||||
return true
|
|
||||||
},
|
|
||||||
},
|
|
||||||
|
|
||||||
{
|
|
||||||
key: 'Mod-k 1',
|
|
||||||
preventDefault: true,
|
|
||||||
run: (view) => {
|
|
||||||
const input = view.state.doc.toString()
|
|
||||||
printParserOutput(input)
|
|
||||||
|
|
||||||
return true
|
|
||||||
},
|
|
||||||
},
|
|
||||||
|
|
||||||
{
|
|
||||||
key: 'Mod-k 2',
|
|
||||||
preventDefault: true,
|
|
||||||
run: (view) => {
|
|
||||||
const input = view.state.doc.toString()
|
|
||||||
printBytecodeOutput(input)
|
|
||||||
|
|
||||||
return true
|
|
||||||
},
|
|
||||||
},
|
|
||||||
])
|
|
||||||
|
|
||||||
let firstTime = true
|
|
||||||
const singleLineFilter = EditorState.transactionFilter.of((transaction) => {
|
|
||||||
if (multilineMode) return transaction // Allow everything in multiline mode
|
|
||||||
|
|
||||||
if (firstTime) {
|
|
||||||
firstTime = false
|
|
||||||
if (transaction.newDoc.toString().includes('\n')) {
|
|
||||||
multilineMode = true
|
|
||||||
return transaction
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
transaction.changes.iterChanges((fromA, toA, fromB, toB, inserted) => {
|
|
||||||
if (inserted.toString().includes('\n')) {
|
|
||||||
multilineMode = true
|
|
||||||
return
|
|
||||||
}
|
|
||||||
})
|
|
||||||
|
|
||||||
return transaction
|
|
||||||
})
|
|
||||||
|
|
||||||
export const shrimpKeymap = [customKeymap, singleLineFilter]
|
|
||||||
|
|
||||||
class History {
|
|
||||||
private commands: string[] = []
|
|
||||||
private index: number | undefined
|
|
||||||
private storageKey = 'shrimp-command-history'
|
|
||||||
|
|
||||||
constructor() {
|
|
||||||
try {
|
|
||||||
this.commands = JSON.parse(localStorage.getItem(this.storageKey) || '[]')
|
|
||||||
} catch {
|
|
||||||
console.warn('Failed to load command history from localStorage')
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
push(command: string) {
|
|
||||||
this.commands.push(command)
|
|
||||||
|
|
||||||
// Limit to last 50 commands
|
|
||||||
this.commands = this.commands.slice(-50)
|
|
||||||
localStorage.setItem(this.storageKey, JSON.stringify(this.commands))
|
|
||||||
this.index = undefined
|
|
||||||
}
|
|
||||||
|
|
||||||
previous(): string | undefined {
|
|
||||||
if (this.commands.length === 0) return
|
|
||||||
|
|
||||||
if (this.index === undefined) {
|
|
||||||
this.index = this.commands.length - 1
|
|
||||||
} else if (this.index > 0) {
|
|
||||||
this.index -= 1
|
|
||||||
}
|
|
||||||
|
|
||||||
return this.commands[this.index]
|
|
||||||
}
|
|
||||||
|
|
||||||
next(): string | undefined {
|
|
||||||
if (this.commands.length === 0 || this.index === undefined) return
|
|
||||||
|
|
||||||
if (this.index < this.commands.length - 1) {
|
|
||||||
this.index += 1
|
|
||||||
return this.commands[this.index]
|
|
||||||
} else {
|
|
||||||
this.index = undefined
|
|
||||||
return ''
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const history = new History()
|
|
||||||
|
|
@ -1,9 +0,0 @@
|
||||||
import { parser } from '#/parser/shrimp'
|
|
||||||
import { LRLanguage, LanguageSupport } from '@codemirror/language'
|
|
||||||
import { highlighting } from '#/parser/highlight.js'
|
|
||||||
|
|
||||||
const language = LRLanguage.define({
|
|
||||||
parser: parser.configure({ props: [highlighting] }),
|
|
||||||
})
|
|
||||||
|
|
||||||
export const shrimpLanguage = new LanguageSupport(language)
|
|
||||||
|
|
@ -1,35 +0,0 @@
|
||||||
import { history, defaultKeymap, historyKeymap } from '@codemirror/commands'
|
|
||||||
import { bracketMatching, indentOnInput } from '@codemirror/language'
|
|
||||||
import { highlightSpecialChars, drawSelection, dropCursor, keymap } from '@codemirror/view'
|
|
||||||
import { closeBrackets, autocompletion, completionKeymap } from '@codemirror/autocomplete'
|
|
||||||
import { EditorState, Compartment } from '@codemirror/state'
|
|
||||||
import { searchKeymap } from '@codemirror/search'
|
|
||||||
import { shrimpKeymap } from './keymap'
|
|
||||||
import { shrimpTheme, shrimpHighlighting } from './theme'
|
|
||||||
import { shrimpLanguage } from './shrimpLanguage'
|
|
||||||
import { shrimpErrors } from './errors'
|
|
||||||
import { persistencePlugin } from './persistence'
|
|
||||||
import { catchErrors } from './catchErrors'
|
|
||||||
|
|
||||||
export const shrimpSetup = (lineNumbersCompartment: Compartment) => {
|
|
||||||
return [
|
|
||||||
catchErrors,
|
|
||||||
shrimpKeymap,
|
|
||||||
highlightSpecialChars(),
|
|
||||||
history(),
|
|
||||||
drawSelection(),
|
|
||||||
dropCursor(),
|
|
||||||
EditorState.allowMultipleSelections.of(true),
|
|
||||||
bracketMatching(),
|
|
||||||
closeBrackets(),
|
|
||||||
autocompletion(),
|
|
||||||
indentOnInput(),
|
|
||||||
keymap.of([...defaultKeymap, ...historyKeymap, ...searchKeymap, ...completionKeymap]),
|
|
||||||
lineNumbersCompartment.of([]),
|
|
||||||
shrimpTheme,
|
|
||||||
shrimpLanguage,
|
|
||||||
shrimpHighlighting,
|
|
||||||
shrimpErrors,
|
|
||||||
persistencePlugin,
|
|
||||||
]
|
|
||||||
}
|
|
||||||
|
|
@ -1,60 +0,0 @@
|
||||||
import { EditorView } from '@codemirror/view'
|
|
||||||
import { HighlightStyle, syntaxHighlighting } from '@codemirror/language'
|
|
||||||
import { tags } from '@lezer/highlight'
|
|
||||||
|
|
||||||
const highlightStyle = HighlightStyle.define([
|
|
||||||
{ tag: tags.keyword, color: 'var(--color-keyword)' },
|
|
||||||
{ tag: tags.name, color: 'var(--color-function)' },
|
|
||||||
{ tag: tags.string, color: 'var(--color-string)' },
|
|
||||||
{ tag: tags.number, color: 'var(--color-number)' },
|
|
||||||
{ tag: tags.bool, color: 'var(--color-bool)' },
|
|
||||||
{ tag: tags.operator, color: 'var(--color-operator)' },
|
|
||||||
{ tag: tags.paren, color: 'var(--color-paren)' },
|
|
||||||
{ tag: tags.regexp, color: 'var(--color-regex)' },
|
|
||||||
{ tag: tags.function(tags.variableName), color: 'var(--color-function-call)' },
|
|
||||||
{ tag: tags.function(tags.invalid), color: 'white' },
|
|
||||||
{
|
|
||||||
tag: tags.definition(tags.variableName),
|
|
||||||
color: 'var(--color-variable-def)',
|
|
||||||
backgroundColor: 'var(--bg-variable-def)',
|
|
||||||
padding: '1px 2px',
|
|
||||||
borderRadius: '2px',
|
|
||||||
fontWeight: '500',
|
|
||||||
},
|
|
||||||
])
|
|
||||||
|
|
||||||
export const shrimpHighlighting = syntaxHighlighting(highlightStyle)
|
|
||||||
|
|
||||||
export const shrimpTheme = EditorView.theme(
|
|
||||||
{
|
|
||||||
'&': {
|
|
||||||
color: 'var(--text-editor)',
|
|
||||||
backgroundColor: 'var(--bg-editor)',
|
|
||||||
height: '100%',
|
|
||||||
fontSize: '18px',
|
|
||||||
},
|
|
||||||
'.cm-content': {
|
|
||||||
fontFamily: '"Pixeloid Mono", "Courier New", monospace',
|
|
||||||
caretColor: 'var(--caret)',
|
|
||||||
padding: '0px',
|
|
||||||
},
|
|
||||||
'&.cm-focused .cm-cursor': {
|
|
||||||
borderLeftColor: 'var(--caret)',
|
|
||||||
},
|
|
||||||
'&.cm-focused .cm-selectionBackground, ::selection': {
|
|
||||||
backgroundColor: 'var(--bg-selection)',
|
|
||||||
},
|
|
||||||
'.cm-editor': {
|
|
||||||
border: 'none',
|
|
||||||
outline: 'none',
|
|
||||||
height: '100%',
|
|
||||||
},
|
|
||||||
'.cm-matchingBracket': {
|
|
||||||
backgroundColor: 'var(--color-bool)',
|
|
||||||
},
|
|
||||||
'.cm-nonmatchingBracket': {
|
|
||||||
backgroundColor: 'var(--color-string)',
|
|
||||||
},
|
|
||||||
},
|
|
||||||
{ dark: true },
|
|
||||||
)
|
|
||||||
|
|
@ -1,38 +0,0 @@
|
||||||
import { outputSignal, errorSignal } from '#editor/editor'
|
|
||||||
import { Compiler } from '#compiler/compiler'
|
|
||||||
import { errorMessage, log } from '#utils/utils'
|
|
||||||
import { bytecodeToString } from 'reefvm'
|
|
||||||
import { parser } from '#parser/shrimp'
|
|
||||||
import { sendToNose } from '#editor/noseClient'
|
|
||||||
import { treeToString } from '#utils/tree'
|
|
||||||
|
|
||||||
export const runCode = async (input: string) => {
|
|
||||||
try {
|
|
||||||
const compiler = new Compiler(input)
|
|
||||||
sendToNose(compiler.bytecode)
|
|
||||||
} catch (error) {
|
|
||||||
log.error(error)
|
|
||||||
errorSignal.emit(`${errorMessage(error)}`)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export const printParserOutput = (input: string) => {
|
|
||||||
try {
|
|
||||||
const cst = parser.parse(input)
|
|
||||||
const string = treeToString(cst, input)
|
|
||||||
outputSignal.emit(string)
|
|
||||||
} catch (error) {
|
|
||||||
log.error(error)
|
|
||||||
errorSignal.emit(`${errorMessage(error)}`)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export const printBytecodeOutput = (input: string) => {
|
|
||||||
try {
|
|
||||||
const compiler = new Compiler(input)
|
|
||||||
outputSignal.emit(bytecodeToString(compiler.bytecode))
|
|
||||||
} catch (error) {
|
|
||||||
log.error(error)
|
|
||||||
errorSignal.emit(`${errorMessage(error)}`)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
51
src/editor/theme.ts
Normal file
51
src/editor/theme.ts
Normal file
|
|
@ -0,0 +1,51 @@
|
||||||
|
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 @@
|
||||||
const DEBUG = process.env.DEBUG || false
|
import { isDebug } from '#utils/utils'
|
||||||
|
|
||||||
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 (DEBUG) {
|
if (isDebug()) {
|
||||||
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,6 +19,7 @@ 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,
|
||||||
|
|
@ -31,17 +32,28 @@ export const globals: Record<string, any> = {
|
||||||
str,
|
str,
|
||||||
|
|
||||||
// shrimp runtime info
|
// shrimp runtime info
|
||||||
$: {
|
$: runningInBrowser
|
||||||
args: Bun.argv.slice(3),
|
? {
|
||||||
argv: Bun.argv.slice(1),
|
args: [],
|
||||||
env: process.env,
|
argv: [],
|
||||||
pid: process.pid,
|
env: {},
|
||||||
cwd: process.env.PWD,
|
pid: 0,
|
||||||
script: {
|
cwd: '',
|
||||||
name: Bun.argv[2] || '(shrimp)',
|
script: {
|
||||||
path: resolve(join('.', Bun.argv[2] ?? '')),
|
name: '',
|
||||||
},
|
path: '.',
|
||||||
},
|
},
|
||||||
|
} : {
|
||||||
|
args: Bun.argv.slice(3),
|
||||||
|
argv: Bun.argv.slice(1),
|
||||||
|
env: process.env,
|
||||||
|
pid: process.pid,
|
||||||
|
cwd: process.env.PWD,
|
||||||
|
script: {
|
||||||
|
name: Bun.argv[2] || '(shrimp)',
|
||||||
|
path: resolve(join('.', Bun.argv[2] ?? '')),
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
// hello
|
// hello
|
||||||
echo: (...args: any[]) => {
|
echo: (...args: any[]) => {
|
||||||
|
|
@ -49,7 +61,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)
|
||||||
},
|
},
|
||||||
|
|
@ -120,7 +132,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]
|
||||||
|
|
|
||||||
|
|
@ -1,10 +0,0 @@
|
||||||
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)
|
|
||||||
|
|
@ -1,84 +0,0 @@
|
||||||
:root {
|
|
||||||
/* Background colors */
|
|
||||||
--bg-editor: #011627;
|
|
||||||
--bg-output: #40318d;
|
|
||||||
--bg-status-bar: #1e2a4a;
|
|
||||||
--bg-status-border: #0e1a3a;
|
|
||||||
--bg-selection: #1d3b53;
|
|
||||||
--bg-variable-def: #1e2a4a;
|
|
||||||
|
|
||||||
/* Text colors */
|
|
||||||
--text-editor: #d6deeb;
|
|
||||||
--text-output: #7c70da;
|
|
||||||
--text-status: #b3a9ff55;
|
|
||||||
--caret: #80a4c2;
|
|
||||||
|
|
||||||
/* Syntax highlighting colors */
|
|
||||||
--color-keyword: #c792ea;
|
|
||||||
--color-function: #82aaff;
|
|
||||||
--color-string: #c3e88d;
|
|
||||||
--color-number: #f78c6c;
|
|
||||||
--color-bool: #ff5370;
|
|
||||||
--color-operator: #89ddff;
|
|
||||||
--color-paren: #676e95;
|
|
||||||
--color-function-call: #ff9cac;
|
|
||||||
--color-variable-def: #ffcb6b;
|
|
||||||
--color-error: #ff6e6e;
|
|
||||||
--color-regex: #e1acff;
|
|
||||||
|
|
||||||
/* ANSI terminal colors */
|
|
||||||
--ansi-black: #011627;
|
|
||||||
--ansi-red: #ff5370;
|
|
||||||
--ansi-green: #c3e88d;
|
|
||||||
--ansi-yellow: #ffcb6b;
|
|
||||||
--ansi-blue: #82aaff;
|
|
||||||
--ansi-magenta: #c792ea;
|
|
||||||
--ansi-cyan: #89ddff;
|
|
||||||
--ansi-white: #d6deeb;
|
|
||||||
|
|
||||||
/* ANSI bright colors (slightly more vibrant) */
|
|
||||||
--ansi-bright-black: #676e95;
|
|
||||||
--ansi-bright-red: #ff6e90;
|
|
||||||
--ansi-bright-green: #d4f6a8;
|
|
||||||
--ansi-bright-yellow: #ffe082;
|
|
||||||
--ansi-bright-blue: #a8c7fa;
|
|
||||||
--ansi-bright-magenta: #e1acff;
|
|
||||||
--ansi-bright-cyan: #a8f5ff;
|
|
||||||
--ansi-bright-white: #ffffff;
|
|
||||||
}
|
|
||||||
|
|
||||||
@font-face {
|
|
||||||
font-family: 'C64ProMono';
|
|
||||||
src: url('../../assets/C64_Pro_Mono-STYLE.woff2') format('woff2');
|
|
||||||
font-weight: normal;
|
|
||||||
font-style: normal;
|
|
||||||
}
|
|
||||||
|
|
||||||
@font-face {
|
|
||||||
font-family: 'Pixeloid Mono';
|
|
||||||
src: url('../../assets/PixeloidMono.ttf') format('truetype');
|
|
||||||
font-weight: normal;
|
|
||||||
font-style: normal;
|
|
||||||
}
|
|
||||||
|
|
||||||
* {
|
|
||||||
margin: 0;
|
|
||||||
padding: 0;
|
|
||||||
box-sizing: border-box;
|
|
||||||
}
|
|
||||||
|
|
||||||
body {
|
|
||||||
background: var(--bg-output);
|
|
||||||
color: var(--text-output);
|
|
||||||
font-family: 'Pixeloid Mono', 'Courier New', monospace;
|
|
||||||
font-size: 18px;
|
|
||||||
height: 100vh;
|
|
||||||
overflow: hidden;
|
|
||||||
}
|
|
||||||
|
|
||||||
#root {
|
|
||||||
height: 100vh;
|
|
||||||
background: var(--bg-output);
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
}
|
|
||||||
|
|
@ -1,12 +0,0 @@
|
||||||
<!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>
|
|
||||||
|
|
@ -1,29 +0,0 @@
|
||||||
import index from './index.html'
|
|
||||||
|
|
||||||
const server = Bun.serve({
|
|
||||||
port: process.env.PORT ? Number(process.env.PORT) : 3001,
|
|
||||||
routes: {
|
|
||||||
'/*': index,
|
|
||||||
|
|
||||||
'/api/hello': {
|
|
||||||
async GET(req) {
|
|
||||||
return Response.json({
|
|
||||||
message: 'Hello, world!',
|
|
||||||
method: 'GET',
|
|
||||||
})
|
|
||||||
},
|
|
||||||
async PUT(req) {
|
|
||||||
return Response.json({
|
|
||||||
message: 'Hello, world!',
|
|
||||||
method: 'PUT',
|
|
||||||
})
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
development: process.env.NODE_ENV !== 'production' && {
|
|
||||||
hmr: true,
|
|
||||||
console: true,
|
|
||||||
},
|
|
||||||
})
|
|
||||||
|
|
||||||
console.log(`🚀 Server running at ${server.url}`)
|
|
||||||
|
|
@ -1,68 +0,0 @@
|
||||||
/**
|
|
||||||
* 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,3 +136,12 @@ 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