diff --git a/README.md b/README.md index 2f09893..221069e 100644 --- a/README.md +++ b/README.md @@ -17,9 +17,12 @@ tag, never to make the page answer on the compiler's behalf. Concretely, that rules out a few things that would otherwise be tempting: - No second formatter. `fmt` is one of the exports. -- No syntax highlighting that knows more than the grammar the compiler ships. +- No syntax highlighting written here. The colouring in the playground is the + compiler's own lexer, asked on every keystroke, so there is no second + grammar to keep in step. - No error messages written here. Diagnostics are rendered from what the - compiler returns, carets and all. + compiler returns, carets and all, and the index at `errors/` is the set of + pages the compiler generates rather than a list maintained here. - No examples written here. The playground's are the compiler's corpus at the pinned tag, and the summary under each one is the comment at the top of the file. The landing page's program is the exception, and it is short and its @@ -88,7 +91,9 @@ checked through the pinned artifact and every one of them is clean. ``` index.html what the language is play/ the playground +errors/ every diagnostic code, read out of the compiler install/ how to get a binary running +one-clause/ what a signature turns into examples/ the compiler's corpus at the pinned tag assets/ the stylesheet, the scripts, the brand files, the compiler tools/ the one check that runs before anything merges diff --git a/assets/deed-v0.2.1-wasm32-unknown-unknown.wasm b/assets/deed-v0.2.1-wasm32-unknown-unknown.wasm deleted file mode 100644 index 1c85174..0000000 Binary files a/assets/deed-v0.2.1-wasm32-unknown-unknown.wasm and /dev/null differ diff --git a/assets/deed-v0.2.2-wasm32-unknown-unknown.wasm b/assets/deed-v0.2.2-wasm32-unknown-unknown.wasm new file mode 100644 index 0000000..b57576e Binary files /dev/null and b/assets/deed-v0.2.2-wasm32-unknown-unknown.wasm differ diff --git a/assets/errors.js b/assets/errors.js new file mode 100644 index 0000000..f6e3283 --- /dev/null +++ b/assets/errors.js @@ -0,0 +1,100 @@ +// The error index, asked of the compiler rather than written here. +// +// `deed-explain` generates a page per diagnostic code from the doc comment +// above it and an example taken out of a test that already had to exist. The +// artifact carries all of them, so this page cannot document a code the +// compiler does not have, or miss one it does. + +const TAG = "v0.2.2"; +const VERSION = "0.2.2"; +const WASM_URL = `../assets/deed-${TAG}-wasm32-unknown-unknown.wasm`; + +const STATUS = document.getElementById("status"); +const FILTER = document.getElementById("filter"); +const COUNT = document.getElementById("count"); +const CODES = document.getElementById("codes"); + +function esc(text) { + return text.replace(/&/g, "&").replace(//g, ">"); +} + +function render(pages) { + CODES.innerHTML = pages + .map( + (page) => ` +
+

${page.code} ${esc(page.name)}

+

${esc(page.text).replace(/\n\n/g, "

").replace(/\n/g, " ")}

+ ${ + page.example + ? `
${esc(page.example)}
+

from ${esc(page.example_source ?? "")}

` + : `

No example could be lifted from a test for this one.

` + } +
`, + ) + .join(""); +} + +async function load() { + let wasm; + try { + const module = await WebAssembly.instantiateStreaming(fetch(WASM_URL), {}); + wasm = module.instance.exports; + } catch (error) { + STATUS.innerHTML = `The compiler did not load, so there is nothing to list. (${error})`; + return; + } + + const decoder = new TextDecoder(); + const read = () => { + const ptr = wasm.deed_result_ptr(); + const len = wasm.deed_result_len(); + const text = decoder.decode(new Uint8Array(wasm.memory.buffer).slice(ptr, ptr + len)); + wasm.deed_free(ptr, len); + return text; + }; + + wasm.deed_version(); + const reported = read(); + if (reported !== VERSION) { + STATUS.innerHTML = `This page pinned ${VERSION} and the module says ${reported}, so it is not being used.`; + return; + } + + wasm.deed_explain(); + const pages = read() + .split("\n") + .filter((line) => line.trim() !== "") + .map((line) => JSON.parse(line)); + + STATUS.innerHTML = + `Deed ${esc(reported)}, ` + + `${TAG}, ` + + `asked in this tab.`; + + FILTER.disabled = false; + const show = () => { + const wanted = FILTER.value.trim().toLowerCase(); + const shown = wanted + ? pages.filter((page) => + `${page.code} ${page.name} ${page.text}`.toLowerCase().includes(wanted), + ) + : pages; + COUNT.textContent = wanted + ? `${shown.length} of ${pages.length} codes` + : `${pages.length} codes`; + render(shown); + }; + + FILTER.addEventListener("input", show); + show(); + + // A link to a code should land on it, and the list did not exist when the + // browser tried the first time. + if (location.hash) { + document.getElementById(location.hash.slice(1))?.scrollIntoView(); + } +} + +load(); diff --git a/assets/play.js b/assets/play.js index 9aea5ec..1df0d50 100644 --- a/assets/play.js +++ b/assets/play.js @@ -13,8 +13,8 @@ // release, because a release asset cannot be fetched from a browser at all: // both the download URL and the API one redirect to a host that sends no // `Access-Control-Allow-Origin`. See decisions/2026-07-31-no-build-step.md. -const TAG = "v0.2.1"; -const VERSION = "0.2.1"; +const TAG = "v0.2.2"; +const VERSION = "0.2.2"; const WASM_URL = `../assets/deed-${TAG}-wasm32-unknown-unknown.wasm`; const SOURCE = document.getElementById("source"); @@ -23,6 +23,8 @@ const STATUS = document.getElementById("status"); const EXAMPLE = document.getElementById("example"); const SUMMARY = document.getElementById("summary"); const STOP = document.getElementById("stop"); +const HIGHLIGHT = document.getElementById("highlight"); +const GUTTER = document.getElementById("gutter"); const VERBS = Array.from(document.querySelectorAll("[data-verb]")); const encoder = new TextEncoder(); @@ -33,6 +35,7 @@ const decoder = new TextDecoder(); let worker = null; let pending = null; let nextId = 1; +let ready = false; // Long enough that nothing anyone types by hand hits it, short enough that a // frozen page is not what a mistake looks like. @@ -62,6 +65,7 @@ function spawn() { // replacement to load the module again. function stop(why, deliberate = false) { worker.terminate(); + ready = false; if (pending) { const settle = pending; pending = null; @@ -285,7 +289,10 @@ function render(verb, json, source) { async function run(verb) { const source = SOURCE.value; try { - OUTPUT.innerHTML = render(verb, await ask(verb, source), source); + const answer = await ask(verb, source); + OUTPUT.innerHTML = render(verb, answer, source); + if (verb === "deed_check" || verb === "deed_fmt") markLines(answer, SOURCE.value); + if (verb === "deed_fmt") paint(); } catch (error) { // A stop is an answer the reader asked for, not a failure to answer. if (error.stopped) return; @@ -293,16 +300,115 @@ async function run(verb) { } } +// The colouring, from the compiler's own lexer rather than from a grammar +// written here. `deed_tokens` classifies every byte range but whitespace, so +// the gaps between ranges are exactly the whitespace and get copied through. +// +// The textarea sits on top with transparent text, so what anyone reads is +// this layer and what anyone types is that one. They have to agree on every +// font property or the caret drifts, which is why the CSS sets both from the +// same block. +async function paint() { + const source = SOURCE.value; + let classified; + try { + classified = await ask("deed_tokens", source); + } catch { + // Colouring is the part that can be missing. The editor still works. + return; + } + if (SOURCE.value !== source) return; + + let out = ""; + let at = 0; + for (const line of classified.split("\n")) { + if (line.trim() === "") continue; + const { class: kind, start, end } = JSON.parse(line); + out += esc(source.slice(at, start)); + out += `${esc(source.slice(start, end))}`; + at = end; + } + out += esc(source.slice(at)); + + // A trailing newline collapses in a `pre`, and the caret can sit after it. + HIGHLIGHT.innerHTML = out + "\n"; + drawGutter(source); +} + +// The line numbers, and which lines the compiler had something to say about. +let marked = new Map(); + +function drawGutter(source) { + const lines = source.split("\n").length; + let out = ""; + for (let n = 1; n <= lines; n++) { + const severity = marked.get(n); + out += severity ? `${n}\n` : `${n}\n`; + } + GUTTER.innerHTML = out; +} + +function markLines(answer, source) { + marked = new Map(); + for (const line of answer.split("\n")) { + if (line.trim() === "") continue; + const item = JSON.parse(line); + if (item.kind !== "diagnostic") continue; + const { file, span } = item.diagnostic.primary; + if (file !== "main.deed") continue; + // An error on a line beats a warning on the same one. + const severity = item.diagnostic.severity === "warning" ? "warning" : "error"; + if (severity === "error" || !marked.has(span.startLine)) { + marked.set(span.startLine, severity); + } + } + drawGutter(source); +} + +// Three layers scrolling as one. The gutter follows vertically only: it has +// no columns to scroll past. +SOURCE.addEventListener("scroll", () => { + HIGHLIGHT.scrollTop = SOURCE.scrollTop; + HIGHLIGHT.scrollLeft = SOURCE.scrollLeft; + GUTTER.scrollTop = SOURCE.scrollTop; +}); + // `check` is the fast one and the one the language is about, so it runs while // you type rather than waiting to be asked. Only when nothing else is in // flight: a `run` that is still going is a better use of the compiler than a // `check` of a program that is being edited anyway. +// +// Colouring is on a shorter fuse than checking, because it is answering a +// question about the text rather than about the program, and text that stays +// grey while you type reads as broken. +let painting = null; let typing = null; + +// Both of these can find the compiler busy with the other one. Re-arming +// rather than returning is the difference between "later" and "never": an +// earlier version dropped the check whenever a paint was still in flight, so +// the output pane kept answering about the program before last. +// +// "Busy" and "not there" are separate questions for the same reason. The verbs +// are disabled in both cases, so reading the buttons would have made a +// temporary state look permanent. +function schedule(which, delay) { + clearTimeout(which === paint ? painting : typing); + const timer = setTimeout(() => { + if (!ready) return; + if (pending) return schedule(which, 100); + which(); + }, delay); + if (which === paint) painting = timer; + else typing = timer; +} + +const check = () => run("deed_check").then(paint); + SOURCE.addEventListener("input", () => { - clearTimeout(typing); - typing = setTimeout(() => { - if (!pending && worker && !VERBS[0].disabled) run("deed_check"); - }, 500); + drawGutter(SOURCE.value); + schedule(paint, 150); + schedule(check, 500); }); STOP.addEventListener("click", () => { @@ -336,7 +442,9 @@ function arrived(data) { `Deed ${esc(reported)}, ` + `${TAG}, ` + `running in this tab.`; + ready = true; running(false); + paint(); } function load() { @@ -434,6 +542,8 @@ async function loadFromLink() { if (version !== VERSION) { SHARED.textContent = `This program was written against ${version} and the page is running ${VERSION}, so it may not say the same thing.`; } + marked = new Map(); + paint(); return true; } @@ -472,6 +582,8 @@ async function loadExamples() { OUTPUT.textContent = "Press Check, Run, Test or Format."; const response = await fetch(`../examples/${encodeURIComponent(file)}`); SOURCE.value = await response.text(); + marked = new Map(); + paint(); }); } diff --git a/assets/style.css b/assets/style.css index e789553..4e830f6 100644 --- a/assets/style.css +++ b/assets/style.css @@ -7,6 +7,11 @@ --error: #a3252b; --warning: #8a6100; --surface: #f6f6f6; + --syntax-keyword: #0b6349; + --syntax-string: #8a4b00; + --syntax-number: #1f4bb8; + --syntax-comment: #5c5c5c; + --syntax-punctuation: #4a4a4a; --mono: ui-monospace, SFMono-Regular, "SF Mono", Menlo, Consolas, "Liberation Mono", monospace; } @@ -21,6 +26,11 @@ --error: #f2777a; --warning: #e6b455; --surface: #1e2124; + --syntax-keyword: #4bbf95; + --syntax-string: #e0a35c; + --syntax-number: #8fb0ff; + --syntax-comment: #9aa0a6; + --syntax-punctuation: #b7bcc2; } } @@ -273,6 +283,122 @@ textarea#source { resize: vertical; } +/* The editor: a gutter, the colouring, and the textarea over the top of it. + One source line is one visual line (no wrapping) so the gutter can say + which line it is without lying about wrapped ones. */ + +.editor { + display: flex; + /* Fixed rather than a minimum: both layers inside are positioned, so the + only thing that could give this box a height is the gutter, and then the + editor would be as tall as the file. */ + height: 26rem; + border: 1px solid var(--line); + border-radius: 0.3rem; + background: var(--surface); + overflow: hidden; +} + +.editor .gutter, +.editor .highlight, +.editor textarea#source { + font-family: var(--mono); + font-size: 0.9rem; + line-height: 1.5; + tab-size: 4; + white-space: pre; +} + +.editor .gutter { + flex: 0 0 auto; + padding: 0.75rem 0.5rem 0.75rem 0.75rem; + color: var(--muted); + text-align: right; + user-select: none; + overflow: hidden; + border-right: 1px solid var(--line); +} + +.editor .gutter b { + font-weight: 600; +} + +.editor .gutter b.has-error { + color: var(--error); +} + +.editor .gutter b.has-warning { + color: var(--warning); +} + +.editor .layers { + position: relative; + flex: 1; + min-width: 0; +} + +.editor .highlight, +.editor textarea#source { + position: absolute; + inset: 0; + margin: 0; + padding: 0.75rem; + border: 0; + border-radius: 0; + min-height: 0; + overflow: auto; +} + +.editor .highlight { + pointer-events: none; + color: var(--fg); + /* Driven by the textarea's scroll position, so it must not grow one of its + own: two scrollbars in the same box is one too many. */ + overflow: hidden; +} + +.editor textarea#source { + width: 100%; + height: 100%; + resize: none; + background: transparent; + /* The colouring underneath is what anyone reads; this layer is the caret, + the selection and the keyboard. */ + color: transparent; + caret-color: var(--fg); +} + +.editor textarea#source::selection { + background: color-mix(in srgb, var(--accent) 30%, transparent); +} + +.t-keyword { + color: var(--syntax-keyword); + font-weight: 600; +} + +.t-string { + color: var(--syntax-string); +} + +.t-number { + color: var(--syntax-number); +} + +.t-comment { + color: var(--syntax-comment); + font-style: italic; +} + +.t-punctuation { + color: var(--syntax-punctuation); +} + +.t-error { + color: var(--error); + text-decoration: underline wavy; +} + #output { margin: 0; padding: 0.75rem; @@ -362,6 +488,11 @@ textarea#source { overflow: auto; } + body.fits .editor { + height: 100%; + min-height: 0; + } + body.fits footer.site { margin-top: 1rem; padding-top: 0.5rem; @@ -476,3 +607,39 @@ table.files th { margin: 0.25rem 0 0; color: var(--muted); } + +/* Diagnostics index */ + +.diagnostic { + border-top: 1px solid var(--line); + padding-top: 1rem; + margin-top: 1.5rem; +} + +.diagnostic h2 { + font-size: 1rem; + font-family: var(--mono); + margin: 0 0 0.5rem; + scroll-margin-top: 1rem; +} + +.diagnostic h2 a { + text-decoration: none; +} + +.diagnostic .from { + font-size: 0.8rem; + font-family: var(--mono); + margin-top: 0.4rem; +} + +input[type="search"] { + font: inherit; + font-size: 0.95rem; + padding: 0.3rem 0.5rem; + min-width: min(28rem, 100%); + border: 1px solid var(--line); + border-radius: 0.3rem; + background: var(--surface); + color: var(--fg); +} diff --git a/errors/index.html b/errors/index.html new file mode 100644 index 0000000..dc7a685 --- /dev/null +++ b/errors/index.html @@ -0,0 +1,66 @@ + + + + + + Diagnostics | Deed + + + + + +
+
+ Deed + +
+ +
+

Diagnostics

+

+ Every code this compiler can produce, with why it exists and a + program that triggers it. Nothing on this page was written here: it + is what deed explain prints, asked of the same build the + playground runs. +

+

loading

+ +
+ + +
+

+ +
+
+ + +
+ + + diff --git a/examples/index.json b/examples/index.json index 6c832ea..b41bd77 100644 --- a/examples/index.json +++ b/examples/index.json @@ -1,5 +1,5 @@ { - "tag": "v0.2.1", + "tag": "v0.2.2", "examples": [ { "file": "calculator.deed", diff --git a/index.html b/index.html index bdc56e8..2bf5311 100644 --- a/index.html +++ b/index.html @@ -30,6 +30,7 @@ diff --git a/install/index.html b/install/index.html index 2603721..1a86fd5 100644 --- a/install/index.html +++ b/install/index.html @@ -18,6 +18,7 @@ diff --git a/one-clause/index.html b/one-clause/index.html index 6a056f7..b05673a 100644 --- a/one-clause/index.html +++ b/one-clause/index.html @@ -18,6 +18,7 @@ diff --git a/play/index.html b/play/index.html index 47fc23b..ceb7edb 100644 --- a/play/index.html +++ b/play/index.html @@ -17,6 +17,7 @@ @@ -49,8 +50,12 @@

Playground

- - +
+
Press Check, Run, Test or Format.
diff --git a/tools/check.mjs b/tools/check.mjs index f5046fc..41b61c7 100644 --- a/tools/check.mjs +++ b/tools/check.mjs @@ -66,20 +66,40 @@ for (const path of files.filter((f) => f.endsWith(".html"))) { } } -// The pin names a file, and the file has to be one somebody added. -const play = await readFile(join(root, "assets", "play.js"), "utf8"); -const tag = play.match(/const TAG = "([^"]+)"/)?.[1]; -const version = play.match(/const VERSION = "([^"]+)"/)?.[1]; - -if (!tag || !version) { - complain("assets/play.js", "has no TAG or no VERSION"); -} else { +// The pin names a file, and the file has to be one somebody added. More than +// one script carries it now, and two pins that disagree is the failure the +// version check exists to catch, so they are compared here before shipping. +const pins = new Map(); +for (const path of files.filter((f) => f.endsWith(".js"))) { + const text = await readFile(path, "utf8"); + const tag = text.match(/const TAG = "([^"]+)"/)?.[1]; + const version = text.match(/const VERSION = "([^"]+)"/)?.[1]; + if (tag || version) pins.set(relative(root, path).replaceAll("\\", "/"), { tag, version }); +} + +if (pins.size === 0) { + complain("assets/", "no script pins a release"); +} + +const [first] = pins.values(); +for (const [where, { tag, version }] of pins) { + if (!tag || !version) { + complain(where, "has a TAG without a VERSION, or the other way round"); + continue; + } if (`v${version}` !== tag) { - complain("assets/play.js", `pins ${tag} and ${version}, which are not the same release`); + complain(where, `pins ${tag} and ${version}, which are not the same release`); + } + if (tag !== first.tag) { + complain(where, `pins ${tag} while another script pins ${first.tag}`); } +} + +const tag = first?.tag; +if (tag) { const artifact = join(root, "assets", `deed-${tag}-wasm32-unknown-unknown.wasm`); if (!(await exists(artifact))) { - complain("assets/play.js", `pins ${tag}, and deed-${tag}-wasm32-unknown-unknown.wasm is not here`); + complain("assets/", `the pin says ${tag}, and deed-${tag}-wasm32-unknown-unknown.wasm is not here`); } }