Odin Speaks Odin ยท Appendix H

Appendix H: A browser IDE for Muspel

Chapter 20 gave the browser engine a way to load a game from source it is handed at runtime: JavaScript writes a .mus file's bytes into src_recv_buf, calls engine_module_add, walks the import graph, and finishes with engine_run_modules. The page's game switcher is nothing more than that protocol driven from a list of folder names. This appendix drives the very same protocol from a different source: a text box you can type in. The result is a small in-browser IDE, engine/ide.html, with the editor on the left, the running game on the right, a RUN button that turns into STOP, and localStorage persistence, download, and upload.

Almost none of that needs new engine code. The switcher already proves a game can be loaded from bytes rather than from disk; feeding those bytes from a <textarea> instead of a fetch is a change to the page, not to the engine. There is exactly one idea in this appendix worth the engine's while, and it is the syntax highlighting.

Design decision: the editor's syntax highlighting is Muspel's own lexer (muspel/lexer.odin), reached through a new export, not a second grammar re-implemented in JavaScript. Alternatives considered: the usual browser-editor approach, a pile of regular expressions (or a hand-written JavaScript tokeniser) that approximate the language's lexical grammar well enough to colour it. Trade-off: a JavaScript tokeniser is self-contained (no round trip into WASM) but is a second definition of what a keyword, a number, or a string literal is, one that drifts the moment Chapter 2's lexer changes and nobody remembers the copy. Reusing the real lexer means the colour on screen is produced by the exact code the compiler will run: add a keyword to keyword_kind, and the editor colours it as a keyword with no further work. The cost is one WASM call per repaint, which for an editor-sized buffer is far too cheap to notice. This is the same "single source of truth" discipline the opcode table (Chapter 8) and the module store (Chapter 20) already follow, applied to lexing.

H.1 What the engine already gives us

Before writing a line, it is worth being precise about how little is missing, because it explains the shape of everything below.

  • Running a game from bytes is the module protocol from Chapter 20: engine_module_reset, then engine_module_add with the source in src_recv_buf, then engine_run_modules. The IDE's RUN button is that sequence with main.mus's bytes coming from the editor. See ยงH.5.
  • Rendering is batch.js appending its <canvas> into #app, unchanged. The IDE just puts #app in the right-hand pane.
  • Audio, persistence, and the FPS readout are the same app_env imports index.html already supplies to Batch.instantiate. The IDE copies that object verbatim; the engine cannot instantiate without every import it declares, so leaving one out is not optional.

Two things the engine cannot yet do, and which ยงH.2 and ยงH.3 add:

  1. Tokenise a buffer on demand, so the page can colour it with the real lexer.
  2. Freeze a running game without unloading it, so STOP has something to do.

H.2 Tokenising on demand: engine_tokenize

The new file is engine/src/highlight_api.odin. It adds no language machinery; it is a thin adaptor over muspel.lex_all plus the one pause lever of ยงH.3. The editor's contract with it is deliberately narrow: write the current source into the same src_recv_buf a load already uses, call engine_tokenize(src_len), and read back one fixed-size record per token.

Each record is three u32s: a highlight class, a byte offset into the source, and a byte length. The class is coarser than Token_Kind on purpose. The editor does not need the parser's full vocabulary; it needs just enough distinctions to colour by, so hl_class folds the fifty-odd token kinds down to eight buckets:

HL_PUNCT   :: 0 // (), {}, [], comma, dot, colon, semicolon: structural, left uncoloured
HL_KEYWORD :: 1 // var func if else while for in return yield struct enum import as
HL_CONST   :: 2 // true false nil
HL_NUMBER  :: 3
HL_STRING  :: 4
HL_IDENT   :: 5
HL_OP      :: 6 // + - * / % ! = == != < <= > >= && ||
HL_ERROR   :: 7 // an Illegal token: something the lexer could not read

hl_class :: proc(kind: muspel.Token_Kind) -> u32 {
	#partial switch kind {
	case .Var, .Func, .If, .Else, .While, .For, .In, .Return, .Yield, .Struct, .Enum, .Import, .As:
		return HL_KEYWORD
	case .True, .False, .Nil:
		return HL_CONST
	case .Number:  return HL_NUMBER
	case .String:  return HL_STRING
	case .Ident:   return HL_IDENT
	case .Illegal: return HL_ERROR
	case .Plus, .Minus, .Star, .Slash, .Percent, .Bang, .Assign, .Equal, .Not_Equal,
	     .Less, .Less_Equal, .Greater, .Greater_Equal, .And_And, .Or_Or:
		return HL_OP
	}
	return HL_PUNCT
}

The numeric values are a wire contract with ide.html: the page indexes a CSS-class table by them, so you may append to this list but never renumber it, exactly the constraint the CB_* callback constants (Chapter 14) live under.

The records land in a fixed output buffer the page reads through a pointer export, the same buffer-plus-pointer shape src_recv_buf and the module name buffers already use:

TOK_FIELDS :: 3
MAX_TOKENS :: 8192
tok_out_buf: [MAX_TOKENS * TOK_FIELDS]u32

@(export)
engine_tok_buffer_ptr :: proc "contextless" () -> [^]u32 { return &tok_out_buf[0] }

@(export)
engine_tok_capacity :: proc "contextless" () -> u32 { return MAX_TOKENS }

Now the export itself. The lexer records each token's start as a 1-based (line, column); the editor wants an absolute byte offset it can slice with. One pass over the source builds a line_start table (the byte offset where each line begins), after which the conversion is a lookup plus column - 1:

@(export)
engine_tokenize :: proc "c" (src_len: u32) -> u32 {
	context = runtime.default_context()
	if int(src_len) > len(src_recv_buf) do return 0
	src := string(src_recv_buf[:src_len])

	line_start := make([dynamic]int)
	defer delete(line_start)
	append(&line_start, 0)
	for i in 0 ..< len(src) {
		if src[i] == '\n' do append(&line_start, i + 1)
	}

	tokens := muspel.lex_all(src)
	defer delete(tokens)

	n: u32 = 0
	for tok in tokens {
		if n >= MAX_TOKENS do break

		// Skip the lexer's zero-width bookkeeping tokens: the trailing EOF and the semicolons
		// automatic-semicolon-insertion adds at line ends (both have an empty lexeme). Keep an
		// Illegal token even when its lexeme is a message, because the fault site is worth marking.
		if len(tok.lexeme) == 0 && tok.kind != .Illegal do continue

		li := tok.line - 1
		if li < 0 || li >= len(line_start) do continue
		offset := line_start[li] + tok.column - 1
		if offset < 0 || offset > len(src) do continue

		length := len(tok.lexeme)
		#partial switch tok.kind {
		case .String:
			// A string token's lexeme is its contents without the quotes, but its span starts at
			// the opening quote, so widen by two to colour the whole "..." including both quotes.
			length += 2
		case .Illegal:
			// An Illegal lexeme can be a human-readable message ("unexpected '&'"), not a slice of
			// src, so mark a single byte at the fault site rather than a message-length run.
			length = 1
		}
		if offset + length > len(src) do length = len(src) - offset
		if length <= 0 do continue

		base := n * TOK_FIELDS
		tok_out_buf[base + 0] = hl_class(tok.kind)
		tok_out_buf[base + 1] = u32(offset)
		tok_out_buf[base + 2] = u32(length)
		n += 1
	}
	return n
}

Three details are load-bearing.

Byte offsets, not character offsets. The lexer counts columns in bytes, and the page slices the source as UTF-8 bytes (ยงH.4), so a string literal holding non-ASCII text still lines up. Had the export reported character positions, or the page sliced a JavaScript string by character, every token after the first multi-byte character would colour the wrong run.

Two kinds that are not plain slices. A .String token's lexeme excludes its surrounding quotes but its recorded span starts at the opening quote (see scan_string in Chapter 2), so the length is widened by two to cover both quotes. An .Illegal token's lexeme is sometimes a human-readable message rather than a slice of the source, so its length is meaningless as a span and the code marks a single byte at the fault site instead. Everything else, the common case, is a direct slice and its len(lexeme) is exactly right.

No per-repaint leak. Chapter 20's loader clones each module's source and deliberately never frees it, an accepted leak because a reader loads a game a handful of times. Highlighting is different: it runs on every edit. A per-keystroke leak of the token slice and the line table would grow without bound over an editing session, so both are freed with defer before the call returns, and the source is read straight out of src_recv_buf without a clone (a repaint never needs it to outlive the call). This is the same "whose lifetime is this" question Chapter 16 drew a boundary around, answered the other way because the call frequency is the other way.

H.3 STOP: freezing without unloading

The engine's animation loop runs whenever active is true (Chapter 18's tick guard). There is no export to halt it, because until now nothing wanted to: a game runs until another replaces it. STOP wants to halt this game without the teardown a fresh load would cost, so main.odin gains one flag:

// paused freezes the running game without unloading it: the tick still fires but skips _process and
// _draw, so the last frame stands until unpaused.
paused: bool

and the tick's CB_TICK case gains one guard, before it reads the clock:

case CB_TICK:
	// STOP (IDE) freezes the game: keep counting time so the first frame after resume does not see
	// a huge dt, but run neither simulation nor draw, leaving the last frame on the canvas.
	if paused {
		last_ms = batch.now()
		return
	}
	now := batch.now()
	// ... unchanged ...

Updating last_ms even while frozen matters: without it, the frame after RUN would compute a dt of however many seconds you spent stopped, and the game would teleport. The lever itself lives beside the tokeniser, a one-line export:

@(export)
engine_set_paused :: proc "contextless" (p: u32) { paused = p != 0 }

A normal engine build never calls it, so paused stays false and the guard is a single always-false branch per frame. This is the honest cost of a feature that only the IDE uses: one predictable branch, paid by every game, forever. It is cheap enough to accept; a busier lever would deserve a compile-time -define instead.

H.4 The editor: a transparent textarea over a highlighted layer

The oldest trick in browser code editors, and still the right one when you refuse to pull in a third-party editor, is two elements stacked pixel-for-pixel: a <pre> that shows the highlighted text, and a transparent <textarea> on top of it that captures typing. The textarea's own glyphs are made invisible (color: transparent) so only its caret and selection show; every visible character is the <pre> underneath. They must share identical font, size, line height, padding, and whitespace handling, or the caret drifts away from the letters:

.editor pre, .editor textarea {
	margin: 0; padding: 14px; border: 0;
	position: absolute; inset: 0; width: 100%; height: 100%;
	font: 14px/1.55 ui-monospace, "SF Mono", Menlo, Consolas, monospace;
	tab-size: 2; white-space: pre; word-wrap: normal;
}
.editor pre       { z-index: 1; overflow: hidden; pointer-events: none; color: #cdd7ea; }
.editor textarea  { z-index: 2; overflow: auto; color: transparent; caret-color: #eaf6ff;
                    background: transparent; }

The textarea scrolls; the <pre> is scrolled in lockstep from a scroll handler (pre.scrollTop = textarea.scrollTop). The repaint reads the tokens and rebuilds the <pre>'s inner HTML, working entirely in bytes so it matches the offsets the export reported:

function repaintHighlight() {
	const text = editor.value;
	if (!engine) { highlight.textContent = text; return; }   // before boot: plain text

	const bytes = enc.encode(text);                          // UTF-8, to match byte offsets
	writeBytes(engine.engine_src_buffer_ptr(), bytes);
	const n = engine.engine_tokenize(bytes.length);
	// Take the record view AFTER tokenising: the call may have grown (and replaced) the buffer.
	const recs = new Uint32Array(engine.memory.buffer, engine.engine_tok_buffer_ptr(), n * 3);

	let html = "", pos = 0;
	for (let i = 0; i < n; i++) {
		const cls = recs[i*3], off = recs[i*3 + 1], len = recs[i*3 + 2];
		html += renderGap(bytes.subarray(pos, off));         // whitespace + comments between tokens
		html += '<span class="' + (CLASS[cls] || "t-id") + '">' +
		        escapeHtml(dec.decode(bytes.subarray(off, off + len))) + "</span>";
		pos = off + len;
	}
	html += renderGap(bytes.subarray(pos));
	highlight.innerHTML = html + "\n";
}

Two subtleties earn their comments. The Uint32Array view onto the record buffer is taken after engine_tokenize returns, because tokenising allocates, which can grow WASM memory, which replaces memory.buffer and invalidates any view taken earlier, the same hazard Chapter 20's loader documents on its own writeBytes. And the repaint is debounced (a 60 ms timer coalesces bursts of keystrokes) so a fast typist does not trigger one WASM round trip per character.

The one thing the lexer cannot colour: comments. skip_whitespace_and_comments (Chapter 2) throws comments away before any token is produced, by design, so lex_all never emits a comment token. That is correct for a compiler and inconvenient for an editor. The fix is precise rather than a second tokeniser: the text between two tokens is, by definition, only whitespace and comments (a // inside a string is part of that string's token, never a gap), so a // found in a gap is always a real comment. renderGap colours from each // to end of line and leaves the rest plain:

function renderGap(sub) {
	const s = dec.decode(sub);
	let out = "", i = 0;
	while (i < s.length) {
		const slash = s.indexOf("//", i);
		if (slash < 0) { out += escapeHtml(s.slice(i)); break; }
		out += escapeHtml(s.slice(i, slash));
		let nl = s.indexOf("\n", slash);
		if (nl < 0) nl = s.length;
		out += '<span class="t-com">' + escapeHtml(s.slice(slash, nl)) + "</span>";
		i = nl;
	}
	return out;
}

This is the whole of the highlighting that is not Muspel's own lexer: one page of gap-colouring for the one lexical form the lexer is right to discard.

H.5 RUN and STOP

RUN compiles and runs the editor's text as a single-file game, through the very module protocol the switcher uses, then unpauses:

function runCode() {
	const src = enc.encode(editor.value);
	const nameBytes = enc.encode("main.mus");
	engine.engine_module_reset();
	writeBytes(engine.engine_mod_name_buffer_ptr(), nameBytes);
	writeBytes(engine.engine_src_buffer_ptr(), src);
	const count = engine.engine_module_add(nameBytes.length, src.length, 1) >>> 0; // 1 = entry
	if (count === MOD_ADD_ERR) { log("Parse error (see the console)."); return; }
	if (count > 0)             { log("The IDE runs a single file; `import` isn't supported here yet."); return; }

	writeBytes(engine.engine_mod_name_buffer_ptr(), nameBytes); // run_modules re-reads the key
	if (!engine.engine_run_modules(nameBytes.length)) { log("Failed to run."); return; }
	engine.engine_set_paused(0);
	setRunning(true);
}

Compare Chapter 20's loadGame: this is the same three calls with the breadth-first fetch loop removed, because there is only ever one file. That single-file scope is a deliberate limit: an import needs sibling files the editor does not have, so a source that declares one (engine_module_add returns a non-zero import count) is reported rather than half-loaded. A parse error is already surfaced the way every load surfaces it, painted on the canvas overlay by report_module_parse_error, and now mirrored to the console too (ยงH.7), so the IDE notes it and leaves the previous frame standing.

*Gotcha: engine_module_add returns a Muspel u32, but WASM hands a 32-bit result to JavaScript as a signed i32. MOD_ADD_ERR is 0xFFFFFFFF, which is -1 when read as signed, so a plain count === 0xFFFFFFFF check silently misses it: on a parse error count is -1, the check is false, count > 0 is also false, and control falls through to engine_run_modules, which finds nothing in the store and reports "entry module was never added" over* the real parse error. The overlay only ever shows the most recent error, so the useful one is the one you lose. The one-character fix is to read the result as unsigned with >>> 0 before comparing, which is why the line above ends that way. index.html carried the same latent bug for every load; it never tripped only because the switcher loads known-good games that never fail to parse. The IDE, which runs whatever you type, trips it on your first typo, which is exactly why an editor is a better test of a loader than a fixed playlist.

STOP is the lever from ยงH.3, and the button is a toggle over one boolean:

function stopCode() { engine.engine_set_paused(1); setRunning(false); }
runBtn.addEventListener("click", () => { running ? stopCode() : runCode(); });

RUN always recompiles the current editor text, so the STOP โ†’ edit โ†’ RUN cycle always runs what is on screen. There is no separate "resume": pressing RUN after a STOP is a fresh run, which is the predictable behaviour for an editor where the text may have changed while stopped.

H.6 Persistence, download, and upload

These three are pure page code, and each follows a convention the book already established. Autosave is the flat-key localStorage pattern from every game's save_/load_ pair, debounced so a burst of typing writes once:

const STORAGE_KEY = "muspel_ide:source";
function scheduleSave() {
	clearTimeout(saveTimer);
	saveTimer = setTimeout(() => localStorage.setItem(STORAGE_KEY, editor.value), 400);
}

On boot the page seeds the editor from that key, falling back to a starter script when it is unset, the same "tolerate first-ever run" rule the monorepo's persistence convention insists on. Download is a Blob and a synthetic <a download>; upload is a hidden <input type="file"> and a FileReader:

downloadBtn.onclick = () => {
	const url = URL.createObjectURL(new Blob([editor.value], { type: "text/plain;charset=utf-8" }));
	const a = Object.assign(document.createElement("a"), { href: url, download: "main.mus" });
	a.click();
	setTimeout(() => URL.revokeObjectURL(url), 1000);
};
fileInput.onchange = () => {
	const file = fileInput.files[0];
	if (!file) return;
	const reader = new FileReader();
	reader.onload = () => setText(String(reader.result)); // setText repaints + saves
	reader.readAsText(file);
	fileInput.value = "";                                  // let the same file be re-chosen later
};

H.7 The console: seeing print, and every error

Two different streams feed the console pane.

print output needs no engine change. Muspel's print builtin is fmt.println (Chapter 11's native_print); on the js_wasm32 target that reaches JavaScript as a console.log through Odin's runtime, so the pane is populated by teeing the browser console into a <div>, which also captures the page's own diagnostics:

for (const [name, cls] of [["log", ""], ["warn", "err"], ["error", "err"]]) {
	const orig = console[name].bind(console);
	console[name] = (...args) => { orig(...args); log(args.join(" "), cls); };
}

Errors do need a small engine change, and it is the counterpart to the gotcha in ยงH.5. Every error the engine surfaces, a parse error, a resolve error, an import cycle, a missing entry, a runtime fault in a hook, is painted by the single function draw_error_overlay (main.odin). But a canvas overlay shows only the most recent error: each redraw overwrites the last, so when two errors happen in quick succession the first is simply gone. That is precisely the trap the signedness bug set, the real parse error erased by the spurious "entry module was never added" drawn a moment later, and a scrolling console is the durable record that defeats it.

So draw_error_overlay gains one line, at the very top, before its own if !canvas_ready do return bail-out, so that even an error with no canvas to draw on still reaches the host:

env_report_error(raw_data(msg), i32(len(msg)), i32(line))

env_report_error is a new app_env import, (ptr, length, line); passing the message's own pointer and length needs no extra buffer because JavaScript reads it synchronously inside the call, while the memory is still valid. Because draw_error_overlay is the one choke point every on-screen error passes through, this single call puts every engine error in the console. ide.html decodes it into the pane; index.html sends it to console.error. Every page that instantiates the engine must now supply this import, the same all-or-nothing rule every app_env function lives under (ยงH.1).

H.8 Wiring it in, and trying it

The IDE is one file, engine/ide.html, copied into dist/ beside index.html by one added line in engine/build.sh:

cp index.html dist/index.html
cp ide.html   dist/ide.html

Then, from engine/:

./run.sh

and open http://localhost:9000/ide.html. The starter script (a box bouncing off the canvas edges, written with the const, multi-binding var, and one-line block bodies of Appendix G ยงยงG.7-G.8) is already in the editor; press RUN and it appears on the right, the button turning into STOP. Type into the editor and the highlighting keeps up, produced by the same lexer Chapter 2 built; introduce a stray & and it is underlined in red as an Illegal token the instant you type it, because hl_class maps .Illegal to HL_ERROR and the lexer recognises the fault at exactly the same place the compiler would.

H.9 A reference the reader never leaves the page for

A person writing their first .mus file needs two things this IDE can supply without the engine's help: the language's shape, and the list of builtins a game may call. The ? button in the toolbar opens a modal overlay carrying both, so the syntax and the whole API surface are one keystroke away from the editor rather than a chapter away in a book.

Mechanically it is the plainest part of the whole page: a fixed-position <div> with a dimmed backdrop, hidden until the button toggles it, dismissed by the close button, a click on the backdrop, or Escape.

function setHelp(open) { helpOverlay.hidden = !open; }
helpBtn.addEventListener("click", () => setHelp(true));
helpClose.addEventListener("click", () => setHelp(false));
helpOverlay.addEventListener("click", (e) => { if (e.target === helpOverlay) setHelp(false); });
window.addEventListener("keydown", (e) => { if (e.key === "Escape" && !helpOverlay.hidden) setHelp(false); });

Its content is static HTML, not generated: bindings (var/const/multi), literals, declarations, control flow, the cooperative-scripting verbs (yield, wait, on), the engine lifecycle hooks (_config/_ready/_process/_draw/_input), and a builtin reference grouped by subsystem, drawing, input, audio, maths, collections, strings, vectors, persistence.

The one honest maintenance cost. The overlay's builtin list is hand-written to mirror engine/src/api.odin's Engine_Builtins table, and nothing enforces that it stays in step: add a builtin to that table and the overlay will not mention it until someone edits the HTML too. This is the opposite of the syntax highlighting (ยงH.2), which cannot drift because it is the real lexer. A page that instead read the builtin names from the engine, there is already an engine_builtin_names() on the Odin side, would close the gap; it is left out here only to keep the overlay pure static markup, the one place in this IDE where a second, hand-maintained copy of engine knowledge is allowed to exist.

H.10 Examples: the "New" button as a template menu

The starter (ยงH.8) teaches the four hooks, but a reader's next question is always "how do I read the keyboard / grow an array / make a sound", and the honest answer is a worked example of each. So the New button is a dropdown: its first item is the built-in starter, and below it sits a short library of small programs, one per idea, that load straight into the editor.

The examples are not embedded in the page the way the starter is. They are real .mus files under engine/ide_examples/, copied into dist/ by one more line in build.sh (cp -r ide_examples dist/ide_examples), and described by a hand-written manifest:

[
  { "file": "keyboard.mus", "title": "Keyboard input", "blurb": "Move a square with the arrow keys or WASD (is_key_down)" },
  { "file": "mouse.mus",    "title": "Mouse input",    "blurb": "A ring follows the pointer; hold to paint (mouse_x/y, mouse_down)" },
  ...
]

This is deliberately the same shape as Chapter 20's games.json and the switcher that reads it: a manifest of names, each body fetched over HTTP only when actually chosen. Opening the menu costs nothing; picking an item fetches that one file and drops it into the editor, guarded by the same unsaved-work confirm the starter uses.

async function buildNewMenu() {
  newMenu.innerHTML = "";
  addMenuItem("Starter", "The bouncing-box template", () => loadInto(STARTER, "the starter"));
  let examples = [];
  try { examples = await (await fetch("ide_examples/manifest.json?t=" + Date.now())).json(); }
  catch (e) { return; }                       // no examples served: the starter alone is fine
  for (const ex of examples) {
    addMenuItem(ex.title, ex.blurb, async () => {
      const res = await fetch("ide_examples/" + ex.file + "?t=" + Date.now());
      loadInto(await res.text(), ex.title);
    });
  }
}

Keeping the examples as standalone files rather than page strings buys two things. They are testable the way any .mus file is, ./run.sh parse ide_examples/keyboard.mus checks each one, and they double as a readable catalogue of the engine's builtins in use: keyboard (is_key_down), mouse (mouse_x/mouse_y/ mouse_down), growable arrays (list/append/remove/len), maps, automatic garbage collection (gc: true, gc_threshold_kb, gc()), structs and enums, text and a dt-driven clock, sound (play_tone/play_sweep), and orbital motion (sin/cos). Each mirrors an idiom the monorepo's own games already use, and none reaches for wait() inside a per-frame hook, because the engine's lifecycle drives _process(dt) once per frame rather than resuming a suspended routine, so time is kept with a dt accumulator (the pattern ยงH.9's builtin list and every shipped game follow).

H.11 What's deliberately absent

In the honest spirit of Appendix A's own omissions list:

  • Multi-file editing. The editor holds one file, so import is reported rather than supported (ยงH.5). A tabbed multi-file editor feeding the same breadth-first loop as Chapter 20's loadGame is a natural extension, and a sizeable one; it is left out to keep the appendix about the one idea that needed engine support.
  • Bracket matching and autocomplete. Both are pure page code layered on the same token stream engine_tokenize already returns; neither needs anything new from the engine, which is why neither is built here. (A line-number gutter, in the same spirit, is built: a right-aligned column that shares the editor's line height and follows its vertical scroll, rebuilt only when the line count changes. It needs nothing from the engine at all.)
  • Undo beyond the textarea's own. The browser's built-in undo covers the textarea; a structured undo history is out of scope.

The appendix's claim is narrower and, hopefully, more useful: an IDE for a language you built is mostly page plumbing over machinery you already have, and its one genuinely language-aware feature, highlighting, is best served not by re-describing your grammar in JavaScript but by calling the lexer you already wrote.