switch to codemirror

This commit is contained in:
2025-06-17 11:48:01 -07:00
parent ab276ca992
commit e167d7e629
11 changed files with 925 additions and 244 deletions

196
playground/src/cmeditor.ts Normal file
View File

@@ -0,0 +1,196 @@
import { AbstractEditor, EditorDelegate, Marker } from "./types";
import { basicSetup } from "codemirror";
import { EditorView, hoverTooltip, Tooltip } from "@codemirror/view";
import { Compartment } from "@codemirror/state";
import { parser } from "./parser.js";
import { oneDark, oneDark as themeOneDark } from "@codemirror/theme-one-dark";
import { styleTags, tags as t } from "@lezer/highlight";
import { linter, Diagnostic } from "@codemirror/lint";
import {
LanguageSupport,
LRLanguage,
StreamLanguage,
StringStream,
} from "@codemirror/language";
let parserWithMetadata = parser.configure({
props: [
styleTags({
Identifier: t.variableName,
LineComment: t.lineComment,
"if then else data where": t.keyword,
}),
// indentNodeProp, foldNodeProp
],
});
const newtLanguage = LRLanguage.define({
parser: parserWithMetadata,
languageData: {
commentTokens: {
line: "--",
},
},
});
// prettier did this...
const keywords = [
"let",
"in",
"where",
"case",
"of",
"data",
"U",
"do",
"ptype",
"pfunc",
"module",
"infixl",
"infixr",
"infix",
"∀",
"forall",
"import",
"uses",
"class",
"instance",
"record",
"constructor",
"if",
"then",
"else",
"$",
"λ",
"?",
"@",
".",
"->",
"→",
":",
"=>",
":=",
"$=",
"=",
"<-",
"\\",
"_",
"|",
];
interface State {
tokenizer(stream: StringStream, state: State): string | null;
}
function tokenizer(stream: StringStream, state: State): string | null {
stream.eatSpace();
if (stream.match("--")) {
stream.skipToEnd();
return "comment";
}
if (stream.match(/^[/]-/)) {
state.tokenizer = commentTokenizer;
return state.tokenizer(stream, state);
}
if (stream.match(/^[\w_][\w\d_']*/)) {
let word = stream.current();
if (keywords.includes(word)) return "keyword";
if (word[0] >= "A" && word[0] <= "Z") return "typename";
return "identifier";
}
// unhandled
stream.next();
return null;
}
function commentTokenizer(stream: StringStream, state: State): string | null {
console.log("ctok");
let dash = false;
let ch;
while ((ch = stream.next())) {
if (dash && ch === "/") {
state.tokenizer = tokenizer;
return "comment";
}
dash = ch === "-";
}
console.log("XX", stream.current());
return "comment";
}
const newtLanguage2 = StreamLanguage.define({
startState: () => ({ tokenizer }),
token(stream, st) {
return st.tokenizer(stream, st);
},
});
function newt() {
return new LanguageSupport(newtLanguage2);
}
export class CMEditor implements AbstractEditor {
view: EditorView;
delegate: EditorDelegate;
theme: Compartment;
constructor(container: HTMLElement, doc: string, delegate: EditorDelegate) {
this.delegate = delegate;
this.theme = new Compartment();
this.view = new EditorView({
doc,
parent: container,
extensions: [
basicSetup,
linter((view) => this.delegate.lint(view)),
this.theme.of(EditorView.baseTheme({})),
hoverTooltip((view, pos) => {
let cursor = this.view.state.doc.lineAt(pos);
let line = cursor.number;
let range = this.view.state.wordAt(pos);
console.log(range);
if (range) {
let col = range.from - cursor.from;
let word = this.view.state.doc.sliceString(range.from, range.to);
let entry = this.delegate.getEntry(word, line, col);
console.log("entry", entry);
if (entry) {
let rval: Tooltip = {
pos: range.head,
above: true,
create: () => {
let dom = document.createElement("div");
dom.className = "tooltip";
dom.textContent = entry.type;
return { dom };
},
};
return rval;
}
}
// we'll iterate the syntax tree for word.
// let entry = delegate.getEntry(word, line, col)
return null;
}),
newt(),
],
});
}
setDark(isDark: boolean) {
this.view.dispatch({
effects: this.theme.reconfigure(
isDark ? oneDark : EditorView.baseTheme({})
),
});
}
setValue(_doc: string) {
// Is this all we can do?
this.view.dispatch({
changes: { from: 0, to: this.view.state.doc.length, insert: _doc },
});
}
getValue() {
// maybe?
return this.view.state.doc.toString();
}
setMarkers(_: Marker[]) {}
}

View File

@@ -1,91 +1,25 @@
import { effect, signal } from "@preact/signals";
import { newtConfig, newtTokens } from "./monarch.ts";
import * as monaco from "monaco-editor";
import { Diagnostic } from "@codemirror/lint";
import { useEffect, useRef, useState } from "preact/hooks";
import { h, render } from "preact";
import { ChangeEvent } from "preact/compat";
import { archive, preload } from "./preload.ts";
import { CompileReq, CompileRes, Message } from "./types.ts";
import { ABBREV } from "./abbrev.ts";
// editor.(createModel / setModel / getModels) to switch files
// TODO - remember files and allow switching?
// static zip filesystem with user changes overlaid via localStorage
// download individual files (we could use the cheap compression from the pdflib or no compression to make zip download)
// would need way to reset/delete
interface FC {
file: string;
line: number;
col: number;
}
interface TopEntry {
fc: FC;
name: String;
type: String;
}
interface TopData {
context: TopEntry[];
}
import {
AbstractEditor,
EditorDelegate,
CompileReq,
CompileRes,
Message,
TopData,
Marker,
} from "./types.ts";
import { CMEditor } from "./cmeditor.ts";
let topData: undefined | TopData;
// we need to fix the definition of word
monaco.languages.register({ id: "newt" });
monaco.languages.setMonarchTokensProvider("newt", newtTokens);
monaco.languages.setLanguageConfiguration("newt", newtConfig);
monaco.languages.registerDefinitionProvider("newt", {
provideDefinition(model, position, token) {
if (!topData) return;
// HACK - we don't know our filename which was generated from `module` decl, so
// assume the last context entry is in our file.
let last = topData.context[topData.context.length - 1];
let file = last.fc.file;
const info = model.getWordAtPosition(position);
if (!info) return;
let entry = topData.context.find((entry) => entry.name === info.word);
// we can't switch files at the moment
if (!entry || entry.fc.file !== file) return;
let lineNumber = entry.fc.line + 1;
let column = entry.fc.col + 1;
let word = model.getWordAtPosition({ lineNumber, column });
let range = new monaco.Range(lineNumber, column, lineNumber, column);
if (word) {
range = new monaco.Range(
lineNumber,
word.startColumn,
lineNumber,
word.endColumn
);
}
return { uri: model.uri, range };
},
});
monaco.languages.registerHoverProvider("newt", {
provideHover(model, position, token, context) {
if (!topData) return;
const info = model.getWordAtPosition(position);
if (!info) return;
let entry = topData.context.find((entry) => entry.name === info.word);
if (!entry) return;
return {
range: new monaco.Range(
position.lineNumber,
info.startColumn,
position.lineNumber,
info.endColumn
),
contents: [{ value: `${entry.name} : ${entry.type}` }],
};
},
});
const newtWorker = new Worker("worker.js");
// :FIXME use because Safari
let postMessage = (msg: CompileReq) => newtWorker.postMessage(msg);
// Safari/MobileSafari have small stacks in webworkers.
if (navigator.vendor.includes("Apple")) {
const workerFrame = document.createElement("iframe");
@@ -104,7 +38,7 @@ document.body.appendChild(iframe);
function run(src: string) {
console.log("SEND TO", iframe.contentWindow);
const fileName = state.currentFile.value;
postMessage({ type: "compileRequest", fileName, src });
// postMessage({ type: "compileRequest", fileName, src });
}
function runOutput() {
@@ -134,6 +68,9 @@ function setOutput(output: string) {
state.output.value = output;
}
let lastID = 0;
const nextID = () => "" + lastID++;
window.onmessage = (ev: MessageEvent<Message>) => {
console.log("window got", ev.data);
if ("messages" in ev.data) state.messages.value = ev.data.messages;
@@ -142,28 +79,47 @@ window.onmessage = (ev: MessageEvent<Message>) => {
}
// safari callback
if ("output" in ev.data) {
newtWorker.onmessage?.(ev)
setOutput(ev.data.output);
state.javascript.value = ev.data.javascript;
}
};
// TODO wrap up IPC
type Suspense<T> = {
resolve: (value: T | PromiseLike<T>) => void;
reject: (reason?: any) => void;
};
const callbacks: Record<string, Suspense<string>> = {};
newtWorker.onmessage = (ev: MessageEvent<CompileRes>) => {
let suspense = callbacks[ev.data.id];
if (suspense) {
suspense.resolve(ev.data.output);
delete callbacks[ev.data.id];
}
console.log('result', ev.data, 'suspense', suspense)
// FIXME - we want to have the callback take a response for its command
setOutput(ev.data.output);
state.javascript.value = ev.data.javascript;
};
self.MonacoEnvironment = {
getWorkerUrl(moduleId, _label) {
console.log("Get worker", moduleId);
return moduleId;
},
};
function runCommand(req: CompileReq) {
return new Promise<string>(
(resolve, reject) => {
callbacks[req.id] = { resolve, reject }
postMessage(req);
}
);
}
const state = {
output: signal(""),
javascript: signal(""),
messages: signal<string[]>([]),
editor: signal<monaco.editor.IStandaloneCodeEditor | null>(null),
editor: signal<AbstractEditor | null>(null),
dark: signal(false),
files: signal<string[]>(["Tour.newt"]),
currentFile: signal<string>(localStorage.currentFile ?? "Tour.newt"),
@@ -174,13 +130,15 @@ if (window.matchMedia) {
function checkDark(ev: { matches: boolean }) {
console.log("CHANGE", ev);
if (ev.matches) {
monaco.editor.setTheme("vs-dark");
// monaco.editor.setTheme("vs-dark");
document.body.className = "dark";
state.dark.value = true;
state.editor.value?.setDark(true);
} else {
monaco.editor.setTheme("vs");
// monaco.editor.setTheme("vs");
document.body.className = "light";
state.dark.value = false;
state.editor.value?.setDark(false);
}
}
let query = window.matchMedia("(prefers-color-scheme: dark)");
@@ -215,69 +173,76 @@ let initialVertical = localStorage.vertical == "true";
effect(() => {
let text = state.output.value;
let editor = state.editor.value;
if (editor) processOutput(editor, text);
// TODO - abstract this for both editors
// if (editor) processOutput(editor, text);
});
interface EditorProps {
initialValue: string;
}
const language: EditorDelegate = {
getEntry(word, _row, _col) {
return topData?.context.find((entry) => entry.name === word);
},
onChange(value) {
// clearTimeout(timeout);
// timeout = setTimeout(() => {
// run(value);
// localStorage.code = value;
// }, 1000);
},
getFileName() {
if (!topData) return "";
let last = topData.context[topData.context.length - 1];
return last.fc.file;
},
async lint(view) {
console.log("LINT");
let src = view.state.doc.toString();
const fileName = state.currentFile.value;
console.log("FN", fileName);
// console.log("SRC", src);
try {
let out = await runCommand({
id: nextID(),
type: "compileRequest",
fileName,
src,
});
console.log("OUT", out);
let markers = processOutput(out);
let diags: Diagnostic[] = []
for (let marker of markers) {
let col = marker.startColumn
let line = view.state.doc.line(marker.startLineNumber)
const pos = line.from + col - 1;
let word = view.state.wordAt(pos)
diags.push({
from: word?.from ?? pos,
to: word?.to ?? pos+1,
severity: marker.severity,
message: marker.message,
})
}
return diags
} catch (e) {
console.log("ERR", e);
}
return [];
},
};
function Editor({ initialValue }: EditorProps) {
const ref = useRef<HTMLDivElement>(null);
useEffect(() => {
const container = ref.current!;
const editor = monaco.editor.create(container, {
value,
language: "newt",
fontFamily: "Comic Code, Menlo, Monaco, Courier New, sans",
automaticLayout: true,
acceptSuggestionOnEnter: "off",
unicodeHighlight: { ambiguousCharacters: false },
minimap: { enabled: false },
});
const editor = new CMEditor(container, value, language);
// const editor = new MonacoEditor(container, value, language)
state.editor.value = editor;
editor.onDidChangeModelContent((ev) => {
clearTimeout(timeout);
timeout = setTimeout(() => {
let value = editor.getValue();
run(value);
localStorage.code = value;
}, 1000);
let last = ev.changes[ev.changes.length - 1];
const model = editor.getModel();
// figure out heuristics here, what chars do we want to trigger?
// the lean one will only be active if it sees you type \
// and bail if it decides you've gone elsewhere
// it maintains an underline annotation, too.
if (model && last.text && " ')\\".includes(last.text)) {
console.log('LAST', last)
let { startLineNumber, startColumn } = last.range;
const text = model.getValueInRange(
new monaco.Range(
startLineNumber,
Math.max(1, startColumn - 10),
startLineNumber,
startColumn
)
);
const m = text.match(/(\\[^ ]+)$/);
if (m) {
let cand = m[0];
console.log("GOT", cand);
let text = ABBREV[cand];
if (text) {
const range = new monaco.Range(
startLineNumber,
startColumn - cand.length,
startLineNumber,
startColumn
);
editor.executeEdits("replaceSequence", [{ range, text: text }]);
}
}
}
});
editor.setDark(state.dark.value);
if (initialValue === LOADING) loadFile("Tour.newt");
else run(initialValue);
}, []);
@@ -431,11 +396,12 @@ let timeout: number | undefined;
// Adapted from the vscode extension, but types are slightly different
// and positions are 1-based.
const processOutput = (
editor: monaco.editor.IStandaloneCodeEditor,
// editor: AbstractEditor,
output: string
) => {
let model = editor.getModel()!;
let markers: monaco.editor.IMarkerData[] = [];
// let model = editor.getModel()!;
console.log('process output', output)
let markers: Marker[] = [];
let lines = output.split("\n");
let m = lines[0].match(/.*Process (.*)/);
let fn = "";
@@ -451,11 +417,8 @@ const processOutput = (
if (fn && fn !== file) {
lineNumber = column = 0;
}
let start = { column, lineNumber };
// we don't have the full range, so grab the surrounding word
let endColumn = column + 1;
let word = model.getWordAtPosition(start);
if (word) endColumn = word.endColumn;
// heuristics to grab the entire message:
// anything indented
@@ -464,13 +427,9 @@ const processOutput = (
while (lines[i + 1]?.match(/^( )/)) {
message += "\n" + lines[++i];
}
const severity =
kind === "ERROR"
? monaco.MarkerSeverity.Error
: monaco.MarkerSeverity.Info;
if (kind === "ERROR" || lineNumber)
markers.push({
severity,
severity: kind === 'ERROR' ? 'error' : 'info',
message,
startLineNumber: lineNumber,
endLineNumber: lineNumber,
@@ -479,5 +438,7 @@ const processOutput = (
});
}
}
monaco.editor.setModelMarkers(model, "newt", markers);
console.log('markers', markers)
// editor.setMarkers(markers)
return markers;
};

210
playground/src/monaco.ts Normal file
View File

@@ -0,0 +1,210 @@
import { ABBREV } from "./abbrev.ts";
import { newtConfig, newtTokens } from "./monarch.ts";
import * as monaco from "monaco-editor";
import { AbstractEditor, EditorDelegate, Marker } from "./types.ts";
// we need to fix the definition of word
monaco.languages.register({ id: "newt" });
monaco.languages.setMonarchTokensProvider("newt", newtTokens);
monaco.languages.setLanguageConfiguration("newt", newtConfig);
self.MonacoEnvironment = {
getWorkerUrl(moduleId, _label) {
console.log("Get worker", moduleId);
return moduleId;
},
};
export class MonacoEditor implements AbstractEditor {
editor: monaco.editor.IStandaloneCodeEditor;
delegate: EditorDelegate;
constructor(container: HTMLElement, value: string, language: EditorDelegate) {
this.delegate = language;
let editor = (this.editor = monaco.editor.create(container, {
value,
language: "newt",
fontFamily: "Comic Code, Menlo, Monaco, Courier New, sans",
automaticLayout: true,
acceptSuggestionOnEnter: "off",
unicodeHighlight: { ambiguousCharacters: false },
minimap: { enabled: false },
}));
monaco.languages.registerDefinitionProvider("newt", {
provideDefinition(model, position, token) {
const info = model.getWordAtPosition(position);
if (!info) return;
let entry = language.getEntry(
info.word,
position.lineNumber,
info.startColumn
);
if (!entry) return;
// HACK - we don't know our filename which was generated from `module` decl, so
// assume the last context entry is in our file.
let file = language.getFileName();
if (!entry || entry.fc.file !== file) return;
let lineNumber = entry.fc.line + 1;
let column = entry.fc.col + 1;
let word = model.getWordAtPosition({ lineNumber, column });
let range = new monaco.Range(lineNumber, column, lineNumber, column);
if (word) {
range = new monaco.Range(
lineNumber,
word.startColumn,
lineNumber,
word.endColumn
);
}
return { uri: model.uri, range };
},
});
monaco.languages.registerHoverProvider("newt", {
provideHover(model, position, token, context) {
const info = model.getWordAtPosition(position);
if (!info) return;
let entry = language.getEntry(
info.word,
position.lineNumber,
info.startColumn
);
if (!entry) return;
return {
range: new monaco.Range(
position.lineNumber,
info.startColumn,
position.lineNumber,
info.endColumn
),
contents: [{ value: `${entry.name} : ${entry.type}` }],
};
},
});
editor.onDidChangeModelContent((ev) => {
this.delegate.onChange(editor.getValue());
let last = ev.changes[ev.changes.length - 1];
const model = editor.getModel();
// figure out heuristics here, what chars do we want to trigger?
// the lean one will only be active if it sees you type \
// and bail if it decides you've gone elsewhere
// it maintains an underline annotation, too.
if (model && last.text && " ')\\".includes(last.text)) {
console.log("LAST", last);
let { startLineNumber, startColumn } = last.range;
const text = model.getValueInRange(
new monaco.Range(
startLineNumber,
Math.max(1, startColumn - 10),
startLineNumber,
startColumn
)
);
const m = text.match(/(\\[^ ]+)$/);
if (m) {
let cand = m[0];
console.log("GOT", cand);
let text = ABBREV[cand];
if (text) {
const range = new monaco.Range(
startLineNumber,
startColumn - cand.length,
startLineNumber,
startColumn
);
editor.executeEdits("replaceSequence", [{ range, text: text }]);
}
}
}
});
}
setValue(value: string) {
this.editor.setValue(value);
}
getValue() {
return this.editor.getValue();
}
setMarkers(markers: Marker[]) {
let model = this.editor.getModel()!;
const mapMarker = (marker: Marker): monaco.editor.IMarkerData => {
let severity =
marker.severity === "ERROR"
? monaco.MarkerSeverity.Error
: monaco.MarkerSeverity.Info;
// translate to surrounding word
// FIXME - we have `getWord` in monaco, but probably belongs to the delegate
// and eventually we have better FC
let { message, startColumn, endColumn, startLineNumber, endLineNumber } =
marker;
let word = model.getWordAtPosition({
column: startColumn,
lineNumber: startLineNumber,
});
if (word) endColumn = word.endColumn;
return {
message,
startColumn,
endColumn,
startLineNumber,
endLineNumber,
severity,
};
};
monaco.editor.setModelMarkers(model, "newt", markers.map(mapMarker));
}
}
// scratch
const processOutput = (
editor: monaco.editor.IStandaloneCodeEditor,
output: string
) => {
let model = editor.getModel()!;
let markers: monaco.editor.IMarkerData[] = [];
let lines = output.split("\n");
let m = lines[0].match(/.*Process (.*)/);
let fn = "";
if (m) fn = m[1];
for (let i = 0; i < lines.length; i++) {
const line = lines[i];
const match = line.match(/(INFO|ERROR) at (.*):\((\d+), (\d+)\):\s*(.*)/);
if (match) {
let [_full, kind, file, line, col, message] = match;
let lineNumber = +line + 1;
let column = +col + 1;
// FIXME - pass the real path in
if (fn && fn !== file) {
lineNumber = column = 0;
}
let start = { column, lineNumber };
// we don't have the full range, so grab the surrounding word
let endColumn = column + 1;
let word = model.getWordAtPosition(start);
if (word) endColumn = word.endColumn;
// heuristics to grab the entire message:
// anything indented
// Context:, or Goal: are part of PRINTME
// unexpected / expecting appear in parse errors
while (lines[i + 1]?.match(/^( )/)) {
message += "\n" + lines[++i];
}
const severity =
kind === "ERROR"
? monaco.MarkerSeverity.Error
: monaco.MarkerSeverity.Info;
if (kind === "ERROR" || lineNumber)
markers.push({
severity,
message,
startLineNumber: lineNumber,
endLineNumber: lineNumber,
startColumn: column,
endColumn,
});
}
}
monaco.editor.setModelMarkers(model, "newt", markers);
};

View File

@@ -1,10 +1,15 @@
import { EditorView } from "codemirror";
import { linter, Diagnostic } from "@codemirror/lint";
export interface CompileReq {
id: string
type: "compileRequest";
fileName: string;
src: string;
}
export interface CompileRes {
id: string
type: "compileResult";
output: string;
javascript: string;
@@ -26,3 +31,44 @@ export interface ExecCode {
}
export type Message = CompileReq | CompileRes | ConsoleList | ConsoleItem | ExecCode
// editor.(createModel / setModel / getModels) to switch files
// TODO - remember files and allow switching?
// static zip filesystem with user changes overlaid via localStorage
// download individual files (we could use the cheap compression from the pdflib or no compression to make zip download)
// would need way to reset/delete
export interface FC {
file: string;
line: number;
col: number;
}
interface TopEntry {
fc: FC;
name: string;
type: string;
}
export interface TopData {
context: TopEntry[];
}
export interface EditorDelegate {
getEntry(word: string, row: number, col: number): TopEntry | undefined
onChange(value: string): unknown
getFileName(): string
lint(view: EditorView): Promise<Diagnostic[]> | Diagnostic[]
}
export interface Marker {
severity: 'error' | 'info' | 'warning'
message: string
startColumn: number
startLineNumber: number
endColumn: number
endLineNumber: number
}
export interface AbstractEditor {
setValue: (_: string) => unknown;
getValue: () => string;
setMarkers: (_: Marker[]) => unknown
setDark(isDark: boolean): unknown
}

View File

@@ -10,7 +10,7 @@ const handleMessage = async function (ev: { data: CompileReq }) {
console.log("message", ev.data);
await preload;
shim.archive = archive;
let { src, fileName } = ev.data;
let { id, src, fileName } = ev.data;
const outfile = "out.js";
shim.process.argv = ["browser", "newt", fileName, "-o", outfile, "--top"];
shim.files[fileName] = new TextEncoder().encode(src);
@@ -29,7 +29,7 @@ const handleMessage = async function (ev: { data: CompileReq }) {
console.log(`process ${fileName} in ${duration} ms`);
let javascript = new TextDecoder().decode(shim.files[outfile]);
let output = shim.stdout;
sendResponse({ type: 'compileResult', javascript, output, duration });
sendResponse({ id, type: 'compileResult', javascript, output, duration });
};
// hooks for worker.html to override