diff --git a/.github/workflows/website-checks.yml b/.github/workflows/website-checks.yml index 1bcf207b19..3746cb49ea 100644 --- a/.github/workflows/website-checks.yml +++ b/.github/workflows/website-checks.yml @@ -338,7 +338,8 @@ jobs: # The X player card (public/play/) is generated from specs/x/player.t27 through the # compiler: X's limits on the card, the tabs the page must carry, and the site bundle # built from src/lib/highlight.ts. The tests also hold the RTL tab's reading of the - # emitted Verilog to what t27c wrote for hello_world. + # emitted Verilog to what t27c wrote for hello_world, and run every command of the + # Term tab (public/play/shell.js) on hello_world against the spec's SHELL_* and SAY_*. - name: X player spec run: npm run check:play && npm run test:play-spec # The world scan's contract (specs/catalog/discovery.t27 through the compiler), diff --git a/.gitignore b/.gitignore index e030dba605..d3808be3b3 100644 --- a/.gitignore +++ b/.gitignore @@ -90,6 +90,8 @@ zig-macos-*/ zig-linux-*/ zig-windows-*/ zig/ +# ...but a play's zig terminal page is a page, not an SDK download (play-from-spec.mjs writes it) +!apps/website/public/play/*/zig/ *.tar.xz # Ralph logs (keep config) diff --git a/apps/website/public/play/embed.html b/apps/website/public/play/embed.html index 7484d1d462..18621074d8 100644 --- a/apps/website/public/play/embed.html +++ b/apps/website/public/play/embed.html @@ -142,9 +142,28 @@ .rtl h4 .cp:hover{background:rgba(0,255,136,.1)} .rtl .note{color:#666;font-size:.74rem;margin:.5em 0 0} -/* term */ -#pane-term{padding:.5em .7em} +/* term: a terminal over the same compiler (shell.js), and the recording it can replay */ +#pane-term.on{display:flex;flex-direction:column} #pane-term .t27t .shr{display:none} +.sh{display:flex;flex-direction:column;flex:1;min-height:0;font-family:'JetBrains Mono',ui-monospace,Menlo,Consolas,monospace;font-size:.8rem;line-height:1.4;background:#030303} +.sh-out{flex:1;min-height:0;overflow:auto;padding:.5em .8em;position:relative;outline:none;white-space:pre-wrap;overflow-wrap:anywhere;color:#ddd} +.sh-out .ln{min-height:1.4em} +.sh-out .ln.cmd{margin-top:.35em;color:#fff} +.sh-out .ln.dim,.sh-out i{color:#777;font-style:normal} +.sh-out b{font-weight:600;color:#fff} +.sh-out .g{color:#00ff88}.sh-out .r{color:#ff5c5c}.sh-out .y{color:#ffd700} +.sh-out s{color:#666} +.sh-out .ps,.sh-in .ps{color:#00ff88;user-select:none} +.sh-out pre.blk{margin:.2em 0 .3em;padding:.35em .6em;background:#000;border-left:2px solid rgba(0,255,136,.25);white-space:pre;overflow:auto;font:inherit} +.sh-out pre.out{color:#00ff88} +.sh-box{margin:.3em 0} +.sh-in{display:flex;align-items:center;gap:.5em;padding:.3em .8em;border-top:1px solid rgba(255,255,255,.08)} +.sh-in input{flex:1;min-width:0;font:inherit;color:#fff;background:transparent;border:0;outline:none;caret-color:#00ff88;padding:.2em 0} +.sh-chips{flex-wrap:nowrap;overflow-x:auto;padding:.3em .8em .45em;scrollbar-width:none} +.sh-chips button{white-space:nowrap;font-size:.78rem} +.sh-chips button.cmd{color:#00ff88;border-color:rgba(0,255,136,.3)} +/* on a touch screen the prompt is never small enough for iOS to zoom into it */ +@media (pointer:coarse){.sh-in input{font-size:var(--ed-touch-font)}} .empty{padding:1em;color:#888} @media (max-aspect-ratio:1/1){ @@ -157,6 +176,8 @@ footer{position:sticky;bottom:0;z-index:2} .cmp{grid-template-columns:1fr;height:auto} .viz svg{height:9em} + /* the page scrolls, so the terminal takes the screen between the header and the footer */ + #pane-term.on{height:calc(100vh - 5rem);min-height:18em} } @media (prefers-reduced-motion:reduce){.stages li,.bars div i{transition:none}} diff --git a/apps/website/public/play/embed.js b/apps/website/public/play/embed.js index f011614bd6..a51fcc4695 100644 --- a/apps/website/public/play/embed.js +++ b/apps/website/public/play/embed.js @@ -12,12 +12,13 @@ // // The colours are the spec explorer's: ./site.js is generated from the site's own highlighter and // palette (SITE_MODULES in player.t27), so a spec reads the same here as on t27.ai. The RTL tab -// reads the emitted Verilog with ./rtl.js. +// reads the emitted Verilog with ./rtl.js; the Term tab is a terminal over the same compiler, +// ./shell.js. // // Query: spec= cast= v= // autoplay=1 | auto_play=true (X adds both on click) tab= +// backend= -import { runSpec } from './t27run.js' const $ = (sel) => document.querySelector(sel) const root = new URL('../', import.meta.url) @@ -34,6 +35,9 @@ const status = (text, cls = '') => { $('#status').innerHTML = cls ? ` !(k in cfg)) if (missing.length) throw new Error(`player.t27 lacks ${missing.join(', ')}`) if (cfg.COMPILE_ANIMATION_MS > cfg.AUTOPLAY_LIMIT_MS) throw new Error('player.t27: the compile piece is longer than the autoplay limit') + if (cfg.STAGES?.length !== 7) throw new Error('player.t27: STAGES must name the seven compile stages') return cfg } @@ -147,39 +153,10 @@ function download(text, name) { a.click() setTimeout(() => { URL.revokeObjectURL(a.href); a.remove() }, 0) } -const EXT = { zig: '.zig', c: '.c', rust: '.rs', verilog: '.v', verilog_hir: '.hir.v', js: '.js', ts: '.ts' } -// Verilog is saved under its module's name, which is what verilator's DECLFILENAME lint expects. -function fileName(backend, code, stem) { - const m = /^\s*module\s+([A-Za-z_][\w$]*)/m.exec(code) - return (backend.startsWith('verilog') && m ? m[1] : stem) + EXT[backend] -} - -// --- the facts every tab shows ---------------------------------------------------------------- -const BACKENDS = ['zig', 'c', 'rust', 'verilog', 'verilog_hir', 'js', 'ts'] - -function factsOf(source, analysis) { - const targets = analysis.targets ?? {} - const backends = BACKENDS.filter((k) => k in targets).map((k) => ({ name: k, ok: targets[k].ok !== false && typeof targets[k].code === 'string' && targets[k].code.length > 0, bytes: targets[k].bytes ?? (targets[k].code ?? '').length, code: targets[k].code ?? '', error: targets[k].error })) - let tests = null - try { tests = runSpec(analysis.ast) } catch (e) { tests = { error: e.message, results: [], pass: 0, fail: 0, skip: 0 } } - return { - bytes: analysis.sourceBytes ?? new TextEncoder().encode(source).length, - lines: analysis.sourceLines ?? source.split('\n').length, - tokens: analysis.tokenCount ?? (analysis.tokens ?? []).length, - kinds: new Set((analysis.tokens ?? []).map((t) => t.kind)).size, - nodes: analysis.nodeCount ?? 0, - depth: analysis.astDepth ?? 0, - topLevel: analysis.topLevel ?? (analysis.ast?.children ?? []).length, - discarded: (analysis.discarded?.length ?? 0) + (analysis.lexerDiscarded?.length ?? 0) + (analysis.swallowed?.length ?? 0), - typeOk: analysis.typecheck?.ok === true, - typeErrors: analysis.typecheck?.errors ?? [], - hirOk: analysis.hir?.ok !== false, - hirBytes: (analysis.hir?.text ?? '').length, - astError: analysis.astError ?? null, - backends, - tests, - } -} +// The file a backend's output is saved as, and the facts every tab shows: ./shell.js, which the +// generator's test runs without a page. +const fileName = (backend, code, stem) => shell.fileName(cfg, backend, code, stem) +const factsOf = (source, analysis) => shell.factsOf(cfg, source, analysis) // --- compile: the run as one short linear piece --------------------------------------------- function icicle(ast) { @@ -207,15 +184,16 @@ function renderCompile(pane, f, analysis, classes) { const testLine = t.error ? `${html(t.error)}` : `${t.pass} pass · ${t.fail} fail · ${t.skip} skip` const counted = Object.values(classes).reduce((s, n) => s + n, 0) const tokbar = Object.entries(classes).sort((a, b) => b[1] - a[1]).map(([c, n]) => ``).join('') + // The stage names are STAGES in player.t27; `t27c check` in the terminal prints the same ones. const stages = [ - ['source', `${fmt(f.bytes)} bytes · ${fmt(f.lines)} lines`], - ['lexer', `${fmt(f.tokens)} tokens · ${f.kinds} kinds${f.discarded ? ` · ${f.discarded} dropped` : ''}
${tokbar}
`], - ['parser', f.astError ? `${html(f.astError)}` : `${fmt(f.nodes)} nodes · depth ${f.depth} · ${f.topLevel} top-level`], - ['types', f.typeOk ? `ok · 0 errors` : `${f.typeErrors.length} error(s) ${html(f.typeErrors[0] ?? '')}`], - ['hir', f.hirOk ? `ok · ${fmt(f.hirBytes)} bytes` : `failed`], - ['backends', `${okBackends}/${f.backends.length} · ${fmt(totalBackendBytes)} bytes emitted`], - ['tests', testLine], - ] + `${fmt(f.bytes)} bytes · ${fmt(f.lines)} lines`, + `${fmt(f.tokens)} tokens · ${f.kinds} kinds${f.discarded ? ` · ${f.discarded} dropped` : ''}
${tokbar}
`, + f.astError ? `${html(f.astError)}` : `${fmt(f.nodes)} nodes · depth ${f.depth} · ${f.topLevel} top-level`, + f.typeOk ? `ok · 0 errors` : `${f.typeErrors.length} error(s) ${html(f.typeErrors[0] ?? '')}`, + f.hirOk ? `ok · ${fmt(f.hirBytes)} bytes` : `failed`, + `${okBackends}/${f.backends.length} · ${fmt(totalBackendBytes)} bytes emitted`, + testLine, + ].map((v, i) => [cfg.STAGES[i], v]) const tree = analysis.ast ? icicle(analysis.ast) : { svg: '', depth: 0 } const maxBytes = Math.max(1, ...f.backends.map((b) => b.bytes)) pane.innerHTML = `
@@ -296,21 +274,7 @@ function renderCode(pane, f, pick, stem) { pane.querySelector('[data-k="save"]')?.addEventListener('click', () => download(cur.code, fileName(cur.name, cur.code, stem))) const run = pane.querySelector('[data-k="run"]') if (run) run.addEventListener('click', async () => { - // The emitted module, imported as it is. Its exports are printed, nothing is called. - let out - try { - const url = URL.createObjectURL(new Blob([cur.code], { type: 'text/javascript' })) - const mod = await import(url) - URL.revokeObjectURL(url) - const names = Object.keys(mod) - out = `import ok: ${names.length} export(s)\n` + names.map((k) => { - const v = mod[k] - const s = typeof v === 'function' ? `function(${v.length})` : typeof v === 'bigint' ? `${v}n` : (() => { try { return JSON.stringify(v, (_, x) => (typeof x === 'bigint' ? `${x}n` : x)) } catch { return String(v) } })() - return ` ${k} = ${String(s).slice(0, 160)}` - }).join('\n') - } catch (e) { - out = `import failed: ${e.message}` - } + const out = await shell.importJs(cur.code, cfg) let pre = pane.querySelector('pre.out') if (!pre) { pre = document.createElement('pre'); pre.className = 'out'; pane.querySelector('.code').appendChild(pre) } pre.textContent = out @@ -396,26 +360,23 @@ ${fns ? `

Functions inputsbits · result bits · pane.querySelector('[data-k="cmds"]').addEventListener('click', (e) => copyText(tryCmds.join('\n'), e.currentTarget)) } -// --- term: a recording of t27c on the same file ------------------------------------------------ -let termMounted = null -async function renderTerm(pane, cast, specName) { - if (termMounted) return - termMounted = true +// --- term: a terminal over the same compiler (./shell.js), and the recording of t27c ------------ +async function replayCast(el, cast, specName) { try { const { mount } = await import(at('term/player.js').href) - pane.innerHTML = '
' - mount(pane.firstChild, { src: new URL(`term/${cast}/session.cast`, root).href, title: `t27c on ${specName}`, share: new URL(`term/${cast}/`, root).href }) + mount(el, { src: new URL(`term/${cast}/session.cast`, root).href, title: `t27c on ${specName}`, share: new URL(`term/${cast}/`, root).href }) } catch (e) { - pane.innerHTML = `

The recording did not load: ${html(e.message)}

` + el.innerHTML = `

The recording did not load: ${html(e.message)}

` } } // --- the page ----------------------------------------------------------------------------------- async function main() { const v = build ? `?v=${build}` : '' - ;[site, { readRtl }] = await Promise.all([ + ;[site, { readRtl }, shell] = await Promise.all([ import(new URL('site.js' + v, import.meta.url).href).catch((e) => { throw new Error(`site.js did not load: ${e.message}`) }), import(new URL('rtl.js' + v, import.meta.url).href).catch((e) => { throw new Error(`rtl.js did not load: ${e.message}`) }), + import(new URL('shell.js' + v, import.meta.url).href).catch((e) => { throw new Error(`shell.js did not load: ${e.message}`) }), ]) for (const [k, c] of Object.entries(site.CLS_COLOR)) document.documentElement.style.setProperty(`--h-${k}`, c) for (const [k, c] of Object.entries(site.C)) if (typeof c === 'string' && !/[;{}]/.test(c)) document.documentElement.style.setProperty(`--c-${k}`, c) @@ -423,7 +384,7 @@ async function main() { status('compiling player.t27…') const orchRes = await fetch(new URL('player.t27' + (build ? `?v=${build}` : ''), import.meta.url)) if (!orchRes.ok) throw new Error(`player.t27 did not load (HTTP ${orchRes.status})`) - const cfg = playerConfig(analyze(await orchRes.text(), 'player.t27')) + cfg = playerConfig(analyze(await orchRes.text(), 'player.t27')) document.documentElement.style.setProperty('--ed-touch-font', `${cfg.EDITOR_TOUCH_FONT_PX}px`) const specPath = query.get('spec') || cfg.PLAY_SPECS[0] @@ -442,8 +403,8 @@ async function main() { if (!res.ok) throw new Error(`${specPath} is not published (HTTP ${res.status})`) let source = await res.text() - // Tabs, from the spec. Term only when there is a recording to show. - const tabs = cfg.TABS.map((id, i) => ({ id, label: cfg.TAB_LABELS[i] })).filter((t) => t.id !== 'term' || cast) + // Tabs, from the spec. + const tabs = cfg.TABS.map((id, i) => ({ id, label: cfg.TAB_LABELS[i] })) const nav = $('#tabs') nav.innerHTML = tabs.map((t) => ``).join('') const wideQuery = matchMedia(`(min-width: ${cfg.WIDE_MIN_PX}px) and (min-aspect-ratio: 4/3)`) @@ -453,7 +414,7 @@ async function main() { current = id for (const b of nav.querySelectorAll('button')) b.setAttribute('aria-selected', String(b.dataset.tab === id)) for (const p of document.querySelectorAll('.pane')) p.classList.toggle('on', p.id === `pane-${id}`) - if (id === 'term' && cast) renderTerm($('#pane-term'), cast, specName) + if (id === 'term') openTerm() if (id !== 'compile' && player) player.end() } const layout = () => { @@ -514,6 +475,21 @@ async function main() { src.dispatchEvent(new Event('input')) }) + // The terminal, mounted the first time its tab opens. On a backend's share page with autoplay, + // it types SHELL_DEMO for that backend, at SHELL_TYPE_MS a character, and runs it. + let term = null + const openTerm = () => { + if (term) return + term = shell.mountShell($('#pane-term'), { + cfg, specName, stem, analyze, download, codeHtml, + source: () => src.value, + specHtml: (analysis) => site.highlightSource(src.value, analysis.tokens ?? []).map(spansHtml).join('\n'), + replay: cast ? (el) => replayCast(el, cast, specName) : null, + backend: query.get('backend'), + }) + if (autoplay && !reduced && query.get('tab') === 'term') term.type(cfg.SHELL_DEMO.replaceAll('{backend}', cfg.BACKENDS.includes(query.get('backend')) ? query.get('backend') : cfg.BACKENDS[0]), cfg.SHELL_TYPE_MS) + } + const autoplay = cfg.AUTOPLAY_PARAMS.some((k) => ['1', 'true'].includes(query.get(k))) layout() const want = query.get('tab') diff --git a/apps/website/public/play/hello-world/c/card.png b/apps/website/public/play/hello-world/c/card.png new file mode 100644 index 0000000000..1dfa869fe1 Binary files /dev/null and b/apps/website/public/play/hello-world/c/card.png differ diff --git a/apps/website/public/play/hello-world/c/index.html b/apps/website/public/play/hello-world/c/index.html new file mode 100644 index 0000000000..808a3899ce --- /dev/null +++ b/apps/website/public/play/hello-world/c/index.html @@ -0,0 +1,35 @@ + + + + + + +hello_world.t27 to C, in a terminal inside the post · Trinity S³AI + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/apps/website/public/play/hello-world/index.html b/apps/website/public/play/hello-world/index.html index 2a38f997d6..43ecad5693 100644 --- a/apps/website/public/play/hello-world/index.html +++ b/apps/website/public/play/hello-world/index.html @@ -1,35 +1,35 @@ - + hello_world.t27, compiled inside the post · Trinity S³AI - + - + - + - - + + - + - - + + diff --git a/apps/website/public/play/hello-world/js/card.png b/apps/website/public/play/hello-world/js/card.png new file mode 100644 index 0000000000..9b5e16befe Binary files /dev/null and b/apps/website/public/play/hello-world/js/card.png differ diff --git a/apps/website/public/play/hello-world/js/index.html b/apps/website/public/play/hello-world/js/index.html new file mode 100644 index 0000000000..69451d0e9b --- /dev/null +++ b/apps/website/public/play/hello-world/js/index.html @@ -0,0 +1,35 @@ + + + + + + +hello_world.t27 to JavaScript, in a terminal inside the post · Trinity S³AI + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/apps/website/public/play/hello-world/rust/card.png b/apps/website/public/play/hello-world/rust/card.png new file mode 100644 index 0000000000..670936b6c8 Binary files /dev/null and b/apps/website/public/play/hello-world/rust/card.png differ diff --git a/apps/website/public/play/hello-world/rust/index.html b/apps/website/public/play/hello-world/rust/index.html new file mode 100644 index 0000000000..be6e2a48b3 --- /dev/null +++ b/apps/website/public/play/hello-world/rust/index.html @@ -0,0 +1,35 @@ + + + + + + +hello_world.t27 to Rust, in a terminal inside the post · Trinity S³AI + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/apps/website/public/play/hello-world/ts/card.png b/apps/website/public/play/hello-world/ts/card.png new file mode 100644 index 0000000000..1a93c2e9cd Binary files /dev/null and b/apps/website/public/play/hello-world/ts/card.png differ diff --git a/apps/website/public/play/hello-world/ts/index.html b/apps/website/public/play/hello-world/ts/index.html new file mode 100644 index 0000000000..515e316125 --- /dev/null +++ b/apps/website/public/play/hello-world/ts/index.html @@ -0,0 +1,35 @@ + + + + + + +hello_world.t27 to TypeScript, in a terminal inside the post · Trinity S³AI + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/apps/website/public/play/hello-world/verilog-hir/card.png b/apps/website/public/play/hello-world/verilog-hir/card.png new file mode 100644 index 0000000000..ae74854d3e Binary files /dev/null and b/apps/website/public/play/hello-world/verilog-hir/card.png differ diff --git a/apps/website/public/play/hello-world/verilog-hir/index.html b/apps/website/public/play/hello-world/verilog-hir/index.html new file mode 100644 index 0000000000..94b5f63b65 --- /dev/null +++ b/apps/website/public/play/hello-world/verilog-hir/index.html @@ -0,0 +1,35 @@ + + + + + + +hello_world.t27 to Verilog (HIR), in a terminal inside the post · Trinity S³AI + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/apps/website/public/play/hello-world/verilog/card.png b/apps/website/public/play/hello-world/verilog/card.png new file mode 100644 index 0000000000..50f950fe57 Binary files /dev/null and b/apps/website/public/play/hello-world/verilog/card.png differ diff --git a/apps/website/public/play/hello-world/verilog/index.html b/apps/website/public/play/hello-world/verilog/index.html new file mode 100644 index 0000000000..10c5dde7ba --- /dev/null +++ b/apps/website/public/play/hello-world/verilog/index.html @@ -0,0 +1,35 @@ + + + + + + +hello_world.t27 to Verilog, in a terminal inside the post · Trinity S³AI + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/apps/website/public/play/hello-world/zig/card.png b/apps/website/public/play/hello-world/zig/card.png new file mode 100644 index 0000000000..2210b27341 Binary files /dev/null and b/apps/website/public/play/hello-world/zig/card.png differ diff --git a/apps/website/public/play/hello-world/zig/index.html b/apps/website/public/play/hello-world/zig/index.html new file mode 100644 index 0000000000..68cb65b319 --- /dev/null +++ b/apps/website/public/play/hello-world/zig/index.html @@ -0,0 +1,35 @@ + + + + + + +hello_world.t27 to Zig, in a terminal inside the post · Trinity S³AI + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/apps/website/public/play/player.t27 b/apps/website/public/play/player.t27 index b327b13e8e..efac42b7e6 100644 --- a/apps/website/public/play/player.t27 +++ b/apps/website/public/play/player.t27 @@ -23,8 +23,12 @@ ; the spec's own test blocks. Code shows what each backend emitted, coloured the way the spec ; explorer colours it. RTL reads the emitted Verilog the way a hardware engineer would before ; synthesis: the module's ports and parameters, the bit map of every packed struct, the -; functions, and what a Verilog-2005 tool will refuse in that text. Term replays a recording of -; t27c on the same file in a terminal. +; functions, and what a Verilog-2005 tool will refuse in that text. Term is a terminal over the +; same compiler: `t27c gen rust` prints the Rust it emits for the spec in the editor, and so for +; each of the seven backends; `t27c test`, `ls`, `cat` and `save` work on what it made; `run` +; executes the JavaScript backend's module in the page; `replay` plays a recording of t27c on +; the same file, made on a real machine. Each backend has a share page of its own, so a post can +; open the terminal on that backend. ; WHAT IS DELIBERATELY ABSENT: sound, a sign-in, a cookie, a request to any host but t27.ai, ; and a test outcome this page made up. A block the evaluator cannot follow is a skip and says @@ -37,8 +41,9 @@ pub const KIND : str = "player"; pub const ID : str = "x/player"; pub const NAME : str = "t27 in a post"; ; The derived files the generator rewrites (relative to apps/website): this spec's public copy, -; then the site modules below as one ES module, then one share page per play, in PLAY_IDS order. -pub const GENERATED : [3]str = ["public/play/player.t27", "public/play/site.js", "public/play/hello-world/index.html"]; +; then the site modules below as one ES module, then per play, in PLAY_IDS order, its share page +; and one share page per backend, in BACKENDS order. +pub const GENERATED : [10]str = ["public/play/player.t27", "public/play/site.js", "public/play/hello-world/index.html", "public/play/hello-world/zig/index.html", "public/play/hello-world/c/index.html", "public/play/hello-world/rust/index.html", "public/play/hello-world/verilog/index.html", "public/play/hello-world/verilog-hir/index.html", "public/play/hello-world/js/index.html", "public/play/hello-world/ts/index.html"]; ; --- What the player takes from the site ------------------------------------------------------- ; The spec explorer's highlighter and palette, bundled from the site's own source rather than @@ -101,6 +106,83 @@ pub const OUTCOMES : [3]str = ["pass", "fail", "skip"]; pub const SKIP_IS_NEVER_PASS : bool = true; pub const TESTS_NOTE : str = "Run here, in your browser, by a small evaluator over the compiler's syntax tree. Fail means an assert was false, or the block divided by zero or indexed past the end. Anything the evaluator cannot follow is a skip and names the reason; a skip is never counted as a pass."; +; --- Term: a terminal over the same compiler -------------------------------------------------- +; The compiler's backends, in the order every tab lists them. The generator refuses this list +; unless it is exactly the set of targets the compiler answers with, so a backend added to t27c +; and not named here (or named here and gone from t27c) stops the build instead of going unseen. +pub const BACKENDS : [7]str = ["zig", "c", "rust", "verilog", "verilog_hir", "js", "ts"]; +pub const BACKEND_NAMES : [7]str = ["Zig", "C", "Rust", "Verilog", "Verilog (HIR)", "JavaScript", "TypeScript"]; +; The emitted file's name is the spec's stem plus this; Verilog takes its module's name instead. +pub const BACKEND_EXTS : [7]str = [".zig", ".c", ".rs", ".v", ".hir.v", ".js", ".ts"]; +; Each backend's share page: public/play///, the terminal opened on that backend. +pub const BACKEND_PAGES : [7]str = ["zig", "c", "rust", "verilog", "verilog-hir", "js", "ts"]; +; What `run` prints for the backends this page cannot run: the command on a machine that has +; the toolchain. {file} is the emitted file. The terminal prints it; it never claims its outcome. +pub const BACKEND_RUN_LOCAL : [7]str = ["zig test {file}", "cc -std=c11 -c {file}", "rustc --crate-type lib --edition 2021 {file}", "iverilog -g2012 -o /dev/null {file}", "iverilog -g2012 -o /dev/null {file}", "node {file}", "tsc --noEmit --strict {file}"]; +; The backends whose output this page runs itself. A browser has a JavaScript engine; it has no +; zig, cc, rustc, iverilog or tsc, and this page fetches nothing from any other host to fake one. +pub const RUNS_IN_BROWSER : [1]str = ["js"]; +; The commands, what each takes, and the line `help` prints for it. Two words are one command. +pub const SHELL_COMMANDS : [12]str = ["help", "ls", "cat", "save", "lang", "t27c gen", "t27c test", "t27c check", "run", "replay", "history", "clear"]; +pub const SHELL_ARGS : [12]str = ["", "", "", "", "[backend]", "[backend]", "", "", "[backend]", "", "", ""]; +pub const SHELL_HELP : [12]str = ["this list", "the spec, and the file each backend emitted from it", "print a file", "download a file", "show the backend, or pick one", "compile the spec in the editor and print what the backend emits", "run the spec's test and invariant blocks", "what each compiler stage made of the spec", "run the emitted code, where this page can: JavaScript", "play a recording of t27c on this spec, made on a real machine", "the commands typed so far", "clear the screen"]; +; The commands a tap runs, for a reader with no keyboard; the backends are taps of their own. +pub const SHELL_CHIPS : [5]str = ["t27c test", "run", "ls", "replay", "help"]; +pub const SHELL_NOTE : str = "Every line here is printed by the t27 compiler running in this page, or by the JavaScript it emitted. Nothing is sent anywhere. A browser has no zig, cc, rustc or iverilog: for those backends, run prints the command for your machine."; +; Opened on a backend with autoplay, the terminal types this one command and runs it. +pub const SHELL_DEMO : str = "t27c gen {backend}"; +pub const SHELL_TYPE_MS : u8 = 45; +pub const SHELL_DEMO_MAX_CHARS : u8 = 32; +; A backend's share page. {spec} is the play's file name, {lang} its BACKEND_NAMES entry and +; {backend} its BACKENDS entry. Filled in, each is held to X's limits like any play's. +pub const SHELL_PAGE_TITLE : str = "{spec} to {lang}, in a terminal inside the post"; +pub const SHELL_PAGE_DESCRIPTION : str = "A terminal in your browser running the t27 compiler as WebAssembly: t27c gen {backend} prints the {lang} it emits for {spec}, and t27c test runs the spec's tests."; +pub const SHELL_PAGE_IMAGE_ALT : str = "The t27 player's terminal: t27c gen {backend} on {spec}, and the {lang} the compiler emitted, coloured."; + +; --- What the terminal says ------------------------------------------------------------------ +; Every word the terminal prints is one of these; public/play/shell.js holds none of its own. +; It fills the {names} and lays the columns out, nothing more. The generator refuses a SAY_ +; constant that shell.js never says and a phrase shell.js says that is not here, so the two +; cannot drift. The name sets the colour: SAY_ERR_ red, SAY_WARN_ yellow, SAY_NOTE_ dim. +; shell.js is JavaScript by hand for one reason, measured: gen-js lowers declarations, not +; function bodies, so the terminal's logic cannot yet be a t27 spec compiled by this compiler. +pub const STAGES : [7]str = ["source", "lexer", "parser", "types", "hir", "backends", "tests"]; +pub const SAY_CHECK : [6]str = ["{bytes} bytes, {lines} lines", "{tokens} tokens, {kinds} kinds, {dropped} dropped", "{nodes} nodes, depth {depth}, {top} top-level", "{errors} errors", "{bytes} bytes", "{ok} of {all} emitted, {bytes} bytes"]; +pub const SAY_BANNER : str = "t27 terminal - {spec} - {count} backends"; +pub const SAY_NOTE_START : str = "type help, or tap a command below"; +pub const SAY_NOTE_BACKENDS : str = "backends: {backends}"; +pub const SAY_NOTE_ALSO : str = "also understood: {alias}, the local command of a backend that runs here, and the names of native tools"; +pub const SAY_SPEC_FILE : str = "the spec"; +pub const SAY_RUNS_HERE : str = "runs in this page"; +pub const SAY_FAILED : str = "failed"; +pub const SAY_ERR_WHICH_FILE : str = "{cmd}: which file? ls lists them"; +pub const SAY_ERR_NO_FILE : str = "{cmd}: {file}: no such file; ls lists them"; +pub const SAY_ERR_NOT_EMITTED : str = "{backend} emitted nothing: {error}"; +pub const SAY_SAVED : str = "asked your browser to download {file}, {bytes} bytes"; +pub const SAY_ERR_NO_BACKEND : str = "{cmd}: no backend called {name}; the backends are {backends}"; +pub const SAY_NOTE_GEN : str = "// {file}: {bytes} bytes of {lang}, compiled in {ms} ms by t27_compiler.wasm in this page"; +pub const SAY_ERR_RUNNER : str = "the test runner stopped: {error}"; +pub const SAY_NOTE_NO_TESTS : str = "this spec has no test or invariant block"; +pub const SAY_TESTS : str = "{pass} pass, {fail} fail, {skip} skip"; +pub const SAY_ASSERTS : str = "asserts: {count}"; +pub const SAY_NOTE_CHECKED : str = "compiled in {ms} ms, in this page"; +pub const SAY_NOTE_IMPORTING : str = "importing {file} into this page; its exports are printed, nothing is called"; +pub const SAY_IMPORT_OK : str = "import ok: {count} exports"; +pub const SAY_ERR_IMPORT : str = "import failed: {error}"; +pub const SAY_WARN_NOT_HERE : str = "{lang} does not run in a browser: this page has no {tool}. On a machine that has it:"; +pub const SAY_LOCAL_STEPS : [2]str = [" $ save {file}", " $ {command}"]; +pub const SAY_NOTE_NO_CAST : str = "there is no recording of t27c on this spec"; +pub const SAY_NOTE_REPLAY : str = "a recording of t27c on this spec, made on a real machine: the typing is staged, every byte printed is real"; +pub const SAY_WARN_NATIVE : str = "{tool} is not in your browser: this page has the t27 compiler and nothing else"; +pub const SAY_NOTE_NATIVE_RUN : str = "run {backend} prints the command for your machine"; +pub const SAY_ERR_T27C : str = "t27c {sub}: not in this terminal; it has {commands}"; +pub const SAY_ERR_NOT_FOUND : str = "{cmd}: command not found; help lists what this terminal does"; +pub const SAY_ERR_THREW : str = "{cmd} stopped: {error}"; +; Names a reader may type for a toolchain this page does not have, besides the first word of +; each BACKEND_RUN_LOCAL; and the real t27c's spelling of gen, which the terminal also takes. +pub const NATIVE_TOOLS : [7]str = ["gcc", "clang", "cargo", "verilator", "yosys", "deno", "bun"]; +pub const SHELL_GEN_ALIAS : str = "t27c gen-{backend}"; + ; --- Plays --------------------------------------------------------------------------------------- ; One entry per play, the same position in every array. SPEC is a path under public/t27/files/; ; CAST names a recording published at public/term//. @@ -109,8 +191,8 @@ pub const PLAY_IDS : [1]str = ["hello-world"]; pub const PLAY_SPECS : [1]str = ["specs/demos/hello_world.t27"]; pub const PLAY_CASTS : [1]str = ["t27c-hello-world"]; pub const PLAY_TITLES : [1]str = ["hello_world.t27, compiled inside the post"]; -pub const PLAY_DESCRIPTIONS : [1]str = ["The t27 compiler, as WebAssembly, running on one spec in your browser: lexer to seven backends in eight seconds, the spec's own tests, the emitted code, its Verilog read for synthesis, and a t27c run."]; -pub const PLAY_IMAGE_ALTS : [1]str = ["The t27 player: hello_world.t27 on the left, the compiler's stages on the right with token, node and backend counts, and tabs for tests, code, RTL and a terminal recording."]; +pub const PLAY_DESCRIPTIONS : [1]str = ["The t27 compiler, as WebAssembly, running on one spec in your browser: lexer to seven backends, the spec's own tests, the emitted code, its Verilog read for synthesis, and a terminal."]; +pub const PLAY_IMAGE_ALTS : [1]str = ["The t27 player: hello_world.t27 on the left, the compiler's stages on the right with token, node and backend counts, and tabs for tests, code, RTL and a terminal."]; test the_player_is_sixteen_by_nine { assert PLAYER_WIDTH * 9 == PLAYER_HEIGHT * 16; @@ -156,8 +238,43 @@ test the_site_bundle_is_generated_with_the_rest { assert SITE_MODULES[0] == "src/lib/highlight.ts"; } +test the_terminal_speaks_every_backend { + assert BACKENDS[0] == "zig"; + assert BACKENDS[2] == "rust"; + assert BACKENDS[4] == "verilog_hir"; + assert BACKEND_PAGES[4] == "verilog-hir"; + assert BACKEND_EXTS[4] == ".hir.v"; + assert BACKEND_NAMES[5] == "JavaScript"; +} + +test only_javascript_runs_in_the_browser { + assert RUNS_IN_BROWSER[0] == "js"; + assert BACKENDS[5] == RUNS_IN_BROWSER[0]; +} + +test the_typed_command_ends_inside_the_autoplay_limit { + assert SHELL_DEMO_MAX_CHARS * SHELL_TYPE_MS <= AUTOPLAY_LIMIT_MS; + assert SHELL_TYPE_MS > 0; +} + +test the_terminal_is_a_tab_and_gen_is_a_command { + assert TABS[5] == "term"; + assert TAB_LABELS[5] == "Term"; + assert SHELL_COMMANDS[0] == "help"; + assert SHELL_COMMANDS[5] == "t27c gen"; + assert SHELL_ARGS[5] == "[backend]"; + assert SHELL_CHIPS[0] == SHELL_COMMANDS[6]; +} + test the_first_play_is_hello_world { assert PLAY_COUNT == 1; assert PLAY_IDS[0] == "hello-world"; assert PLAY_SPECS[0] == "specs/demos/hello_world.t27"; } + +test the_terminal_reads_the_compile_stages { + assert STAGES[0] == "source"; + assert STAGES[5] == "backends"; + assert STAGES[6] == "tests"; + assert SAY_CHECK[3] == "{errors} errors"; +} diff --git a/apps/website/public/play/shell.js b/apps/website/public/play/shell.js new file mode 100644 index 0000000000..328e241606 --- /dev/null +++ b/apps/website/public/play/shell.js @@ -0,0 +1,361 @@ +// shell.js -- the player's Term tab: a terminal over the compiler already in the page. +// +// Everything the terminal knows and says is in player.t27: its commands (SHELL_COMMANDS, each a +// key of COMMANDS below), the backends, the commands for a machine with a toolchain, the native +// tools it recognises, and every phrase it prints (SAY_*). This file fills the {names} and lays +// the columns out; scripts/play-from-spec.mjs refuses the build when a command or a phrase is on +// one side and not the other. It is JavaScript written by hand for one measured reason: gen-js +// lowers declarations, not function bodies, so this logic cannot yet be a t27 spec compiled by +// the compiler it drives. +// +// Every line printed comes from the page's own t27_compiler.wasm (ctx.analyze), from the spec's +// test blocks run by ./t27run.js, or from the JavaScript the compiler emitted. A browser can run +// one of the seven outputs, JavaScript (RUNNERS); for the others, run prints the command for a +// machine that has the toolchain and never a result it did not see. +// +// No DOM work at the top level: the generator imports this file to read COMMANDS and RUNNERS, and +// its test runs every command through makeShell against the committed spec. + +import { runSpec } from './t27run.js' + +const html = (s) => String(s).replace(/&/g, '&').replace(//g, '>').replace(/"/g, '"') +const fmt = (n) => Number(n).toLocaleString('en-US') +const pad = (s, n) => String(s) + ' '.repeat(Math.max(1, n - String(s).length)) +const norm = (s) => String(s).toLowerCase().replace(/[^a-z0-9]/g, '') +const TONE = [['SAY_ERR_', 'r'], ['SAY_WARN_', 'y'], ['SAY_NOTE_', 'dim']] + +/** A SAY_ template with its {names} filled; a name left unfilled is a bug, and says so. */ +export function fill(template, vars = {}) { + return String(template).replace(/\{([a-z]+)\}/g, (_, k) => { + if (!(k in vars)) throw new Error(`"${template}" needs {${k}}`) + return vars[k] + }) +} + +// The emitted JavaScript, imported as it is. Its exports are printed; nothing is called. +export async function importJs(code, cfg) { + const url = URL.createObjectURL(new Blob([code], { type: 'text/javascript' })) + try { + const mod = await import(url) + const names = Object.keys(mod) + return fill(cfg.SAY_IMPORT_OK, { count: names.length }) + '\n' + names.map((k) => { + const v = mod[k] + const s = typeof v === 'function' ? `function(${v.length})` : typeof v === 'bigint' ? `${v}n` : (() => { try { return JSON.stringify(v, (_, x) => (typeof x === 'bigint' ? `${x}n` : x)) } catch { return String(v) } })() + return ` ${k} = ${String(s).slice(0, 160)}` + }).join('\n') + } catch (e) { + return fill(cfg.SAY_ERR_IMPORT, { error: e.message }) + } finally { + URL.revokeObjectURL(url) + } +} + +// The file a backend's output is saved as: the spec's stem and BACKEND_EXTS. Verilog is saved under +// its module's name, which is what verilator's DECLFILENAME lint expects. +export function fileName(cfg, backend, code, stem) { + const m = /^\s*module\s+([A-Za-z_][\w$]*)/m.exec(code) + return (backend.startsWith('verilog') && m ? m[1] : stem) + cfg.BACKEND_EXTS[cfg.BACKENDS.indexOf(backend)] +} + +// The facts every tab and t27c check show, in BACKENDS order; the spec's test blocks are run by +// ./t27run.js, which never counts a skip as a pass. +export function factsOf(cfg, source, analysis) { + const targets = analysis.targets ?? {} + const backends = cfg.BACKENDS.filter((k) => k in targets).map((k) => ({ name: k, ok: targets[k].ok !== false && typeof targets[k].code === 'string' && targets[k].code.length > 0, bytes: targets[k].bytes ?? (targets[k].code ?? '').length, code: targets[k].code ?? '', error: targets[k].error })) + let tests = null + try { tests = runSpec(analysis.ast) } catch (e) { tests = { error: e.message, results: [], pass: 0, fail: 0, skip: 0 } } + return { + bytes: analysis.sourceBytes ?? new TextEncoder().encode(source).length, + lines: analysis.sourceLines ?? source.split('\n').length, + tokens: analysis.tokenCount ?? (analysis.tokens ?? []).length, + kinds: new Set((analysis.tokens ?? []).map((t) => t.kind)).size, + nodes: analysis.nodeCount ?? 0, + depth: analysis.astDepth ?? 0, + topLevel: analysis.topLevel ?? (analysis.ast?.children ?? []).length, + discarded: (analysis.discarded?.length ?? 0) + (analysis.lexerDiscarded?.length ?? 0) + (analysis.swallowed?.length ?? 0), + typeOk: analysis.typecheck?.ok === true, + typeErrors: analysis.typecheck?.errors ?? [], + hirOk: analysis.hir?.ok !== false, + hirBytes: (analysis.hir?.text ?? '').length, + astError: analysis.astError ?? null, + backends, + tests, + } +} + +// The backends whose output this page can execute, and how. +export const RUNNERS = { js: importJs } + +// --- the commands: (sh, args) -> printed lines ------------------------------------------------- +export const COMMANDS = { + help(sh) { + const c = sh.cfg + const usage = c.SHELL_COMMANDS.map((k, i) => k + (c.SHELL_ARGS[i] ? ' ' + c.SHELL_ARGS[i] : '')) + const width = Math.max(...usage.map((u) => u.length)) + 2 + usage.forEach((u, i) => sh.line(`${html(pad(u, width))}${html(c.SHELL_HELP[i])}`)) + sh.say('SAY_NOTE_BACKENDS', { backends: c.BACKENDS.join(' ') }) + sh.say('SAY_NOTE_ALSO', { alias: fill(c.SHELL_GEN_ALIAS, { backend: '' }) }) + }, + ls(sh) { + const { files } = sh.compile() + const width = Math.max(...files.map((f) => f.name.length)) + 2 + for (const f of files) sh.line(`${f.ok ? html(pad(f.name, width)) : `${html(pad(f.name, width))}`}${pad(f.ok ? fmt(f.text.length) : '-', 8)}${html(f.backend ?? sh.word('SAY_SPEC_FILE'))}${f.ok ? '' : ` ${html(sh.word('SAY_FAILED'))}`}`) + }, + cat(sh, [name]) { + const f = sh.file(name, 'cat') + if (!f) return + if (!f.ok) return sh.say('SAY_ERR_NOT_EMITTED', { backend: f.backend, error: f.error || sh.word('SAY_FAILED') }) + sh.block(f.html()) + }, + save(sh, [name]) { + const f = sh.file(name, 'save') + if (!f) return + if (!f.ok) return sh.say('SAY_ERR_NOT_EMITTED', { backend: f.backend, error: f.error || sh.word('SAY_FAILED') }) + sh.ctx.download(f.text, f.name) + sh.say('SAY_SAVED', { file: f.name, bytes: fmt(f.text.length) }) + }, + lang(sh, [want]) { + if (want) { const b = sh.pick(want, 'lang'); if (!b) return; sh.setBackend(b) } + const c = sh.cfg + c.BACKENDS.forEach((b, i) => sh.line(`${b === sh.backend ? '*' : ' '} ${html(pad(b, 13))}${html(pad(c.BACKEND_NAMES[i], 15))}${html(c.BACKEND_EXTS[i])}${c.RUNS_IN_BROWSER.includes(b) ? ', ' + html(sh.word('SAY_RUNS_HERE')) : ''}`)) + }, + 't27c gen'(sh, [want]) { + const b = want ? sh.pick(want, 't27c gen') : sh.backend + if (!b) return + sh.setBackend(b) + const { ms, files } = sh.compile() + const f = files.find((x) => x.backend === b) + if (!f || !f.ok) return sh.say('SAY_ERR_NOT_EMITTED', { backend: b, error: f?.error || sh.word('SAY_FAILED') }) + sh.say('SAY_NOTE_GEN', { file: f.name, bytes: fmt(f.text.length), lang: sh.nameOf(b), ms: ms.toFixed(0) }) + sh.block(f.html()) + }, + 't27c test'(sh) { + const { facts } = sh.compile() + const t = facts.tests + if (t.error) return sh.say('SAY_ERR_RUNNER', { error: t.error }) + const mark = { pass: '✓', fail: '✗', skip: '–' } + for (const r of t.results) { + const failed = r.status === 'fail' ? (r.asserts ?? []).filter((a) => !a.ok).map((a) => `line ${a.line}${a.detail ? `: ${a.detail}` : ''}`) : [] + const why = r.reason || failed.join('; ') + sh.line(`${mark[r.status]} ${html(pad(r.kind, 10))}${html(r.name)} ${html(fill(sh.cfg.SAY_ASSERTS, { count: (r.asserts ?? []).length }))}${why ? ' · ' + html(why) : ''}`) + } + if (!t.results.length) sh.say('SAY_NOTE_NO_TESTS') + sh.line(`${html(fill(sh.cfg.SAY_TESTS, { pass: t.pass, fail: t.fail, skip: t.skip }))}`) + sh.line(`${html(sh.cfg.TESTS_NOTE)}`) + }, + 't27c check'(sh) { + const { facts: f, ms } = sh.compile() + const ok = f.backends.filter((b) => b.ok) + const c = sh.cfg + // One row per compile stage but the last (t27c test is that one): STAGES and SAY_CHECK. + const rows = [ + [true, { bytes: fmt(f.bytes), lines: fmt(f.lines) }], + [!f.discarded, { tokens: fmt(f.tokens), kinds: f.kinds, dropped: f.discarded }], + [!f.astError, { nodes: fmt(f.nodes), depth: f.depth, top: f.topLevel }, f.astError], + [f.typeOk, { errors: f.typeErrors.length }, f.typeErrors[0]], + [f.hirOk, { bytes: fmt(f.hirBytes) }], + [ok.length === f.backends.length, { ok: ok.length, all: f.backends.length, bytes: fmt(ok.reduce((s, b) => s + b.bytes, 0)) }], + ] + rows.forEach(([good, vars, why], i) => sh.line(`${html(pad(c.STAGES[i], 10))}${html(fill(c.SAY_CHECK[i], vars))}${why ? ` ${html(why)}` : ''}`)) + sh.say('SAY_NOTE_CHECKED', { ms: ms.toFixed(0) }) + }, + async run(sh, [want]) { + const b = want ? sh.pick(want, 'run') : sh.backend + if (!b) return + sh.setBackend(b) + const { files } = sh.compile() + const f = files.find((x) => x.backend === b) + if (!f || !f.ok) return sh.say('SAY_ERR_NOT_EMITTED', { backend: b, error: f?.error || sh.word('SAY_FAILED') }) + const runner = sh.runners[b] + if (runner) { + sh.say('SAY_NOTE_IMPORTING', { file: f.name }) + return sh.block(html(await runner(f.text, sh.cfg)), 'out') + } + const command = fill(sh.cfg.BACKEND_RUN_LOCAL[sh.cfg.BACKENDS.indexOf(b)], { file: f.name }) + sh.say('SAY_WARN_NOT_HERE', { lang: sh.nameOf(b), tool: command.split(' ')[0] }) + for (const step of sh.cfg.SAY_LOCAL_STEPS) sh.line(html(fill(step, { file: f.name, command }))) + }, + async replay(sh) { + if (!sh.ctx.replay) return sh.say('SAY_NOTE_NO_CAST') + sh.say('SAY_NOTE_REPLAY') + await sh.ctx.replay(sh.box()) + }, + history(sh) { + sh.history.forEach((h, i) => sh.line(`${pad(i + 1, 4)}${html(h)}`)) + }, + clear(sh) { + sh.clear() + }, +} + +// One typed line: the longest command it starts with, then its arguments. Shared by the page and +// by scripts/play-from-spec.test.mjs, which runs every command against the committed spec. +export async function dispatch(sh, text) { + const cfg = sh.cfg + let words = text.trim().split(/\s+/).filter(Boolean) + if (!words.length) return + sh.history.push(words.join(' ')) + // The real t27c's spelling, t27c gen-rust, is t27c gen rust here. + const [aliasHead, aliasTail] = cfg.SHELL_GEN_ALIAS.split('{backend}') + const typed = words.slice(0, 2).join(' ') + if (typed.startsWith(aliasHead) && typed.endsWith(aliasTail) && typed.length > aliasHead.length + aliasTail.length) words = [...aliasHead.replace(/-$/, '').split(' '), typed.slice(aliasHead.length, typed.length - aliasTail.length), ...words.slice(2)] + const two = words.slice(0, 2).join(' ') + const key = COMMANDS[two] ? two : words[0] + const args = words.slice(key.split(' ').length) + // A backend's local command, typed for a backend this page runs, is run for that backend. + const local = cfg.BACKENDS.findIndex((b, i) => cfg.RUNS_IN_BROWSER.includes(b) && cfg.BACKEND_RUN_LOCAL[i].split(' ')[0] === words[0]) + const tools = new Set([...cfg.BACKEND_RUN_LOCAL.map((c) => c.split(' ')[0]), ...cfg.NATIVE_TOOLS]) + try { + if (COMMANDS[key]) await COMMANDS[key](sh, args) + else if (local >= 0 && args.length) await COMMANDS.run(sh, [cfg.BACKENDS[local]]) + else if (tools.has(words[0])) { + sh.say('SAY_WARN_NATIVE', { tool: words[0] }) + const i = cfg.BACKEND_RUN_LOCAL.findIndex((c) => c.split(' ')[0] === words[0]) + if (i >= 0) sh.say('SAY_NOTE_NATIVE_RUN', { backend: cfg.BACKENDS[i] }) + } else if (words[0] === 't27c') sh.say('SAY_ERR_T27C', { sub: words.slice(1).join(' '), commands: cfg.SHELL_COMMANDS.filter((c) => c.startsWith('t27c ')).join(', ') }) + else sh.say('SAY_ERR_NOT_FOUND', { cmd: words[0] }) + } catch (e) { + sh.say('SAY_ERR_THREW', { cmd: key, error: e.message }) + } +} + +// The terminal's state and its printing, apart from the page: print(kind, html) is the only way +// out, so the generator's test can run every command without a DOM. +// ctx: { cfg, specName, stem, source(), analyze(source, name), specHtml(analysis), +// codeHtml(code, backend), download(text, name), replay(el) | null, backend } +export function makeShell(ctx, print, runners = RUNNERS) { + const cfg = ctx.cfg + const sh = { + cfg, + ctx, + runners, + history: [], + backend: cfg.BACKENDS.includes(ctx.backend) ? ctx.backend : cfg.BACKENDS[0], + onBackend: () => {}, + line: (h, cls = '') => print('line', h, cls), + block: (h, cls = '') => print('block', h, cls), + box: () => print('box'), + clear: () => print('clear'), + word: (key) => cfg[key], + say(key, vars) { return sh.line(html(fill(cfg[key], vars)), TONE.find(([p]) => key.startsWith(p))?.[1] ?? '') }, + nameOf: (b) => cfg.BACKEND_NAMES[cfg.BACKENDS.indexOf(b)] ?? b, + // A backend by its id, its page name, its language's name or its file extension. + pick(want, cmd) { + const w = norm(want) + const i = cfg.BACKENDS.findIndex((b, j) => [b, cfg.BACKEND_PAGES[j], cfg.BACKEND_NAMES[j], cfg.BACKEND_EXTS[j]].some((x) => norm(x) === w)) + if (i < 0) { sh.say('SAY_ERR_NO_BACKEND', { cmd, name: want, backends: cfg.BACKENDS.join(' ') }); return null } + return cfg.BACKENDS[i] + }, + setBackend(b) { sh.backend = b; sh.onBackend(b) }, + // A fresh compile of whatever is in the editor now, and the files it makes. + compile() { + const source = ctx.source() + const t0 = performance.now() + const analysis = ctx.analyze(source, ctx.specName) + const facts = factsOf(cfg, source, analysis) + const ms = performance.now() - t0 + const files = [{ name: ctx.specName, ok: true, text: source, html: () => ctx.specHtml(analysis) }] + for (const t of facts.backends) files.push({ name: fileName(cfg, t.name, t.code, ctx.stem), backend: t.name, ok: t.ok, text: t.code, error: t.error, html: () => ctx.codeHtml(t.code, t.name) }) + return { analysis, facts, ms, files } + }, + file(name, cmd) { + if (!name) { sh.say('SAY_ERR_WHICH_FILE', { cmd }); return null } + const { files } = sh.compile() + const f = files.find((x) => x.name === name) || files.find((x) => x.name.toLowerCase() === name.toLowerCase()) + if (!f) sh.say('SAY_ERR_NO_FILE', { cmd, file: name }) + return f || null + }, + } + return sh +} + +// --- the terminal on the page --------------------------------------------------------------- +export function mountShell(el, ctx) { + const cfg = ctx.cfg + el.innerHTML = `
+
+
` + const out = el.querySelector('.sh-out') + const input = el.querySelector('input') + const ps = el.querySelector('.ps') + const chips = el.querySelector('.sh-chips') + const sh = makeShell(ctx, (kind, h, cls) => { + if (kind === 'clear') { out.innerHTML = ''; return null } + const node = document.createElement(kind === 'block' ? 'pre' : 'div') + node.className = kind === 'box' ? 'sh-box' : `${kind === 'block' ? 'blk' : 'ln'}${cls ? ' ' + cls : ''}` + if (kind !== 'box') node.innerHTML = h || ' ' + out.appendChild(node) + return node + }) + const prompt = () => { + ps.textContent = `${ctx.stem} [${sh.backend}] $` + for (const b of chips.querySelectorAll('[data-b]')) b.setAttribute('aria-pressed', String(b.dataset.b === sh.backend)) + } + sh.onBackend = prompt + + async function run(text) { + const echo = sh.line(`${html(ps.textContent)} ${html(text.trim())}`, 'cmd') + await dispatch(sh, text) + // A long answer is read from its top, so the command line is scrolled to, not the last line. + out.scrollTop = echo.isConnected ? echo.offsetTop - out.offsetTop : 0 + } + + // Tab: a command, then a file or a backend, by what SHELL_ARGS says the command takes. + function complete() { + const v = input.value + const sp = v.lastIndexOf(' ') + const head = v.slice(0, sp + 1).trim() + const word = v.slice(sp + 1) + const arg = cfg.SHELL_ARGS[cfg.SHELL_COMMANDS.indexOf(head)] ?? '' + let pool + if (!head || cfg.SHELL_COMMANDS.some((c) => c.startsWith(head + ' '))) pool = cfg.SHELL_COMMANDS.filter((c) => c.startsWith(v)).map((c) => c.slice(head ? head.length + 1 : 0)) + else if (arg.includes('file')) pool = sh.compile().files.filter((f) => f.ok).map((f) => f.name) + else if (arg.includes('backend')) pool = cfg.BACKENDS + else return + const hits = [...new Set(pool.filter((c) => c.startsWith(word)))] + if (!hits.length) return + let common = hits[0] + for (const h of hits) while (!h.startsWith(common)) common = common.slice(0, -1) + if (hits.length === 1) common += ' ' + if (common.length > word.length) input.value = v.slice(0, sp + 1) + common + else sh.line(hits.map(html).join(' '), 'dim') + } + + let back = 0 + input.addEventListener('keydown', (e) => { + if (e.key === 'Tab') { e.preventDefault(); complete() } + else if (e.key === 'ArrowUp' || e.key === 'ArrowDown') { + e.preventDefault() + back = Math.max(0, Math.min(sh.history.length, back + (e.key === 'ArrowUp' ? 1 : -1))) + input.value = back ? sh.history[sh.history.length - back] : '' + } else if (e.key === 'l' && e.ctrlKey) { e.preventDefault(); sh.clear() } + else if (e.key === 'c' && e.ctrlKey && input.selectionStart === input.selectionEnd) { sh.line(`${html(ps.textContent)} ${html(input.value)}^C`, 'cmd'); input.value = '' } + }) + el.querySelector('form').addEventListener('submit', (e) => { e.preventDefault(); const v = input.value; input.value = ''; back = 0; run(v) }) + // A tap on the output starts typing, unless it selects text. + out.addEventListener('click', () => { if (!String(getSelection?.() ?? '')) input.focus({ preventScroll: true }) }) + + chips.innerHTML = cfg.BACKENDS.map((b) => ``).join('') + cfg.SHELL_CHIPS.map((c) => ``).join('') + chips.addEventListener('click', (e) => { const b = e.target.closest('button[data-run]'); if (b) run(b.dataset.run) }) + prompt() + sh.line(`${html(fill(cfg.SAY_BANNER, { spec: ctx.specName, count: cfg.BACKENDS.length }))}`) + sh.line(`${html(cfg.SHELL_NOTE)}`) + sh.say('SAY_NOTE_START') + + return { + run, + // The command typed at the given pace, as a reader would see it typed, then run. + type(text, ms) { + return new Promise((done) => { + let i = 0 + const step = () => { + input.value = text.slice(0, ++i) + if (i < text.length) setTimeout(step, ms) + else setTimeout(() => { input.value = ''; run(text).then(done) }, ms * 4) + } + step() + }) + }, + focus: () => input.focus({ preventScroll: true }), + } +} diff --git a/apps/website/public/term/index.html b/apps/website/public/term/index.html index feb6eb640a..a267fe9015 100644 --- a/apps/website/public/term/index.html +++ b/apps/website/public/term/index.html @@ -12,9 +12,9 @@ - + - + + + +
+ +

t27 play · the terminal's own gates

+

The terminal for seven t27 backends: every command run on hello_world. 21 pass, 0 fail.

+
+
recorded 2026-10-04 14:46 UTCreal 71.3 sshown 13.3 sexit codes 0 0
+
$ npm run -s check:play +$ npm run -s test:play-spec
+

Staged: the prompt and the typing. Real: every byte the commands printed, at the time they printed it. Any silence longer than 2 s is shown for 2 s, and the title bar says so while it happens. Edited: home directory shown as ~.

+ +
+ + + diff --git a/apps/website/public/term/play-terminal-gates/meta.json b/apps/website/public/term/play-terminal-gates/meta.json new file mode 100644 index 0000000000..f2771ce556 --- /dev/null +++ b/apps/website/public/term/play-terminal-gates/meta.json @@ -0,0 +1,23 @@ +{ + "id": "play-terminal-gates", + "title": "t27 play · the terminal's own gates", + "desc": "The terminal for seven t27 backends: every command run on hello_world. 21 pass, 0 fail.", + "url": "https://t27.ai/term/play-terminal-gates/", + "gif": "https://t27.ai/term/play-terminal-gates/session.gif", + "post": "https://t27.ai/#/blog/a-terminal-for-seven-backends-in-an-x-post", + "recorded": "2026-10-04 14:46 UTC", + "real_s": 71.3, + "shown_s": 13.3, + "commands": [ + "npm run -s check:play", + "npm run -s test:play-spec" + ], + "exit_codes": [ + "0", + "0" + ], + "redacted": [ + "home directory shown as ~" + ], + "published": "2026-10-04" +} diff --git a/apps/website/public/term/play-terminal-gates/session.cast b/apps/website/public/term/play-terminal-gates/session.cast new file mode 100644 index 0000000000..f2a1f052d6 --- /dev/null +++ b/apps/website/public/term/play-terminal-gates/session.cast @@ -0,0 +1,62 @@ +{"version": 2, "width": 104, "height": 30, "timestamp": 1791125174, "title": "t27 play \u00b7 the terminal's own gates", "commands": ["npm run -s check:play", "npm run -s test:play-spec"], "redacted": ["home directory shown as ~"]} +[0.0003, "o", "\u001b[38;2;255;215;0mwebsite $ \u001b[0m"] +[0.4123, "o", "n"] +[0.4601, "o", "p"] +[0.5014, "o", "m"] +[0.542, "o", " "] +[0.5847, "o", "r"] +[0.6532, "o", "u"] +[0.7157, "o", "n"] +[0.7692, "o", " "] +[0.8207, "o", "-"] +[0.8562, "o", "s"] +[0.8967, "o", " "] +[0.9356, "o", "c"] +[0.9753, "o", "h"] +[1.0191, "o", "e"] +[1.0613, "o", "c"] +[1.1044, "o", "k"] +[1.146, "o", ":"] +[1.1828, "o", "p"] +[1.2185, "o", "l"] +[1.2582, "o", "a"] +[1.297, "o", "y"] +[1.6586, "o", "\r\n"] +[22.9022, "o", "play-from-spec: up to date public/play/player.t27, public/play/site.js, public/play/hello-world/index.html, public/play/hello-world/zig/index.html, public/play/hello-world/c/index.html, public/play/hello-world/rust/index.html, public/play/hello-world/verilog/index.html, public/play/hello-world/verilog-hir/index.html, public/play/hello-world/js/index.html, public/play/hello-world/ts/index.html; spec 4743b287726010b1; player build ded547ceeb2f; 1 play(s): hello-world; spec tests 14, asserts 43, all hold\r\n"] +[23.5193, "x", "0"] +[24.5276, "o", "\u001b[38;2;255;215;0mwebsite $ \u001b[0m"] +[24.9343, "o", "n"] +[24.9733, "o", "p"] +[25.0135, "o", "m"] +[25.0527, "o", " "] +[25.0888, "o", "r"] +[25.133, "o", "u"] +[25.1789, "o", "n"] +[25.2186, "o", " "] +[25.2537, "o", "-"] +[25.2937, "o", "s"] +[25.3308, "o", " "] +[25.3696, "o", "t"] +[25.41, "o", "e"] +[25.45, "o", "s"] +[25.4855, "o", "t"] +[25.5259, "o", ":"] +[25.5639, "o", "p"] +[25.6069, "o", "l"] +[25.648, "o", "a"] +[25.6897, "o", "y"] +[25.7263, "o", "-"] +[25.7691, "o", "s"] +[25.8199, "o", "p"] +[25.8609, "o", "e"] +[25.903, "o", "c"] +[26.2475, "o", "\r\n"] +[67.0293, "o", "\u001b[32m✔ the committed spec builds clean and writes exactly the committed files \u001b[90m(4107.93375ms)\u001b[39m\u001b[39m\r\n\u001b[32m✔ an animation longer than X lets a card start by itself is refused \u001b[90m(1487.221083ms)\u001b[39m\u001b[39m\r\n\u001b[32m✔ a title longer than X shows is refused, not cut \u001b[90m(768.289ms)\u001b[39m\u001b[39m\r\n\u001b[32m✔ a tab the page has no pane for is refused \u001b[90m(2380.081958ms)\u001b[39m\u001b[39m\r\n\u001b[32m✔ the player is served over https or not at all \u001b[90m(1136.682125ms)\u001b[39m\u001b[39m\r\n\u001b[32m✔ a backend the compiler does not emit is refused \u001b[90m(335.69375ms)\u001b[39m\u001b[39m\r\n\u001b[32m✔ a command with no handler, or a handler with no command, is refused \u001b[90m(1586.305208ms)\u001b[39m\u001b[39m\r\n\u001b[32m✔ a phrase the spec defines and the terminal never says, or says and the spec lacks, is refused \u001b[90m(1471.2115ms)\u001b[39m\u001b[39m\r\n\u001b[32m✔ a card demo too long to type inside the box, or inside X's autoplay limit, is refused \u001b[90m(2256.254333ms)\u001b[39m\u001b[39m\r\n\u001b[32m✔ every backend has a share page whose player opens the terminal on that backend, and gi"] +[67.0527, "o", "t keeps it \u001b[90m(2615.170167ms)\u001b[39m\u001b[39m\r\n\u001b[32m✔ every terminal command runs on hello_world and says only what the spec lets it say \u001b[90m(2619.757ms)\u001b[39m\u001b[39m\r\n\u001b[32m✔ run prints the command for a machine with rustc and never a result; js is imported here \u001b[90m(123.609208ms)\u001b[39m\u001b[39m\r\n"] +[67.1245, "o", "\u001b[32m✔ t27c gen-rust is t27c gen rust; gcc, an unknown t27c subcommand and a typo each say why \u001b[90m(622.771584ms)\u001b[39m\u001b[39m\r\n"] +[67.2098, "o", "\u001b[32m✔ save asks for a download of exactly what the compiler emitted \u001b[90m(301.890041ms)\u001b[39m\u001b[39m\r\n"] +[67.3108, "o", "\u001b[32m✔ site.js is built from the site source, so a palette change there reaches the player \u001b[90m(2299.577791ms)\u001b[39m\u001b[39m\r\n\u001b[32m✔ the spec highlighter: a /* inside a string opens no comment, and a ; line is prose \u001b[90m(227.974375ms)\u001b[39m\u001b[39m\r\n\u001b[32m✔ hello_world: gen-verilog emits what a Verilog-2005 reader refuses, and the struct packs to 25 \u001b[90m(599.484ms)\u001b[39m\u001b[39m\r\n\u001b[32m✔ hello_world: the HIR backend writes an unescaped `config` port 32 bits wide for a 25-bit struct \u001b[90m(7.999167ms)\u001b[39m\u001b[39m\r\n\u001b[32m✔ a \"/*\" inside a Verilog string opens no comment, so what follows still counts \u001b[90m(0.342959ms)\u001b[39m\u001b[39m\r\n\u001b[32m✔ a task is declared, and `function real` names its result type, not the function \u001b[90m(13.066042ms)\u001b[39m\u001b[39m\r\n"] +[67.3837, "o", "\u001b[32m✔ an array sized by a constant packs the way t27c packs it \u001b[90m(42.499209ms)\u001b[39m\u001b[39m\r\n"] +[69.3124, "o", "\u001b[34mℹ tests 21\u001b[39m\r\n\u001b[34mℹ suites 0\u001b[39m\r\n\u001b[34mℹ pass 21\u001b[39m\r\n\u001b[34mℹ fail 0\u001b[39m\r\n\u001b[34mℹ cancelled 0\u001b[39m\r\n\u001b[34mℹ skipped 0\u001b[39m\r\n\u001b[34mℹ todo 0\u001b[39m\r\n\u001b[34mℹ duration_ms 33891.065\u001b[39m\r\n"] +[70.2967, "x", "0"] +[71.2999, "o", "\u001b[38;2;255;215;0mwebsite $ \u001b[0m"] diff --git a/apps/website/public/term/play-terminal-gates/session.gif b/apps/website/public/term/play-terminal-gates/session.gif new file mode 100644 index 0000000000..82c2067a8c Binary files /dev/null and b/apps/website/public/term/play-terminal-gates/session.gif differ diff --git a/apps/website/scripts/play-from-spec.mjs b/apps/website/scripts/play-from-spec.mjs index d27a750d0c..6e611fb93f 100644 --- a/apps/website/scripts/play-from-spec.mjs +++ b/apps/website/scripts/play-from-spec.mjs @@ -10,6 +10,8 @@ // public/play/site.js the spec explorer's highlighter and palette (SITE_MODULES), // bundled by esbuild from the site's own source, not copied // public/play//index.html one share page per play, carrying twitter:card = player +// public/play///index.html one more per backend (BACKEND_PAGES), opening the +// terminal on that backend: a post can link straight to Rust // // The share page is the address a post links to. X reads its meta tags and, on a click, loads // `twitter:player` in an iframe; the page itself shows the same player full-window to anyone who @@ -21,7 +23,7 @@ import { existsSync, mkdirSync, readFileSync, statSync, writeFileSync } from 'node:fs' import { buildSync } from 'esbuild' import { dirname, join } from 'node:path' -import { fileURLToPath } from 'node:url' +import { fileURLToPath, pathToFileURL } from 'node:url' import { CYRILLIC, SITE, checkSchema, compilerErrors, constsOf, loadCompiler, readCast, sha256, verdictOf } from './agents-from-specs.mjs' import { runSpecTests } from './viewport-from-spec.mjs' @@ -32,7 +34,7 @@ export const SITE_BUNDLE_OUT = 'public/play/site.js' export const EXPECTED_MODULE = 'x_player' // What the player is built from. A change to any of them is a new player, so it is a new // `v=` on the iframe address and a page --check will call stale. -export const PLAYER_FILES = ['public/play/embed.html', 'public/play/embed.js', 'public/play/t27run.js', 'public/play/rtl.js', 'public/term/player.js', WASM] +export const PLAYER_FILES = ['public/play/embed.html', 'public/play/embed.js', 'public/play/t27run.js', 'public/play/rtl.js', 'public/play/shell.js', 'public/term/player.js', WASM] // The outcomes `public/play/t27run.js` reports, in the order the spec lists them. export const RUNNER_OUTCOMES = ['pass', 'fail', 'skip'] @@ -49,14 +51,28 @@ export const PLAYER_REQUIRED = { SITE_MODULES: 'arr', SITE_IMPORTS: 'arr', SITE_BUNDLE: 'str', PLAY_COUNT: 'u8', PLAY_IDS: 'arr', PLAY_SPECS: 'arr', PLAY_CASTS: 'arr', PLAY_TITLES: 'arr', PLAY_DESCRIPTIONS: 'arr', PLAY_IMAGE_ALTS: 'arr', + BACKENDS: 'arr', BACKEND_NAMES: 'arr', BACKEND_EXTS: 'arr', BACKEND_PAGES: 'arr', BACKEND_RUN_LOCAL: 'arr', RUNS_IN_BROWSER: 'arr', + SHELL_COMMANDS: 'arr', SHELL_ARGS: 'arr', SHELL_HELP: 'arr', SHELL_CHIPS: 'arr', SHELL_NOTE: 'str', + SHELL_DEMO: 'str', SHELL_TYPE_MS: 'u8', SHELL_DEMO_MAX_CHARS: 'u8', + SHELL_PAGE_TITLE: 'str', SHELL_PAGE_DESCRIPTION: 'str', SHELL_PAGE_IMAGE_ALT: 'str', + STAGES: 'arr', NATIVE_TOOLS: 'arr', SHELL_GEN_ALIAS: 'str', } +// What the terminal says: any number of SAY_ constants, each a string or an array of strings. +// shellProblems holds them to public/play/shell.js both ways. +const sayShapes = (consts) => Object.fromEntries(Object.keys(consts).filter((k) => k.startsWith('SAY_')).map((k) => [k, Array.isArray(consts[k].value) ? 'arr' : 'str'])) const PLAY_FIELDS = ['PLAY_IDS', 'PLAY_SPECS', 'PLAY_CASTS', 'PLAY_TITLES', 'PLAY_DESCRIPTIONS', 'PLAY_IMAGE_ALTS'] +const BACKEND_FIELDS = ['BACKEND_NAMES', 'BACKEND_EXTS', 'BACKEND_PAGES', 'BACKEND_RUN_LOCAL'] +const SHELL_FIELDS = ['SHELL_ARGS', 'SHELL_HELP'] // The same two patterns embed.js applies to its query before it fetches anything. export const SPEC_PATH_RE = /^specs\/[A-Za-z0-9_\-/.]+\.t27$/ export const CAST_ID_RE = /^[a-z0-9][a-z0-9-]{0,63}$/ -export const pageOut = (id) => `public/play/${id}/index.html` -export const imageOf = (f, id) => `public/play/${id}/${f.IMAGE_FILE}` +export const pageOut = (id, sub) => `public/play/${id}/${sub ? sub + '/' : ''}index.html` +export const imageOf = (f, id, sub) => `public/play/${id}/${sub ? sub + '/' : ''}${f.IMAGE_FILE}` +// Every file the generator writes, in GENERATED order: per play, its page, then one per backend. +export const generatedFiles = (f) => [PUBLIC_SPEC_OUT, SITE_BUNDLE_OUT, ...f.PLAY_IDS.flatMap((id) => [pageOut(id), ...f.BACKEND_PAGES.map((sub) => pageOut(id, sub))])] +// A backend page's title, description and image text: the SHELL_PAGE_* templates, filled in. +export const fillShell = (template, f, i, j) => template.replaceAll('{spec}', (f.PLAY_SPECS[i] ?? '').split('/').pop()).replaceAll('{lang}', f.BACKEND_NAMES[j] ?? '').replaceAll('{backend}', f.BACKENDS[j] ?? '') /** Width and height from a PNG's IHDR, or null when the bytes are not a PNG. */ export function pngSize(bytes) { @@ -69,7 +85,7 @@ export function pngSize(bytes) { // Semantic checks the schema cannot express. `root` is the site, so a test can point it // at a fixture; `files` reads a site-relative path or returns null. // --------------------------------------------------------------------------- -export function semanticProblems(f, file, root = SITE) { +export async function semanticProblems(f, file, root = SITE, { targets = null } = {}) { const problems = [] const p = (m) => problems.push(`${file}: ${m}`) if (f.KIND !== 'player') p('KIND must be "player"') @@ -78,7 +94,7 @@ export function semanticProblems(f, file, root = SITE) { if (!/^@[A-Za-z0-9_]{1,15}$/.test(f.SITE_HANDLE)) p(`SITE_HANDLE ${f.SITE_HANDLE} is not an X handle`) if (!existsSync(join(root, 'public', f.EMBED))) p(`EMBED public/${f.EMBED} does not exist`) for (const k of PLAY_FIELDS) if (f[k].length !== f.PLAY_COUNT) p(`${k} has ${f[k].length} entries, PLAY_COUNT is ${f.PLAY_COUNT}`) - const want = [PUBLIC_SPEC_OUT, SITE_BUNDLE_OUT, ...f.PLAY_IDS.map(pageOut)] + const want = generatedFiles(f) if (f.SITE_BUNDLE !== SITE_BUNDLE_OUT) p(`SITE_BUNDLE must be ${SITE_BUNDLE_OUT}, the module embed.js imports as ./site.js`) if (f.SITE_MODULES.length !== f.SITE_IMPORTS.length) p('SITE_MODULES and SITE_IMPORTS must have one entry each per module') for (const m of f.SITE_MODULES) if (!/^src\/lib\/[A-Za-z0-9_]+\.ts$/.test(m) || !existsSync(join(root, m))) p(`SITE_MODULES: ${m} is not a module under src/lib/`) @@ -92,6 +108,7 @@ export function semanticProblems(f, file, root = SITE) { for (const t of f.TABS) if (!embed.includes(`id="pane-${t}"`)) p(`tab ${t} has no pane-${t} in public/${f.EMBED}`) if (f.COMPILE_ANIMATION_MS > f.AUTOPLAY_LIMIT_MS) p('COMPILE_ANIMATION_MS exceeds AUTOPLAY_LIMIT_MS') if (new Set(f.PLAY_IDS).size !== f.PLAY_IDS.length) p('PLAY_IDS repeats an id') + problems.push(...(await shellProblems(f, file, root, targets))) for (let i = 0; i < f.PLAY_COUNT; i++) { const id = f.PLAY_IDS[i] const at = `play ${id ?? i}` @@ -117,6 +134,66 @@ export function semanticProblems(f, file, root = SITE) { if (!size) p(`${at}: ${imageOf(f, id)} is not a PNG`) else if (size.width !== f.IMAGE_WIDTH || size.height !== f.IMAGE_HEIGHT) p(`${at}: ${imageOf(f, id)} is ${size.width}x${size.height}, the card declares ${f.IMAGE_WIDTH}x${f.IMAGE_HEIGHT}`) if (statSync(img).size > f.IMAGE_MAX_BYTES) p(`${at}: ${imageOf(f, id)} is ${statSync(img).size} bytes, over ${f.IMAGE_MAX_BYTES}`) + f.BACKEND_PAGES.forEach((sub, j) => { + const there = `${at}, backend ${f.BACKENDS[j]}` + const fill = (k, max) => { const s = fillShell(f[k], f, i, j); if (!s || s.length > max) p(`${there}: ${k} fills to ${s.length} characters, X allows 1..${max}`) } + fill('SHELL_PAGE_TITLE', f.TITLE_MAX) + fill('SHELL_PAGE_DESCRIPTION', f.DESCRIPTION_MAX) + fill('SHELL_PAGE_IMAGE_ALT', f.IMAGE_ALT_MAX) + const card = join(root, imageOf(f, id, sub)) + if (!existsSync(card)) { p(`${there}: ${imageOf(f, id, sub)} is missing`); return } + const sz = pngSize(readFileSync(card)) + if (!sz) p(`${there}: ${imageOf(f, id, sub)} is not a PNG`) + else if (sz.width !== f.IMAGE_WIDTH || sz.height !== f.IMAGE_HEIGHT) p(`${there}: ${imageOf(f, id, sub)} is ${sz.width}x${sz.height}, the card declares ${f.IMAGE_WIDTH}x${f.IMAGE_HEIGHT}`) + if (statSync(card).size > f.IMAGE_MAX_BYTES) p(`${there}: ${imageOf(f, id, sub)} is ${statSync(card).size} bytes, over ${f.IMAGE_MAX_BYTES}`) + }) + } + return problems +} + +// The terminal: what the spec says it does against what public/play/shell.js does, and the +// backend list against the compiler's own answer. +export async function shellProblems(f, file, root = SITE, targets = null) { + const problems = [] + const p = (m) => problems.push(`${file}: ${m}`) + const n = f.BACKENDS.length + for (const k of BACKEND_FIELDS) if (f[k].length !== n) p(`${k} has ${f[k].length} entries, BACKENDS has ${n}`) + if (new Set(f.BACKENDS).size !== n) p('BACKENDS repeats a backend') + if (targets) { + const have = [...targets].sort().join(', ') + if ([...f.BACKENDS].sort().join(', ') !== have) p(`BACKENDS must be the compiler's targets (${have}), is ${f.BACKENDS.join(', ')}`) + } + if (new Set(f.BACKEND_PAGES).size !== n) p('BACKEND_PAGES repeats a page') + for (const sub of f.BACKEND_PAGES) if (!CAST_ID_RE.test(sub)) p(`BACKEND_PAGES: ${sub} must match ${CAST_ID_RE}`) + for (const e of f.BACKEND_EXTS) if (!/^(\.[a-z]+)+$/.test(e)) p(`BACKEND_EXTS: ${e} is not a file extension`) + f.BACKEND_RUN_LOCAL.forEach((c, j) => { if (!c.includes('{file}')) p(`BACKEND_RUN_LOCAL[${j}] (${f.BACKENDS[j]}) does not name {file}`) }) + for (const b of f.RUNS_IN_BROWSER) if (!f.BACKENDS.includes(b)) p(`RUNS_IN_BROWSER: ${b} is not a backend`) + for (const k of SHELL_FIELDS) if (f[k].length !== f.SHELL_COMMANDS.length) p(`${k} has ${f[k].length} entries, SHELL_COMMANDS has ${f.SHELL_COMMANDS.length}`) + for (const c of f.SHELL_CHIPS) if (!f.SHELL_COMMANDS.includes(c)) p(`SHELL_CHIPS: ${c} is not one of SHELL_COMMANDS`) + if (!f.SHELL_DEMO.includes('{backend}')) p('SHELL_DEMO must name {backend}') + for (const b of f.BACKENDS) { + const typed = f.SHELL_DEMO.replaceAll('{backend}', b) + if (typed.length > f.SHELL_DEMO_MAX_CHARS) p(`SHELL_DEMO for ${b} is ${typed.length} characters, SHELL_DEMO_MAX_CHARS is ${f.SHELL_DEMO_MAX_CHARS}`) + if (!f.SHELL_COMMANDS.some((c) => typed === c || typed.startsWith(c + ' '))) p(`SHELL_DEMO "${typed}" is not one of SHELL_COMMANDS`) + } + if (f.SHELL_DEMO_MAX_CHARS * f.SHELL_TYPE_MS > f.AUTOPLAY_LIMIT_MS) p('typing SHELL_DEMO would outlast AUTOPLAY_LIMIT_MS') + if (f.STAGES.length !== 7) p(`STAGES names ${f.STAGES.length} stages; the compile piece has 7`) + if ((f.SAY_CHECK ?? []).length !== f.STAGES.length - 1) p(`SAY_CHECK has ${(f.SAY_CHECK ?? []).length} rows; t27c check prints one per stage but tests, ${f.STAGES.length - 1}`) + if (!f.SHELL_GEN_ALIAS.includes('{backend}')) p('SHELL_GEN_ALIAS must name {backend}') + let shell = null + try { shell = await import(pathToFileURL(join(root, 'public/play/shell.js')).href) } catch (e) { p(`public/play/shell.js does not load: ${e.message}`) } + if (shell) { + const handled = Object.keys(shell.COMMANDS) + for (const c of f.SHELL_COMMANDS) if (!handled.includes(c)) p(`command "${c}" has no handler in public/play/shell.js`) + for (const c of handled) if (!f.SHELL_COMMANDS.includes(c)) p(`public/play/shell.js handles "${c}", which SHELL_COMMANDS does not list`) + // Every phrase shell.js prints is a SAY_ constant here, and every SAY_ constant is printed. + const text = readFileSync(join(root, 'public/play/shell.js'), 'utf8') + const said = new Set((text.match(/\bSAY_[A-Z0-9_]*[A-Z0-9]\b/g) ?? [])) + const have = Object.keys(f).filter((k) => k.startsWith('SAY_')) + for (const k of said) if (!have.includes(k)) p(`public/play/shell.js says ${k}, which this spec does not define`) + for (const k of have) if (!said.has(k)) p(`${k} is never said by public/play/shell.js`) + const runs = Object.keys(shell.RUNNERS).sort().join(', ') + if ([...f.RUNS_IN_BROWSER].sort().join(', ') !== runs) p(`RUNS_IN_BROWSER must be what public/play/shell.js runs (${runs})`) } return problems } @@ -130,15 +207,21 @@ export const esc = (s) => String(s).replace(/&/g, '&').replace(/"/g, '" /** The iframe's query, the same for the card and the page; `v` is the player build. */ export const playQuery = (f, i, build) => `spec=${encodeURIComponent(f.PLAY_SPECS[i])}&cast=${encodeURIComponent(f.PLAY_CASTS[i])}&v=${build}` -export function renderPage(f, i, { specSha, build, imageSha }) { +/** A backend page's query: the play's, opening the terminal on backend j. */ +export const shellQuery = (f, i, j, build) => `spec=${encodeURIComponent(f.PLAY_SPECS[i])}&cast=${encodeURIComponent(f.PLAY_CASTS[i])}&tab=term&backend=${encodeURIComponent(f.BACKENDS[j])}&v=${build}` + +/** j is a backend's index for its page, or undefined for the play's own page. */ +export function renderPage(f, i, { specSha, build, imageSha, j }) { const id = f.PLAY_IDS[i] - const page = `${f.ORIGIN}play/${id}/` - const q = playQuery(f, i, build) + const shellPage = j !== undefined + const page = `${f.ORIGIN}play/${id}/${shellPage ? f.BACKEND_PAGES[j] + '/' : ''}` + const up = shellPage ? '../../../' : '../../' + const q = shellPage ? shellQuery(f, i, j, build) : playQuery(f, i, build) const player = `${f.ORIGIN}${f.EMBED}?${q}` const image = `${page}${f.IMAGE_FILE}?v=${imageSha}` - const title = f.PLAY_TITLES[i] - const desc = f.PLAY_DESCRIPTIONS[i] - const alt = f.PLAY_IMAGE_ALTS[i] + const title = shellPage ? fillShell(f.SHELL_PAGE_TITLE, f, i, j) : f.PLAY_TITLES[i] + const desc = shellPage ? fillShell(f.SHELL_PAGE_DESCRIPTION, f, i, j) : f.PLAY_DESCRIPTIONS[i] + const alt = shellPage ? fillShell(f.SHELL_PAGE_IMAGE_ALT, f, i, j) : f.PLAY_IMAGE_ALTS[i] const open = `${f.ORIGIN}${f.SPEC_PAGE}${encodeURIComponent(f.PLAY_SPECS[i])}` const meta = (attr, k, v) => `` return ` @@ -150,7 +233,7 @@ export function renderPage(f, i, { specSha, build, imageSha }) { ${esc(title)} · Trinity S³AI ${meta('name', 'description', desc)} - + ${meta('property', 'og:type', 'website')} ${meta('property', 'og:site_name', 'Trinity S³AI')} ${meta('property', 'og:url', page)} @@ -172,7 +255,7 @@ ${meta('name', 'twitter:image:alt', alt)} - + @@ -213,11 +296,11 @@ export async function buildPlay({ specText, analyze, root = SITE }) { if (analysis.ast?.name !== EXPECTED_MODULE) problems.push(`${PLAYER_SPEC}: module must be ${EXPECTED_MODULE}, is ${analysis.ast?.name ?? 'missing'}`) let consts = {} try { consts = constsOf(analysis) } catch (e) { problems.push(`${PLAYER_SPEC}: ${e.message}`) } - problems.push(...checkSchema(consts, PLAYER_REQUIRED, {}, PLAYER_SPEC)) + problems.push(...checkSchema(consts, PLAYER_REQUIRED, sayShapes(consts), PLAYER_SPEC)) const f = Object.fromEntries(Object.entries(consts).map(([k, v]) => [k, v.value])) let tests = { tests: 0, asserts: 0, failures: [] } if (problems.length === 0) { - problems.push(...semanticProblems(f, PLAYER_SPEC, root)) + problems.push(...(await semanticProblems(f, PLAYER_SPEC, root, { targets: Object.keys(analysis.targets ?? {}) }))) tests = runSpecTests(analysis, f) if (tests.tests === 0) problems.push(`${PLAYER_SPEC}: no test block; the player must test its own limits`) problems.push(...tests.failures.map((m) => `${PLAYER_SPEC}: test ${m}`)) @@ -231,6 +314,10 @@ export async function buildPlay({ specText, analyze, root = SITE }) { f.PLAY_IDS.forEach((id, i) => { const imageSha = sha256(readFileSync(join(root, imageOf(f, id)))).slice(0, 12) files.push([pageOut(id), renderPage(f, i, { specSha, build, imageSha })]) + f.BACKEND_PAGES.forEach((sub, j) => { + const cardSha = sha256(readFileSync(join(root, imageOf(f, id, sub)))).slice(0, 12) + files.push([pageOut(id, sub), renderPage(f, i, { specSha, build, imageSha: cardSha, j })]) + }) }) return { problems, verdict, fields: f, specSha, tests, build, files } } diff --git a/apps/website/scripts/play-from-spec.test.mjs b/apps/website/scripts/play-from-spec.test.mjs index 19dfe66eb2..2bc712b4a1 100644 --- a/apps/website/scripts/play-from-spec.test.mjs +++ b/apps/website/scripts/play-from-spec.test.mjs @@ -11,12 +11,14 @@ import test from 'node:test' import assert from 'node:assert/strict' +import { spawnSync } from 'node:child_process' import { cpSync, mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs' import { tmpdir } from 'node:os' import { join } from 'node:path' import { SITE, loadCompiler } from './agents-from-specs.mjs' -import { PLAYER_SPEC, SITE_BUNDLE_OUT, buildPlay, playerBuild, renderSiteBundle } from './play-from-spec.mjs' +import { PLAYER_SPEC, SITE_BUNDLE_OUT, buildPlay, imageOf, playerBuild, renderSiteBundle } from './play-from-spec.mjs' import { readRtl } from '../public/play/rtl.js' +import { COMMANDS, dispatch, factsOf, fill, makeShell } from '../public/play/shell.js' const analyze = await loadCompiler(readFileSync(join(SITE, 'public/t27/t27_compiler.wasm'))) const spec = readFileSync(join(SITE, PLAYER_SPEC), 'utf8') @@ -27,7 +29,8 @@ const edit = (from, to) => { const importText = (text) => import(`data:text/javascript;base64,${Buffer.from(text).toString('base64')}`) const site = await importText(readFileSync(join(SITE, SITE_BUNDLE_OUT), 'utf8')) -const hello = analyze(readFileSync(join(SITE, 'public/t27/files/specs/demos/hello_world.t27'), 'utf8')) +const helloSource = readFileSync(join(SITE, 'public/t27/files/specs/demos/hello_world.t27'), 'utf8') +const hello = analyze(helloSource) const emitted = (a, name) => a.targets[name].code const has = (r, level, re) => r.findings.some((x) => x.level === level && re.test(x.text)) @@ -61,6 +64,121 @@ test('the player is served over https or not at all', async () => { assert.ok(out.problems.some((p) => p.includes('ORIGIN must be an https origin')), out.problems.join('\n')) }) +// --- the terminal ------------------------------------------------------------------------------- + +const committed = await buildPlay({ specText: spec, analyze }) +const cfg = committed.fields + +// The terminal with no page: every printed line and every SAY_ key it said, the js runner stubbed +// (Node cannot import a blob: URL; the page's runner is the one the browser measurement covers). +function term(backend = cfg.BACKENDS[0]) { + const out = [] + const said = [] + const ran = [] + const saved = [] + const sh = makeShell({ + cfg, specName: 'hello_world.t27', stem: 'hello_world', backend, + source: () => helloSource, analyze, + specHtml: () => '', codeHtml: (code) => code, download: (text, name) => saved.push([name, text.length]), + replay: null, + }, (kind, h = '') => out.push(`${kind} ${h.replace(/<[^>]+>/g, '')}`), { js: async (code) => { ran.push(code.length); return 'stub' } }) + const say = sh.say + sh.say = (key, vars) => { said.push(key); return say(key, vars) } + return { sh, out, said, ran, saved, run: (text) => dispatch(sh, text) } +} + +test('a backend the compiler does not emit is refused', async () => { + const out = await buildPlay({ specText: edit('"verilog_hir", "js", "ts"]', '"verilog_hir", "js", "py"]'), analyze }) + assert.ok(out.problems.some((p) => p.includes("BACKENDS must be the compiler's targets")), out.problems.join('\n')) +}) + +test('a command with no handler, or a handler with no command, is refused', async () => { + const out = await buildPlay({ specText: edit('"history", "clear"]', '"whoami", "clear"]'), analyze }) + assert.ok(out.problems.some((p) => p.includes('command "whoami" has no handler')), out.problems.join('\n')) + assert.ok(out.problems.some((p) => p.includes('handles "history", which SHELL_COMMANDS does not list')), out.problems.join('\n')) +}) + +test('a phrase the spec defines and the terminal never says, or says and the spec lacks, is refused', async () => { + const out = await buildPlay({ specText: edit('pub const SAY_FAILED : str', 'pub const SAY_FAILURE : str'), analyze }) + assert.ok(out.problems.some((p) => p.includes('says SAY_FAILED, which this spec does not define')), out.problems.join('\n')) + assert.ok(out.problems.some((p) => p.includes('SAY_FAILURE is never said')), out.problems.join('\n')) +}) + +test('a card demo too long to type inside the box, or inside X\'s autoplay limit, is refused', async () => { + const long = await buildPlay({ specText: edit('SHELL_DEMO : str = "t27c gen {backend}"', 'SHELL_DEMO : str = "t27c gen {backend} and then some"'), analyze }) + assert.ok(long.problems.some((p) => /SHELL_DEMO for verilog_hir is \d+ characters/.test(p)), long.problems.join('\n')) + const slow = await buildPlay({ specText: edit('SHELL_DEMO_MAX_CHARS : u8 = 32', 'SHELL_DEMO_MAX_CHARS : u8 = 255'), analyze }) + assert.ok(slow.problems.some((p) => p.includes('typing SHELL_DEMO would outlast AUTOPLAY_LIMIT_MS')), slow.problems.join('\n')) +}) + +test('every backend has a share page whose player opens the terminal on that backend, and git keeps it', () => { + const written = new Map(committed.files) + const paths = [] + cfg.BACKENDS.forEach((b, j) => { + const page = `public/play/hello-world/${cfg.BACKEND_PAGES[j]}/index.html` + assert.ok(written.has(page), `${page} is written`) + assert.match(written.get(page), new RegExp(`name="twitter:player" content="[^"]*tab=term&backend=${b}&`)) + paths.push(page, imageOf(cfg, 'hello-world', cfg.BACKEND_PAGES[j])) + }) + // The root .gitignore has `zig/` for SDK downloads; a page git ignores is a page the site 404s. + const ignored = (list) => spawnSync('git', ['check-ignore', '--no-index', '--', ...list], { cwd: SITE, encoding: 'utf8' }).stdout.trim() + assert.equal(ignored(paths), '', 'no generated page or card is ignored by git') + assert.equal(ignored(['public/elsewhere/zig/card.png']), 'public/elsewhere/zig/card.png', 'the check sees the rule it guards against') +}) + +test('every terminal command runs on hello_world and says only what the spec lets it say', async () => { + const facts = factsOf(cfg, helloSource, hello) + const one = (c, i) => (cfg.SHELL_ARGS[i] === '' ? `${c} hello_world.t27` : cfg.SHELL_ARGS[i] ? `${c} ${cfg.BACKENDS[2]}` : c) + for (const [i, c] of cfg.SHELL_COMMANDS.entries()) { + const t = term() + await t.run(one(c, i)) + assert.ok(!t.said.includes('SAY_ERR_THREW'), `${c} threw: ${t.out.join(' | ')}`) + assert.ok(!t.said.some((k) => k.startsWith('SAY_ERR_')), `${c}: ${t.said.join(', ')}`) + assert.ok(t.out.length > 0 || c === 'clear', `${c} printed nothing`) + } + assert.deepEqual(Object.keys(COMMANDS), cfg.SHELL_COMMANDS) + const t = term() + await t.run('t27c test') + assert.equal(facts.tests.fail, 0) + assert.ok(t.out.some((l) => l.includes(fill(cfg.SAY_TESTS, { pass: facts.tests.pass, fail: 0, skip: facts.tests.skip }))), t.out.join('\n')) +}) + +test('run prints the command for a machine with rustc and never a result; js is imported here', async () => { + const rust = term() + await rust.run('run rust') + assert.deepEqual(rust.said, ['SAY_WARN_NOT_HERE']) + assert.ok(rust.out.some((l) => l.includes('$ rustc --crate-type lib --edition 2021 hello_world.rs')), rust.out.join('\n')) + assert.equal(rust.ran.length, 0, 'nothing ran') + const js = term() + await js.run('node hello_world.js') + assert.deepEqual(js.said, ['SAY_NOTE_IMPORTING']) + assert.equal(js.ran.length, 1, 'the emitted JavaScript went to the runner once') + assert.equal(js.sh.backend, 'js') +}) + +test('t27c gen-rust is t27c gen rust; gcc, an unknown t27c subcommand and a typo each say why', async () => { + const a = term() + await a.run('t27c gen-rust') + const b = term() + await b.run('t27c gen rust') + const timeless = (out) => out.map((l) => l.replace(/ in \d+ ms /, ' in N ms ')) + assert.deepEqual(timeless(a.out), timeless(b.out)) + assert.equal(a.sh.backend, 'rust') + const cases = [['gcc hello_world.c', 'SAY_WARN_NATIVE'], ['t27c fmt', 'SAY_ERR_T27C'], ['lss', 'SAY_ERR_NOT_FOUND'], ['t27c gen cobol', 'SAY_ERR_NO_BACKEND'], ['cat nope.v', 'SAY_ERR_NO_FILE'], ['cat', 'SAY_ERR_WHICH_FILE']] + for (const [typed, key] of cases) { + const t = term() + await t.run(typed) + assert.ok(t.said.includes(key), `${typed}: ${t.said.join(', ')}`) + assert.ok(!t.out.join('\n').includes('needs {'), `${typed}: a phrase was printed unfilled`) + } +}) + +test('save asks for a download of exactly what the compiler emitted', async () => { + const t = term() + await t.run('save hello_world.rs') + assert.deepEqual(t.saved, [['hello_world.rs', hello.targets.rust.code.length]]) +}) + // --- the site bundle --------------------------------------------------------------------------- test('site.js is built from the site source, so a palette change there reaches the player', async () => { diff --git a/apps/website/specs/x/player.t27 b/apps/website/specs/x/player.t27 index b327b13e8e..efac42b7e6 100644 --- a/apps/website/specs/x/player.t27 +++ b/apps/website/specs/x/player.t27 @@ -23,8 +23,12 @@ ; the spec's own test blocks. Code shows what each backend emitted, coloured the way the spec ; explorer colours it. RTL reads the emitted Verilog the way a hardware engineer would before ; synthesis: the module's ports and parameters, the bit map of every packed struct, the -; functions, and what a Verilog-2005 tool will refuse in that text. Term replays a recording of -; t27c on the same file in a terminal. +; functions, and what a Verilog-2005 tool will refuse in that text. Term is a terminal over the +; same compiler: `t27c gen rust` prints the Rust it emits for the spec in the editor, and so for +; each of the seven backends; `t27c test`, `ls`, `cat` and `save` work on what it made; `run` +; executes the JavaScript backend's module in the page; `replay` plays a recording of t27c on +; the same file, made on a real machine. Each backend has a share page of its own, so a post can +; open the terminal on that backend. ; WHAT IS DELIBERATELY ABSENT: sound, a sign-in, a cookie, a request to any host but t27.ai, ; and a test outcome this page made up. A block the evaluator cannot follow is a skip and says @@ -37,8 +41,9 @@ pub const KIND : str = "player"; pub const ID : str = "x/player"; pub const NAME : str = "t27 in a post"; ; The derived files the generator rewrites (relative to apps/website): this spec's public copy, -; then the site modules below as one ES module, then one share page per play, in PLAY_IDS order. -pub const GENERATED : [3]str = ["public/play/player.t27", "public/play/site.js", "public/play/hello-world/index.html"]; +; then the site modules below as one ES module, then per play, in PLAY_IDS order, its share page +; and one share page per backend, in BACKENDS order. +pub const GENERATED : [10]str = ["public/play/player.t27", "public/play/site.js", "public/play/hello-world/index.html", "public/play/hello-world/zig/index.html", "public/play/hello-world/c/index.html", "public/play/hello-world/rust/index.html", "public/play/hello-world/verilog/index.html", "public/play/hello-world/verilog-hir/index.html", "public/play/hello-world/js/index.html", "public/play/hello-world/ts/index.html"]; ; --- What the player takes from the site ------------------------------------------------------- ; The spec explorer's highlighter and palette, bundled from the site's own source rather than @@ -101,6 +106,83 @@ pub const OUTCOMES : [3]str = ["pass", "fail", "skip"]; pub const SKIP_IS_NEVER_PASS : bool = true; pub const TESTS_NOTE : str = "Run here, in your browser, by a small evaluator over the compiler's syntax tree. Fail means an assert was false, or the block divided by zero or indexed past the end. Anything the evaluator cannot follow is a skip and names the reason; a skip is never counted as a pass."; +; --- Term: a terminal over the same compiler -------------------------------------------------- +; The compiler's backends, in the order every tab lists them. The generator refuses this list +; unless it is exactly the set of targets the compiler answers with, so a backend added to t27c +; and not named here (or named here and gone from t27c) stops the build instead of going unseen. +pub const BACKENDS : [7]str = ["zig", "c", "rust", "verilog", "verilog_hir", "js", "ts"]; +pub const BACKEND_NAMES : [7]str = ["Zig", "C", "Rust", "Verilog", "Verilog (HIR)", "JavaScript", "TypeScript"]; +; The emitted file's name is the spec's stem plus this; Verilog takes its module's name instead. +pub const BACKEND_EXTS : [7]str = [".zig", ".c", ".rs", ".v", ".hir.v", ".js", ".ts"]; +; Each backend's share page: public/play///, the terminal opened on that backend. +pub const BACKEND_PAGES : [7]str = ["zig", "c", "rust", "verilog", "verilog-hir", "js", "ts"]; +; What `run` prints for the backends this page cannot run: the command on a machine that has +; the toolchain. {file} is the emitted file. The terminal prints it; it never claims its outcome. +pub const BACKEND_RUN_LOCAL : [7]str = ["zig test {file}", "cc -std=c11 -c {file}", "rustc --crate-type lib --edition 2021 {file}", "iverilog -g2012 -o /dev/null {file}", "iverilog -g2012 -o /dev/null {file}", "node {file}", "tsc --noEmit --strict {file}"]; +; The backends whose output this page runs itself. A browser has a JavaScript engine; it has no +; zig, cc, rustc, iverilog or tsc, and this page fetches nothing from any other host to fake one. +pub const RUNS_IN_BROWSER : [1]str = ["js"]; +; The commands, what each takes, and the line `help` prints for it. Two words are one command. +pub const SHELL_COMMANDS : [12]str = ["help", "ls", "cat", "save", "lang", "t27c gen", "t27c test", "t27c check", "run", "replay", "history", "clear"]; +pub const SHELL_ARGS : [12]str = ["", "", "", "", "[backend]", "[backend]", "", "", "[backend]", "", "", ""]; +pub const SHELL_HELP : [12]str = ["this list", "the spec, and the file each backend emitted from it", "print a file", "download a file", "show the backend, or pick one", "compile the spec in the editor and print what the backend emits", "run the spec's test and invariant blocks", "what each compiler stage made of the spec", "run the emitted code, where this page can: JavaScript", "play a recording of t27c on this spec, made on a real machine", "the commands typed so far", "clear the screen"]; +; The commands a tap runs, for a reader with no keyboard; the backends are taps of their own. +pub const SHELL_CHIPS : [5]str = ["t27c test", "run", "ls", "replay", "help"]; +pub const SHELL_NOTE : str = "Every line here is printed by the t27 compiler running in this page, or by the JavaScript it emitted. Nothing is sent anywhere. A browser has no zig, cc, rustc or iverilog: for those backends, run prints the command for your machine."; +; Opened on a backend with autoplay, the terminal types this one command and runs it. +pub const SHELL_DEMO : str = "t27c gen {backend}"; +pub const SHELL_TYPE_MS : u8 = 45; +pub const SHELL_DEMO_MAX_CHARS : u8 = 32; +; A backend's share page. {spec} is the play's file name, {lang} its BACKEND_NAMES entry and +; {backend} its BACKENDS entry. Filled in, each is held to X's limits like any play's. +pub const SHELL_PAGE_TITLE : str = "{spec} to {lang}, in a terminal inside the post"; +pub const SHELL_PAGE_DESCRIPTION : str = "A terminal in your browser running the t27 compiler as WebAssembly: t27c gen {backend} prints the {lang} it emits for {spec}, and t27c test runs the spec's tests."; +pub const SHELL_PAGE_IMAGE_ALT : str = "The t27 player's terminal: t27c gen {backend} on {spec}, and the {lang} the compiler emitted, coloured."; + +; --- What the terminal says ------------------------------------------------------------------ +; Every word the terminal prints is one of these; public/play/shell.js holds none of its own. +; It fills the {names} and lays the columns out, nothing more. The generator refuses a SAY_ +; constant that shell.js never says and a phrase shell.js says that is not here, so the two +; cannot drift. The name sets the colour: SAY_ERR_ red, SAY_WARN_ yellow, SAY_NOTE_ dim. +; shell.js is JavaScript by hand for one reason, measured: gen-js lowers declarations, not +; function bodies, so the terminal's logic cannot yet be a t27 spec compiled by this compiler. +pub const STAGES : [7]str = ["source", "lexer", "parser", "types", "hir", "backends", "tests"]; +pub const SAY_CHECK : [6]str = ["{bytes} bytes, {lines} lines", "{tokens} tokens, {kinds} kinds, {dropped} dropped", "{nodes} nodes, depth {depth}, {top} top-level", "{errors} errors", "{bytes} bytes", "{ok} of {all} emitted, {bytes} bytes"]; +pub const SAY_BANNER : str = "t27 terminal - {spec} - {count} backends"; +pub const SAY_NOTE_START : str = "type help, or tap a command below"; +pub const SAY_NOTE_BACKENDS : str = "backends: {backends}"; +pub const SAY_NOTE_ALSO : str = "also understood: {alias}, the local command of a backend that runs here, and the names of native tools"; +pub const SAY_SPEC_FILE : str = "the spec"; +pub const SAY_RUNS_HERE : str = "runs in this page"; +pub const SAY_FAILED : str = "failed"; +pub const SAY_ERR_WHICH_FILE : str = "{cmd}: which file? ls lists them"; +pub const SAY_ERR_NO_FILE : str = "{cmd}: {file}: no such file; ls lists them"; +pub const SAY_ERR_NOT_EMITTED : str = "{backend} emitted nothing: {error}"; +pub const SAY_SAVED : str = "asked your browser to download {file}, {bytes} bytes"; +pub const SAY_ERR_NO_BACKEND : str = "{cmd}: no backend called {name}; the backends are {backends}"; +pub const SAY_NOTE_GEN : str = "// {file}: {bytes} bytes of {lang}, compiled in {ms} ms by t27_compiler.wasm in this page"; +pub const SAY_ERR_RUNNER : str = "the test runner stopped: {error}"; +pub const SAY_NOTE_NO_TESTS : str = "this spec has no test or invariant block"; +pub const SAY_TESTS : str = "{pass} pass, {fail} fail, {skip} skip"; +pub const SAY_ASSERTS : str = "asserts: {count}"; +pub const SAY_NOTE_CHECKED : str = "compiled in {ms} ms, in this page"; +pub const SAY_NOTE_IMPORTING : str = "importing {file} into this page; its exports are printed, nothing is called"; +pub const SAY_IMPORT_OK : str = "import ok: {count} exports"; +pub const SAY_ERR_IMPORT : str = "import failed: {error}"; +pub const SAY_WARN_NOT_HERE : str = "{lang} does not run in a browser: this page has no {tool}. On a machine that has it:"; +pub const SAY_LOCAL_STEPS : [2]str = [" $ save {file}", " $ {command}"]; +pub const SAY_NOTE_NO_CAST : str = "there is no recording of t27c on this spec"; +pub const SAY_NOTE_REPLAY : str = "a recording of t27c on this spec, made on a real machine: the typing is staged, every byte printed is real"; +pub const SAY_WARN_NATIVE : str = "{tool} is not in your browser: this page has the t27 compiler and nothing else"; +pub const SAY_NOTE_NATIVE_RUN : str = "run {backend} prints the command for your machine"; +pub const SAY_ERR_T27C : str = "t27c {sub}: not in this terminal; it has {commands}"; +pub const SAY_ERR_NOT_FOUND : str = "{cmd}: command not found; help lists what this terminal does"; +pub const SAY_ERR_THREW : str = "{cmd} stopped: {error}"; +; Names a reader may type for a toolchain this page does not have, besides the first word of +; each BACKEND_RUN_LOCAL; and the real t27c's spelling of gen, which the terminal also takes. +pub const NATIVE_TOOLS : [7]str = ["gcc", "clang", "cargo", "verilator", "yosys", "deno", "bun"]; +pub const SHELL_GEN_ALIAS : str = "t27c gen-{backend}"; + ; --- Plays --------------------------------------------------------------------------------------- ; One entry per play, the same position in every array. SPEC is a path under public/t27/files/; ; CAST names a recording published at public/term//. @@ -109,8 +191,8 @@ pub const PLAY_IDS : [1]str = ["hello-world"]; pub const PLAY_SPECS : [1]str = ["specs/demos/hello_world.t27"]; pub const PLAY_CASTS : [1]str = ["t27c-hello-world"]; pub const PLAY_TITLES : [1]str = ["hello_world.t27, compiled inside the post"]; -pub const PLAY_DESCRIPTIONS : [1]str = ["The t27 compiler, as WebAssembly, running on one spec in your browser: lexer to seven backends in eight seconds, the spec's own tests, the emitted code, its Verilog read for synthesis, and a t27c run."]; -pub const PLAY_IMAGE_ALTS : [1]str = ["The t27 player: hello_world.t27 on the left, the compiler's stages on the right with token, node and backend counts, and tabs for tests, code, RTL and a terminal recording."]; +pub const PLAY_DESCRIPTIONS : [1]str = ["The t27 compiler, as WebAssembly, running on one spec in your browser: lexer to seven backends, the spec's own tests, the emitted code, its Verilog read for synthesis, and a terminal."]; +pub const PLAY_IMAGE_ALTS : [1]str = ["The t27 player: hello_world.t27 on the left, the compiler's stages on the right with token, node and backend counts, and tabs for tests, code, RTL and a terminal."]; test the_player_is_sixteen_by_nine { assert PLAYER_WIDTH * 9 == PLAYER_HEIGHT * 16; @@ -156,8 +238,43 @@ test the_site_bundle_is_generated_with_the_rest { assert SITE_MODULES[0] == "src/lib/highlight.ts"; } +test the_terminal_speaks_every_backend { + assert BACKENDS[0] == "zig"; + assert BACKENDS[2] == "rust"; + assert BACKENDS[4] == "verilog_hir"; + assert BACKEND_PAGES[4] == "verilog-hir"; + assert BACKEND_EXTS[4] == ".hir.v"; + assert BACKEND_NAMES[5] == "JavaScript"; +} + +test only_javascript_runs_in_the_browser { + assert RUNS_IN_BROWSER[0] == "js"; + assert BACKENDS[5] == RUNS_IN_BROWSER[0]; +} + +test the_typed_command_ends_inside_the_autoplay_limit { + assert SHELL_DEMO_MAX_CHARS * SHELL_TYPE_MS <= AUTOPLAY_LIMIT_MS; + assert SHELL_TYPE_MS > 0; +} + +test the_terminal_is_a_tab_and_gen_is_a_command { + assert TABS[5] == "term"; + assert TAB_LABELS[5] == "Term"; + assert SHELL_COMMANDS[0] == "help"; + assert SHELL_COMMANDS[5] == "t27c gen"; + assert SHELL_ARGS[5] == "[backend]"; + assert SHELL_CHIPS[0] == SHELL_COMMANDS[6]; +} + test the_first_play_is_hello_world { assert PLAY_COUNT == 1; assert PLAY_IDS[0] == "hello-world"; assert PLAY_SPECS[0] == "specs/demos/hello_world.t27"; } + +test the_terminal_reads_the_compile_stages { + assert STAGES[0] == "source"; + assert STAGES[5] == "backends"; + assert STAGES[6] == "tests"; + assert SAY_CHECK[3] == "{errors} errors"; +} diff --git a/apps/website/src/data/blog/bodies/a-terminal-for-seven-backends-in-an-x-post.ts b/apps/website/src/data/blog/bodies/a-terminal-for-seven-backends-in-an-x-post.ts new file mode 100644 index 0000000000..4f51a3d978 --- /dev/null +++ b/apps/website/src/data/blog/bodies/a-terminal-for-seven-backends-in-an-x-post.ts @@ -0,0 +1,230 @@ +import type { Block } from '../types' + +// Numbers here come from the recording public/term/play-terminal-gates/ (21 tests, 21 pass), +// from specs/x/player.t27 itself, and from one run of each native toolchain on the files the +// terminal hands out for hello_world (zig 0.16.0, Apple clang 21.0.0, rustc 1.98.1, +// Icarus Verilog 12.0, node, tsc 5.8.3), quoted in gHashTag/t27#6100. + +export const body: Block[] = [ + { + kind: 'p', + text: 'The t27 player, the page that compiles a spec inside an X post, now has a terminal. Type `t27c gen rust` and the t27 compiler, running as WebAssembly in the page, prints the Rust it emits for the spec in the editor. `t27c test` runs the spec\'s own tests. Each of the compiler\'s seven backends has its own share page, so a link opens the terminal already typing the command for that language. Nothing is sent anywhere: every line is printed by the compiler in the page, or by the JavaScript it emitted.', + }, + { + kind: 'terminal', + src: 'term/play-terminal-gates/session.cast', + share: 'https://t27.ai/term/play-terminal-gates/', + title: "t27 play · the terminal's own gates", + caption: 'The generator rebuilds the player from its spec and checks the spec\'s 14 tests (43 asserts), then 21 tests run every terminal command on hello_world: 21 pass, 0 fail. 71.3 s real, about 13 s shown; the prompt and the typing are staged, every byte printed is real.', + }, + { kind: 'h', text: 'Seven pages, one per backend' }, + { + kind: 'p', + text: 'Each page lives at `t27.ai/play/hello-world//` and has its own preview card, so a post shows which language the link opens. Opened with autoplay, the terminal types `t27c gen ` at 45 ms a character and runs it. The longest of the seven, `t27c gen verilog_hir`, is 20 characters, or 0.9 s of typing. The generator refuses a demo longer than 32 characters and checks that 32 characters at that pace stay inside the ten seconds X allows anything in a player card to start by itself (`AUTOPLAY_LIMIT_MS` in the spec).', + }, + { + kind: 'table', + head: ['Backend', 'Page', 'File', 'What `run` does'], + rows: [ + ['Zig', '`/zig/`', '`hello_world.zig`', 'prints `zig test hello_world.zig`'], + ['C', '`/c/`', '`hello_world.c`', 'prints `cc -std=c11 -c hello_world.c`'], + ['Rust', '`/rust/`', '`hello_world.rs`', 'prints `rustc --crate-type lib --edition 2021 hello_world.rs`'], + ['Verilog', '`/verilog/`', '`HelloWorld.v`', 'prints `iverilog -g2012 -o /dev/null HelloWorld.v`'], + ['Verilog (HIR)', '`/verilog-hir/`', '`HelloWorld.hir.v`', 'prints `iverilog -g2012 -o /dev/null HelloWorld.hir.v`'], + ['JavaScript', '`/js/`', '`hello_world.js`', 'imports it into the page and prints its 7 exports; calls nothing'], + ['TypeScript', '`/ts/`', '`hello_world.ts`', 'prints `tsc --noEmit --strict hello_world.ts`'], + ], + }, + { + kind: 'p', + text: 'Only JavaScript runs in a browser. For the other six backends, `run` says the page has no such toolchain, prints `save ` and the command for a machine that has one, and claims no result. The terminal also takes `t27c gen-rust`, the real t27c\'s spelling. `gcc`, `cargo` or `yosys` gets an answer saying the tool is not in the browser, instead of "command not found".', + }, + { kind: 'h', text: 'Every word is in the spec' }, + { + kind: 'p', + text: 'Everything the terminal says or knows is a constant in `specs/x/player.t27`:', + }, + { + kind: 'ul', + items: [ + 'the backends, their file extensions and their local commands;', + 'the twelve commands and their help lines;', + 'the compile stages;', + 'each message.', + ], + }, + { + kind: 'p', + text: 'The JavaScript that runs it, `public/play/shell.js`, holds no English of its own: it fills `{names}` into the spec\'s templates and lays out columns. The generator enforces this in both directions. It refuses:', + }, + { + kind: 'ul', + items: [ + 'a command with no handler, or a handler with no command;', + 'a `SAY_` constant the terminal never says, or a phrase it says that the spec does not define;', + 'a backend list that differs from the set the compiler actually emits.', + ], + }, + { + kind: 'code', + text: 'pub const BACKENDS : [7]str = ["zig", "c", "rust", "verilog", "verilog_hir", "js", "ts"];\n; The backends whose output this page runs itself.\npub const RUNS_IN_BROWSER : [1]str = ["js"];\npub const SAY_WARN_NOT_HERE : str = "{lang} does not run in a browser: this page has no {tool}. On a machine that has it:";', + }, + { kind: 'h', text: 'Why the logic is still JavaScript' }, + { + kind: 'p', + text: 'The words are t27, and the behaviour is not yet: dispatching a line, filling a template, choosing a file name. That part is 361 hand-written lines, for a measured reason. `gen-js` lowers declarations only, by design. The issue that created it, t27#4471, says: "The emitted module is data. Nothing in it may run." That rule is right for a spec that describes a server. For a spec that is a terminal, it means the behaviour has to live beside the spec. t27#6101 asks for an opt-in lowering of function bodies that leaves today\'s output byte for byte unchanged. Once it exists, the dispatch and the templates move into `player.t27`, and `shell.js` shrinks to DOM glue.', + }, + { kind: 'h', text: 'We ran the six commands ourselves' }, + { + kind: 'p', + text: 'A terminal that hands out a file and a command should know what the command does. We ran each one on the files the terminal gives for `hello_world.t27`, as committed, on one macOS machine:', + }, + { + kind: 'table', + head: ['Backend', 'Command', 'Exit', 'First error'], + rows: [ + ['Zig', '`zig test`', '1', '`hello_world.zig:33:23`: error: use of undeclared identifier \'cast\''], + ['C', '`cc -std=c11 -c`', '1', '`hello_world.c:57:22`: error: call to undeclared function \'cast\''], + ['Rust', '`rustc --crate-type lib`', '1', '`error[E0425]`: cannot find function `cast` in this scope'], + ['Verilog', '`iverilog -g2012`', '2', '`HelloWorld.v:79`: error: No function named `cast` found in this context'], + ['Verilog (HIR)', '`iverilog -g2012`', '2', '`HelloWorld.hir.v:15`: syntax error (the port `config` is not escaped)'], + ['JavaScript', '`node`', '0', 'none'], + ['TypeScript', '`tsc --noEmit --strict`', '0', 'none'], + ], + }, + { + kind: 'p', + text: 'Line 47 of the spec computes an area with `cast(config.width) * cast(config.height)`, and nothing declares `cast`. The typechecker accepts the call. The page\'s own test evaluator treats it as a conversion, so in the browser `t27c test` reports 4 pass, 0 fail, 0 skip. Zig, C and Rust refuse the same line. JavaScript and TypeScript pass only because they emit declarations, not the function. With the line rewritten as `(config.width as u16) * (config.height as u16)`, six of the seven pass: `zig test` reports "All 2 tests passed", and the HIR Verilog still fails on its `config` port. Both are filed, as t27#6100 and t27#5966. The terminal does not hide this: it prints the command and never claims the result.', + }, + { kind: 'h', text: 'A page git would have dropped' }, + { + kind: 'p', + text: 'The repository ignores every directory called `zig/`, for downloaded Zig toolchains. That rule also matched `public/play/hello-world/zig/`. The build would have passed, the page would have existed on the machine that made it, and the published site would have answered 404 for the Zig link. One negation line in `.gitignore` fixes it. A test now asks `git check-ignore` about every backend page, with a control path under the same rule that must still be ignored.', + }, + { kind: 'h', text: 'Before it goes on X' }, + { + kind: 'p', + text: 'X\'s player cards may not collect data entry. The terminal takes typed commands, though nothing leaves the page. Whether a terminal page is posted as a player card or only as a link is the owner\'s decision. Nothing has been posted.', + }, + { kind: 'h', text: 'Try it' }, + { + kind: 'ul', + items: [ + 'The player, with the terminal as its last tab: t27.ai/play/hello-world/', + 'The terminal opened on one backend: t27.ai/play/hello-world/rust/ (or `/zig/`, `/c/`, `/verilog/`, `/verilog-hir/`, `/js/`, `/ts/`)', + 'In the terminal: `help`, `t27c test`, `t27c gen zig`, `run js`, `run rust`, `t27c check`', + ], + }, +] + +export const ruBody: Block[] = [ + { + kind: 'p', + text: 'В плеере t27, на странице, которая компилирует спеку прямо внутри поста в X, теперь есть терминал. Наберите `t27c gen rust`, и компилятор t27, работающий в странице как WebAssembly, напечатает Rust, который он выдаёт для спеки в редакторе. `t27c test` запускает собственные тесты спеки. У каждого из семи бэкендов компилятора своя страница для ссылок, так что ссылка открывает терминал, который уже набирает команду для этого языка. Никуда ничего не отправляется: каждую строку печатает компилятор в странице или JavaScript, который он выдал.', + }, + { + kind: 'terminal', + src: 'term/play-terminal-gates/session.cast', + share: 'https://t27.ai/term/play-terminal-gates/', + title: "t27 play · the terminal's own gates", + caption: 'Генератор пересобирает плеер из спеки и проверяет её 14 тестов (43 assert), затем 21 тест прогоняет каждую команду терминала на hello_world: 21 прошёл, 0 упало. 71,3 с в реальности, показано около 13 с; приглашение и набор постановочные, каждый напечатанный байт настоящий.', + }, + { kind: 'h', text: 'Семь страниц, по одной на бэкенд' }, + { + kind: 'p', + text: 'Каждая страница лежит по адресу `t27.ai/play/hello-world//` и имеет свою карточку-превью, так что пост показывает, какой язык открывает ссылка. При автозапуске терминал набирает `t27c gen ` по 45 мс на символ и выполняет команду. Самая длинная из семи, `t27c gen verilog_hir`, занимает 20 символов, то есть 0,9 с набора. Генератор отвергает демо длиннее 32 символов и проверяет, что 32 символа в этом темпе укладываются в десять секунд, которые X разрешает чему-либо в карточке-плеере запускаться само (`AUTOPLAY_LIMIT_MS` в спеке).', + }, + { + kind: 'table', + head: ['Бэкенд', 'Страница', 'Файл', 'Что делает `run`'], + rows: [ + ['Zig', '`/zig/`', '`hello_world.zig`', 'печатает `zig test hello_world.zig`'], + ['C', '`/c/`', '`hello_world.c`', 'печатает `cc -std=c11 -c hello_world.c`'], + ['Rust', '`/rust/`', '`hello_world.rs`', 'печатает `rustc --crate-type lib --edition 2021 hello_world.rs`'], + ['Verilog', '`/verilog/`', '`HelloWorld.v`', 'печатает `iverilog -g2012 -o /dev/null HelloWorld.v`'], + ['Verilog (HIR)', '`/verilog-hir/`', '`HelloWorld.hir.v`', 'печатает `iverilog -g2012 -o /dev/null HelloWorld.hir.v`'], + ['JavaScript', '`/js/`', '`hello_world.js`', 'импортирует его в страницу и печатает 7 экспортов; ничего не вызывает'], + ['TypeScript', '`/ts/`', '`hello_world.ts`', 'печатает `tsc --noEmit --strict hello_world.ts`'], + ], + }, + { + kind: 'p', + text: 'В браузере работает только JavaScript. Для остальных шести бэкендов `run` говорит, что такого инструмента в странице нет, печатает `save ` и команду для машины, где он есть, и не заявляет никакого результата. Терминал понимает и `t27c gen-rust`, написание настоящего t27c. На `gcc`, `cargo` или `yosys` он отвечает, что этого инструмента в браузере нет, а не «command not found».', + }, + { kind: 'h', text: 'Каждое слово — в спеке' }, + { + kind: 'p', + text: 'Всё, что терминал говорит или знает, записано константами в `specs/x/player.t27`:', + }, + { + kind: 'ul', + items: [ + 'бэкенды, расширения их файлов и локальные команды;', + 'двенадцать команд и строки их справки;', + 'стадии компиляции;', + 'каждое сообщение.', + ], + }, + { + kind: 'p', + text: 'В JavaScript, который всё это исполняет, `public/play/shell.js`, нет ни одной собственной английской фразы: он подставляет `{names}` в шаблоны спеки и выравнивает колонки. Генератор проверяет это в обе стороны. Он отвергает:', + }, + { + kind: 'ul', + items: [ + 'команду без обработчика или обработчик без команды;', + 'константу `SAY_`, которую терминал никогда не произносит, или фразу, которую он произносит, а спека не определяет;', + 'список бэкендов, отличающийся от набора, который компилятор действительно выдаёт.', + ], + }, + { + kind: 'code', + text: 'pub const BACKENDS : [7]str = ["zig", "c", "rust", "verilog", "verilog_hir", "js", "ts"];\n; The backends whose output this page runs itself.\npub const RUNS_IN_BROWSER : [1]str = ["js"];\npub const SAY_WARN_NOT_HERE : str = "{lang} does not run in a browser: this page has no {tool}. On a machine that has it:";', + }, + { kind: 'h', text: 'Почему логика пока на JavaScript' }, + { + kind: 'p', + text: 'Слова уже на t27, а поведение пока нет: разбор строки, подстановка в шаблон, выбор имени файла. Эта часть занимает 361 строку, написанную вручную, и причина измерена. `gen-js` по замыслу переводит только объявления. Задача, которая его создала, t27#4471, говорит: «The emitted module is data. Nothing in it may run.» Для спеки, описывающей сервер, это правило верно. Для спеки, которая сама является терминалом, оно значит, что поведение должно жить рядом со спекой. t27#6101 просит включаемый по флагу перевод тел функций, при котором сегодняшний вывод не меняется ни на байт. Когда он появится, разбор команд и шаблоны переедут в `player.t27`, а в `shell.js` останется только связка с DOM.', + }, + { kind: 'h', text: 'Шесть команд мы запустили сами' }, + { + kind: 'p', + text: 'Терминал, который выдаёт файл и команду, должен знать, что эта команда сделает. Мы запустили каждую на файлах, которые терминал выдаёт для `hello_world.t27` в том виде, в каком он лежит в репозитории, на одной машине с macOS:', + }, + { + kind: 'table', + head: ['Бэкенд', 'Команда', 'Код выхода', 'Первая ошибка'], + rows: [ + ['Zig', '`zig test`', '1', '`hello_world.zig:33:23`: error: use of undeclared identifier \'cast\''], + ['C', '`cc -std=c11 -c`', '1', '`hello_world.c:57:22`: error: call to undeclared function \'cast\''], + ['Rust', '`rustc --crate-type lib`', '1', '`error[E0425]`: cannot find function `cast` in this scope'], + ['Verilog', '`iverilog -g2012`', '2', '`HelloWorld.v:79`: error: No function named `cast` found in this context'], + ['Verilog (HIR)', '`iverilog -g2012`', '2', '`HelloWorld.hir.v:15`: syntax error (порт `config` не экранирован)'], + ['JavaScript', '`node`', '0', 'нет'], + ['TypeScript', '`tsc --noEmit --strict`', '0', 'нет'], + ], + }, + { + kind: 'p', + text: 'Строка 47 спеки считает площадь как `cast(config.width) * cast(config.height)`, а `cast` нигде не объявлен. Проверка типов этот вызов пропускает. Собственный вычислитель тестов в странице считает его преобразованием, поэтому в браузере `t27c test` сообщает 4 pass, 0 fail, 0 skip. Zig, C и Rust ту же строку отвергают. JavaScript и TypeScript проходят только потому, что выдают объявления, а не саму функцию. Если переписать строку как `(config.width as u16) * (config.height as u16)`, проходят шесть из семи: `zig test` сообщает «All 2 tests passed», а HIR-Verilog по-прежнему падает на порту `config`. Обе проблемы заведены, это t27#6100 и t27#5966. Терминал этого не прячет: он печатает команду и никогда не заявляет её результат.', + }, + { kind: 'h', text: 'Страница, которую git потерял бы' }, + { + kind: 'p', + text: 'Репозиторий игнорирует любой каталог с именем `zig/`: это место для скачанных наборов Zig. Под это же правило попал и `public/play/hello-world/zig/`. Сборка бы прошла, страница существовала бы на машине, которая её собрала, а опубликованный сайт отвечал бы 404 на ссылку для Zig. Это исправляет одна строка-исключение в `.gitignore`. Теперь тест спрашивает `git check-ignore` о каждой странице бэкенда, с контрольным путём под тем же правилом, который по-прежнему обязан игнорироваться.', + }, + { kind: 'h', text: 'Прежде чем это попадёт в X' }, + { + kind: 'p', + text: 'Карточкам-плеерам X запрещено собирать ввод данных. Терминал принимает набранные команды, хотя ничего не покидает страницу. Публиковать ли страницу терминала как карточку-плеер или только как ссылку, решает владелец. Ничего не опубликовано.', + }, + { kind: 'h', text: 'Попробовать' }, + { + kind: 'ul', + items: [ + 'Плеер, терминал на последней вкладке: t27.ai/play/hello-world/', + 'Терминал, открытый на одном бэкенде: t27.ai/play/hello-world/rust/ (или `/zig/`, `/c/`, `/verilog/`, `/verilog-hir/`, `/js/`, `/ts/`)', + 'В терминале: `help`, `t27c test`, `t27c gen zig`, `run js`, `run rust`, `t27c check`', + ], + }, +] diff --git a/apps/website/src/data/blog/index.ts b/apps/website/src/data/blog/index.ts index 942b40f618..1794389241 100644 --- a/apps/website/src/data/blog/index.ts +++ b/apps/website/src/data/blog/index.ts @@ -2,6 +2,47 @@ import type { PostMeta } from './types' /** Индекс блога: список и метаданные без тяжёлых тел публикаций. */ export const postsIndex: PostMeta[] = [ + { + slug: "a-terminal-for-seven-backends-in-an-x-post", + title: "A terminal for t27's seven backends, inside an X post", + summary: "[one spec, hello_world; only JavaScript runs in the browser; the native results are one macOS machine on one day; nothing posted on X] The t27 player now has a terminal: the compiler, as WebAssembly in the page, prints what each of its seven backends emits and runs the spec's tests. Each backend has its own share page. Every word the terminal says lives in the spec. Run natively, hello_world as committed builds in two of the seven languages, and the post says why.", + date: "2026-10-04", + readingMinutes: 6, + tags: ["t27", "Compiler", "Terminal"], + receipts: [ + { label: "The recording shown in the post: the terminal's own gates, 21 pass, 0 fail", href: "https://t27.ai/term/play-terminal-gates/" }, + { label: "The player, with the terminal as its last tab", href: "https://t27.ai/play/hello-world/" }, + { label: "trinity#1366: this work, the terminal, the seven pages and this post", href: "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/gHashTag/trinity/pull/1366" }, + { label: "trinity#1365: a terminal for every t27 backend, one share page per backend (the issue this work closes)", href: "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/gHashTag/trinity/issues/1365" }, + { label: "trinity#1359: typed code shows at once in the spec editor (the base of this work) · MERGED 2026-10-04", href: "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/gHashTag/trinity/pull/1359" }, + { label: "trinity#1340: the X player card, generated from specs/x/player.t27 · MERGED 2026-10-04", href: "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/gHashTag/trinity/pull/1340" }, + { label: "t27#6100: hello_world.t27 calls cast(), which nothing declares · OPEN", href: "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/gHashTag/t27/issues/6100" }, + { label: "t27#6101: gen-js, an opt-in lowering of fn bodies · OPEN", href: "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/gHashTag/t27/issues/6101" }, + { label: "t27#5966: gen-verilog and gen-verilog-hir output for hello_world is refused by iverilog · OPEN", href: "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/gHashTag/t27/issues/5966" }, + { label: "t27#4471: the gen-js backend, declarations only, by design · CLOSED 2026-09-20", href: "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/gHashTag/t27/issues/4471" }, + ], + openQuestions: [ + "Only JavaScript runs in the browser. For the other six backends the page prints a command and claims nothing; the native results in the post are one macOS machine on one day (zig 0.16.0, Apple clang 21.0.0, rustc 1.98.1, Icarus Verilog 12.0, tsc 5.8.3).", + "hello_world as committed does not build natively in five of the seven languages, and the terminal hands out those files anyway, with the command. Until t27#6100 and t27#5966 are fixed, a reader who runs them gets the errors in the table.", + "The page's test evaluator treats the undeclared cast() as a conversion, so in the browser the spec's tests pass on a line that Zig, C and Rust refuse.", + "The terminal's behaviour is 361 hand-written lines of JavaScript; only its words and facts are in the spec. Moving the behaviour waits on t27#6101, which changes what gen-js promises and may be declined.", + "X's player cards may not collect data entry. Whether a terminal page can be posted as a player card is not decided, and nothing has been posted.", + "One spec has terminal pages. Every other play would need its own seven.", + ], + published: true, + ru: { + title: "Терминал для семи бэкендов t27 внутри поста в X", + summary: "[одна спека, hello_world; в браузере работает только JavaScript; нативные результаты получены на одной машине с macOS в один день; в X ничего не опубликовано] В плеере t27 теперь есть терминал: компилятор, работающий в странице как WebAssembly, печатает то, что выдаёт каждый из семи его бэкендов, и запускает тесты спеки. У каждого бэкенда своя страница для ссылок. Каждое слово терминала живёт в спеке. При нативной сборке hello_world в нынешнем виде собирается на двух языках из семи, и пост объясняет почему.", + openQuestions: [ + "В браузере работает только JavaScript. Для остальных шести бэкендов страница печатает команду и ничего не заявляет; нативные результаты в посте получены на одной машине с macOS в один день (zig 0.16.0, Apple clang 21.0.0, rustc 1.98.1, Icarus Verilog 12.0, tsc 5.8.3).", + "hello_world в нынешнем виде не собирается нативно на пяти языках из семи, а терминал всё равно выдаёт эти файлы вместе с командой. Пока t27#6100 и t27#5966 не исправлены, читатель, который их запустит, получит ошибки из таблицы.", + "Вычислитель тестов в странице считает необъявленный cast() преобразованием, поэтому в браузере тесты спеки проходят на строке, которую Zig, C и Rust отвергают.", + "Поведение терминала — это 361 строка JavaScript, написанная вручную; в спеке только его слова и факты. Перенос поведения ждёт t27#6101, который меняет то, что обещает gen-js, и может быть отклонён.", + "Карточкам-плеерам X запрещено собирать ввод данных. Можно ли публиковать страницу терминала как карточку-плеер, не решено, и ничего не опубликовано.", + "Страницы терминала есть у одной спеки. Каждому другому плееру понадобятся свои семь.", + ], + }, + }, { slug: "t27c-compile-time-and-a-backend-without-llvm", title: "Where t27c's compile time goes, and a first backend without LLVM", diff --git a/apps/website/src/data/blog/posts.ts b/apps/website/src/data/blog/posts.ts index d312fda5f6..9dfeb62cb6 100644 --- a/apps/website/src/data/blog/posts.ts +++ b/apps/website/src/data/blog/posts.ts @@ -1,3 +1,4 @@ +import { body as body_a_terminal_for_seven_backends_in_an_x_post, ruBody as ruBody_a_terminal_for_seven_backends_in_an_x_post } from './bodies/a-terminal-for-seven-backends-in-an-x-post' import { body as body_t27c_compile_time_and_a_backend_without_llvm, ruBody as ruBody_t27c_compile_time_and_a_backend_without_llvm } from './bodies/t27c-compile-time-and-a-backend-without-llvm' import { body as body_three_bits_each_necessary, ruBody as ruBody_three_bits_each_necessary } from './bodies/three-bits-each-necessary' import { body as body_tri_cast_from_command_to_card, ruBody as ruBody_tri_cast_from_command_to_card } from './bodies/tri-cast-from-command-to-card' @@ -87,6 +88,7 @@ import { body as body_real_value_in_integer_container, ruBody as ruBody_real_val import { body as body_one_commit_nine_workflow_outcomes, ruBody as ruBody_one_commit_nine_workflow_outcomes } from './bodies/one-commit-nine-workflow-outcomes' const bodies: Record = { + 'a-terminal-for-seven-backends-in-an-x-post': { body: body_a_terminal_for_seven_backends_in_an_x_post, ruBody: ruBody_a_terminal_for_seven_backends_in_an_x_post }, 't27c-compile-time-and-a-backend-without-llvm': { body: body_t27c_compile_time_and_a_backend_without_llvm, ruBody: ruBody_t27c_compile_time_and_a_backend_without_llvm }, 'three-bits-each-necessary': { body: body_three_bits_each_necessary, ruBody: ruBody_three_bits_each_necessary }, 'tri-cast-from-command-to-card': { body: body_tri_cast_from_command_to_card, ruBody: ruBody_tri_cast_from_command_to_card },