From 01bb083f5231b9949ad83973f8c12a1af02c13e1 Mon Sep 17 00:00:00 2001 From: Taras Mankovski <74687+taras@users.noreply.github.com> Date: Wed, 2 Sep 2026 12:12:41 -0400 Subject: [PATCH 1/2] =?UTF-8?q?=E2=9C=A8=20Describe=20terminal=20grids=20a?= =?UTF-8?q?s=20executable=20document=20structure=20(#729)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `` and `` become reserved core structural syntax. This Story owns the authored structure alone: the grammar, the placement rules, and the row-major layout a grid derives. No terminal authority, provider, pane execution, shell, durability or replay is built here. The grid's closed props hold one required positive-integer `columns`; a pane's hold one required non-empty `title`. Titles are display labels and identify nothing — a pane's structural identity is its ordinal among the grid's direct children, and rows are derived in authored row-major order with the last row left short. `structural-rules.ts` decides what the source says, so expansion and document validation cannot disagree about it: only whitespace and direct `` panes may appear in a grid, and an empty grid, direct text, a non-pane element, a control structure that would produce panes, a nested grid, and a pane written anywhere else are refused. `terminal-grid.ts` places the panes once `columns` and each `title` are known. A grid the grammar accepts runs until a terminal provider would be asked for one. This build installs none, so it refuses there, before any pane body expands or a default shell starts, and carries the layout it derived beside the refusal. Evidence rows TG1-TG4: the new structural suite owns TG1, TG2 and TG4; the catalog, validation and `xmd syntax` suites own TG3. --- packages/cli/tests/syntax-cli.test.ts | 46 ++ packages/core/src/document-validation.ts | 24 + packages/core/src/expand.ts | 156 +++++- packages/core/src/structural-rules.ts | 244 +++++++++ packages/core/src/structural.ts | 23 + packages/core/src/terminal-grid.ts | 69 +++ .../core/tests/document-validation.test.ts | 154 ++++++ packages/core/tests/syntax-catalog.test.ts | 76 +++ .../tests/terminal-grid-structure.test.ts | 511 ++++++++++++++++++ 9 files changed, 1301 insertions(+), 2 deletions(-) create mode 100644 packages/core/src/terminal-grid.ts create mode 100644 packages/core/tests/terminal-grid-structure.test.ts diff --git a/packages/cli/tests/syntax-cli.test.ts b/packages/cli/tests/syntax-cli.test.ts index d47fff3f..b1af9f5e 100644 --- a/packages/cli/tests/syntax-cli.test.ts +++ b/packages/cli/tests/syntax-cli.test.ts @@ -266,6 +266,36 @@ describe("Tier SX — the run profile the command describes", () => { ]); }); + it("TG3: describes both terminal-grid constructs without probing for a terminal", function* () { + // Whatever this runtime can or cannot open, the language is the same, so + // the one boundary a capability probe would cross is a trap here. + const catalog = yield* scoped(function* () { + yield* API.Process.around({ + // deno-lint-ignore require-yield + *exec([options]): Operation { + throw new Error(`describing the syntax ran ${JSON.stringify(options.command)}`); + }, + }); + return yield* syntaxCatalog([]); + }); + const [structural, builtIn] = catalog.categories; + + const grid = structural.entries.find((entry) => entry.name === "Terminal.Grid"); + const pane = structural.entries.find((entry) => entry.name === "Terminal"); + expect(grid?.origin).toEqual({ kind: "structural", construct: "Terminal.Grid" }); + expect(pane?.origin).toEqual({ kind: "structural", construct: "Terminal" }); + expect(grid?.syntax).toEqual([""]); + expect(pane?.syntax).toEqual([ + '', + '', + ]); + expect(grid?.description ?? "").not.toBe(""); + expect(pane?.description ?? "").not.toBe(""); + // Reserved syntax, so neither name is a component this profile offers. + expect(names(builtIn.entries)).not.toContain("Terminal.Grid"); + expect(names(builtIn.entries)).not.toContain("Terminal"); + }); + it("SX3: describes without minting an execution claimant", function* () { const catalog = yield* syntaxCatalog([]); const session = catalog.categories[1].entries.find((entry) => entry.name === "Session"); @@ -422,6 +452,22 @@ describe("Tier SX — the command line", { sanitizeOps: false, sanitizeResources }); }); + it("TG3: prints both terminal-grid constructs, in markdown and in JSON", function* () { + yield* useWorkspace(WORKSPACE, function* (cwd) { + const markdown = yield* runCli(["syntax"], { cwd }).expect(); + expect(markdown.stdout).toContain("### ``"); + expect(markdown.stdout).toContain("### ``"); + expect(markdown.stdout).toContain(""); + expect(markdown.stdout).toContain(''); + expect(markdown.stdout).toContain(''); + + const json = yield* runCli(["syntax", "--json"], { cwd }).expect(); + const structural = parseCatalog(json.stdout).categories[0].entries; + expect(names(structural)).toContain("Terminal.Grid"); + expect(names(structural)).toContain("Terminal"); + }); + }); + it("SX12: succeeds with the defaults in a package tree full of directory links", function* () { yield* useWorkspace( { diff --git a/packages/core/src/document-validation.ts b/packages/core/src/document-validation.ts index 5ec432ff..cb85f51d 100644 --- a/packages/core/src/document-validation.ts +++ b/packages/core/src/document-validation.ts @@ -74,7 +74,9 @@ import { strayCaseMessage, strayElseMessage, strayStructuralMessage, + strayTerminalMessage, switchStructure, + terminalGridStructure, } from "./structural-rules.ts"; import type { StructuralViolation } from "./structural-rules.ts"; import type { @@ -314,6 +316,8 @@ interface LexicalContext { readonly insideIf: boolean; /** Whether a `` in this source lexically encloses this point. */ readonly insideSwitch: boolean; + /** Whether a `` in this source lexically encloses this point. */ + readonly insideTerminalGrid: boolean; /** Whether the immediate parent is an ``. */ readonly underAnswers: boolean; } @@ -492,6 +496,7 @@ class ValidationState { insideLoop: false, insideIf: false, insideSwitch: false, + insideTerminalGrid: false, underAnswers: false, }); } @@ -1083,6 +1088,24 @@ class ValidationState { return context.insideSwitch ? [] : [{ code: "structural-usage-invalid", source: "Case", message: strayCaseMessage() }]; + case "Terminal.Grid": + // The whole layout is decided from source, so every pane's own mistake + // is reported where it was written — and so is a construct written + // below the grid that the grid does not lay out. + return terminalGridStructure(segment).violations; + case "Terminal": + // A well-placed `` is its grid's, and one placed wrongly + // under a grid is already reported by that grid's own structure. What + // is left is a pane with no grid above it at all. + return context.insideTerminalGrid + ? [] + : [ + { + code: "structural-usage-invalid", + source: "Terminal", + message: strayTerminalMessage(), + }, + ]; case "Else": // A well-placed `` is its ``'s, and one placed wrongly under // an `` is already reported by that ``'s own structure. What is @@ -1253,6 +1276,7 @@ function childContext(segment: ComponentElement, context: LexicalContext): Lexic insideLoop: context.insideLoop || segment.name === "Loop", insideIf: context.insideIf || segment.name === "If", insideSwitch: context.insideSwitch || segment.name === "Switch", + insideTerminalGrid: context.insideTerminalGrid || segment.name === "Terminal.Grid", underAnswers: segment.name === "Answers", }; } diff --git a/packages/core/src/expand.ts b/packages/core/src/expand.ts index a8359dd2..e92df89e 100644 --- a/packages/core/src/expand.ts +++ b/packages/core/src/expand.ts @@ -13,7 +13,7 @@ * middleware installation) execute before children's code blocks. */ -import { ensure, Err, scoped, useScope, withResolvers } from "effection"; +import { ensure, Err, Ok, scoped, useScope, withResolvers } from "effection"; import type { Operation, Result } from "effection"; import type { FunctionComponent, @@ -57,9 +57,17 @@ import { strayCaseMessage, strayElseMessage, strayStructuralMessage, + strayTerminalMessage, switchStructure, + terminalColumns, + terminalColumnsMissingMessage, + terminalGridStructure, + terminalTitle, + terminalTitleMissingMessage, } from "./structural-rules.ts"; -import type { StructuralViolation, SwitchCase } from "./structural-rules.ts"; +import type { StructuralViolation, SwitchCase, TerminalPane } from "./structural-rules.ts"; +import { terminalGridLayout } from "./terminal-grid.ts"; +import type { PlacedPane } from "./terminal-grid.ts"; import { asBindingViolation, asExpressionViolation, @@ -1170,6 +1178,28 @@ function* expandListSegments( break; } + if (segment.name === "Terminal.Grid") { + // No raise() here, like the branches above: expandTerminalGrid + // reports every error it creates. + yield* expandTerminalGrid(segment, result); + break; + } + + if (segment.name === "Terminal") { + // A well-placed is consumed by its and + // never expanded on its own. Reaching this branch means the pane sits + // outside every grid, so it names no component and is diagnosed + // rather than resolved from the filesystem. + result.push( + yield* raise({ + type: "error", + message: positioned(strayTerminalMessage(), segment), + source: "Terminal", + }), + ); + break; + } + if (segment.name === "Break") { result.push(...(yield* expandBreak(segment, loop))); break; @@ -2025,6 +2055,128 @@ function* expandSwitch( ); } +function terminalGridError(segment: ComponentElement, message: string): ErrorSegment { + return { type: "error", message: positioned(message, segment), source: "Terminal.Grid" }; +} + +function terminalPaneError(segment: ComponentElement, message: string): ErrorSegment { + return { type: "error", message: positioned(message, segment), source: "Terminal" }; +} + +/** + * The value one prop of a terminal-grid construct produced, or why evaluating + * it failed. A missing prop is `undefined`, which is also what an expression + * evaluating to `undefined` leaves behind (§6.5) — absence either way, and the + * caller says what its construct requires instead. + */ +function* resolveStructuralProp( + segment: ComponentElement, + construct: string, + prop: string, +): Operation> { + const expression = segment.expressions[prop]; + if (expression === undefined) { + return Ok(segment.props[prop]); + } + try { + const resolved = yield* resolveExpressionProps( + {}, + { [prop]: expression }, + construct, + segment.projectedEnv, + ); + return Ok(resolved[prop]); + } catch (error) { + return Err(error instanceof Error ? error : new Error(String(error))); + } +} + +/** + * Open the grid the author wrote (spec §6.21). + * + * The whole layout is decided before anything opens: the panes and their forms + * from source, then `columns` and each pane's `title` from the values the + * document computes. Only once the concrete grid is complete is a terminal + * provider anything's business — and this build has none, so the grid refuses + * there. Nothing beneath a pane has expanded and no shell has started when it + * does, which is what makes the refusal a closed one rather than a partial grid + * left behind. + */ +function* expandTerminalGrid(segment: ComponentElement, owner: Segment[]): Operation { + const structure = terminalGridStructure(segment); + if (structure.violations.length > 0) { + for (const violation of structure.violations) { + owner.push(yield* raise(structuralErrorSegment(violation, segment))); + } + return; + } + + const columnsValue = yield* resolveStructuralProp(segment, "Terminal.Grid", "columns"); + if (!columnsValue.ok) { + owner.push(yield* raise(terminalGridError(segment, columnsValue.error.message))); + return; + } + if (columnsValue.value === undefined) { + owner.push(yield* raise(terminalGridError(segment, terminalColumnsMissingMessage()))); + return; + } + const columns = terminalColumns(columnsValue.value); + if (!columns.ok) { + owner.push(yield* raise(terminalGridError(segment, columns.error.message))); + return; + } + + const placed: PlacedPane[] = []; + for (const pane of structure.panes) { + const title = yield* resolvePaneTitle(pane); + if (!title.ok) { + owner.push(yield* raise(terminalPaneError(pane.element, title.error.message))); + return; + } + placed.push({ title: title.value, form: pane.form }); + } + + const layout = terminalGridLayout(columns.value, placed); + owner.push( + yield* raise({ + type: "error", + message: positioned(noTerminalProviderMessage(), segment), + source: "Terminal.Grid", + // The grid the author asked for, carried beside the sentence so an + // assertion is about the layout that was derived rather than about the + // wording of a refusal. + cause: { + layout: { + columns: layout.columns, + rows: layout.rows, + cells: layout.cells.map((cell) => ({ ...cell })), + }, + }, + }), + ); +} + +/** The label one pane displays, from the value its own `title` prop produced. */ +function* resolvePaneTitle(pane: TerminalPane): Operation> { + const value = yield* resolveStructuralProp(pane.element, "Terminal", "title"); + if (!value.ok) { + return value; + } + if (value.value === undefined) { + return Err(new Error(terminalTitleMissingMessage())); + } + return terminalTitle(value.value); +} + +/** What a complete grid says on a host where nothing can open one. */ +function noTerminalProviderMessage(): string { + return ( + "no terminal provider opened this grid. A host installs the terminal-grid capability " + + "explicitly, and this one installs none, so no pane expanded its content and no default " + + "shell started." + ); +} + function loopError(segment: ComponentElement, message: string): ErrorSegment { return { type: "error", message: positioned(message, segment), source: "Loop" }; } diff --git a/packages/core/src/structural-rules.ts b/packages/core/src/structural-rules.ts index e343ad87..24aa676b 100644 --- a/packages/core/src/structural-rules.ts +++ b/packages/core/src/structural-rules.ts @@ -1034,3 +1034,247 @@ export function answerViolations(segment: ComponentElement): StructuralViolation } return found; } + +const TERMINAL_GRID_PROPS = new Set(["columns"]); +const TERMINAL_PROPS = new Set(["title"]); + +/** What a `` written outside the grid that lays it out says. */ +export function strayTerminalMessage(): string { + return ( + " must be a direct child of . is reserved: it never " + + "resolves a component, and only the grid it belongs to can place it." + ); +} + +/** What a `` written inside another grid says. */ +export function nestedTerminalGridMessage(): string { + return ( + " cannot be written inside another . A grid lays out the " + + "panes it is written with, so one pane cannot become a grid of its own." + ); +} + +/** + * How many columns a grid lays its panes across, or why `columns` rejects it. + * + * The same rule wherever the value came from: a literal is checked while the + * document is only being read, and an expression's answer is checked here too + * once expansion has evaluated it. + */ +export function terminalColumns(columns: Json): Result { + if (typeof columns !== "number") { + return Err( + new Error( + `Prop "columns" on must be a positive integer, not ${jsonKind(columns)}.`, + ), + ); + } + if (!Number.isInteger(columns) || columns < 1) { + return Err( + new Error( + `Prop "columns" on must be a positive integer. Got: ` + + `${JSON.stringify(columns)}.`, + ), + ); + } + return Ok(columns); +} + +/** What a `` naming no column count at all says. */ +export function terminalColumnsMissingMessage(): string { + return ' requires a "columns" prop (a positive integer).'; +} + +/** The label one pane displays, or why `title` rejects it. */ +export function terminalTitle(title: Json): Result { + if (typeof title !== "string") { + return Err( + new Error(`Prop "title" on must be a non-empty string, not ${jsonKind(title)}.`), + ); + } + if (title.length === 0) { + return Err(new Error('Prop "title" on must be a non-empty string. Got: "".')); + } + return Ok(title); +} + +/** What a `` naming no title at all says. */ +export function terminalTitleMissingMessage(): string { + return ' requires a "title" prop (the label the pane displays).'; +} + +/** One pane a grid lays out, and where it sat among its siblings. */ +export interface TerminalPane { + readonly element: ComponentElement; + /** The child index the pane was written at. */ + readonly index: number; + /** + * The pane's structural identity: its position among the grid's panes, + * counting from zero. A title is a display label and identifies nothing. + */ + readonly ordinal: number; + /** Whether the pane runs the markdown it holds or the host's default shell. */ + readonly form: "paired" | "self-closing"; +} + +/** How a `` body divides into panes, and what the division got wrong. */ +export interface TerminalGridStructure { + readonly violations: StructuralViolation[]; + /** The direct panes, in authored order. */ + readonly panes: TerminalPane[]; +} + +/** Which of a pane's two forms was written: its own markdown, or a shell. */ +function paneForm(segment: ComponentElement): TerminalPane["form"] { + return segment.selfClosing ? "self-closing" : "paired"; +} + +/** Everything one `` pane decides from what the author wrote (spec §6.21). */ +function terminalPaneViolations(segment: ComponentElement): StructuralViolation[] { + const found: StructuralViolation[] = []; + const unknownProp = authoredPropNames(segment).find((name) => !TERMINAL_PROPS.has(name)); + if (unknownProp !== undefined) { + found.push( + violation( + "structural-usage-invalid", + "Terminal", + ` only accepts a "title" prop. Got: "${unknownProp}".`, + segment, + ), + ); + } + + if ("title" in segment.props) { + const title = terminalTitle(segment.props.title); + if (!title.ok) { + found.push(violation("structural-usage-invalid", "Terminal", title.error.message, segment)); + } + } else if (!("title" in segment.expressions)) { + found.push( + violation("structural-usage-invalid", "Terminal", terminalTitleMissingMessage(), segment), + ); + } + return found; +} + +/** + * Every `` and `` below a grid that the grid does not + * lay out. The walk stops at a nested grid, which is reported where it sits and + * owns whatever is written beneath it. + */ +function misplacedTerminalViolations(children: Segment[]): StructuralViolation[] { + const found: StructuralViolation[] = []; + + const walk = (segments: Segment[], depth: number): void => { + for (const segment of segments) { + if (segment.type !== "component") { + continue; + } + if (segment.name === "Terminal.Grid") { + if (depth > 0) { + found.push( + violation( + "structural-usage-invalid", + "Terminal.Grid", + nestedTerminalGridMessage(), + segment, + ), + ); + } + continue; + } + if (segment.name === "Terminal" && depth > 0) { + found.push( + violation("structural-usage-invalid", "Terminal", strayTerminalMessage(), segment), + ); + } + walk(segment.children, depth + 1); + } + }; + + walk(children, 0); + return found; +} + +/** + * Divide a `` body into its panes and validate the division + * (spec §6.21). Everything here is read from source, so a grid whose layout the + * author got wrong is refused before `columns` is evaluated, before a pane's + * content expands, and before any terminal provider is asked for anything. + * + * The panes are the grid's direct children and only they: a control structure + * that would produce panes as it ran cannot be one, because which panes exist + * is what the grid must know before it opens anything. + */ +export function terminalGridStructure(segment: ComponentElement): TerminalGridStructure { + const violations: StructuralViolation[] = []; + const panes: TerminalPane[] = []; + + const unknownProp = authoredPropNames(segment).find((name) => !TERMINAL_GRID_PROPS.has(name)); + if (unknownProp !== undefined) { + violations.push( + violation( + "structural-usage-invalid", + "Terminal.Grid", + ` only accepts a "columns" prop. Got: "${unknownProp}".`, + ), + ); + } + if ("columns" in segment.props) { + const columns = terminalColumns(segment.props.columns); + if (!columns.ok) { + violations.push( + violation("structural-usage-invalid", "Terminal.Grid", columns.error.message), + ); + } + } else if (!("columns" in segment.expressions)) { + violations.push( + violation("structural-usage-invalid", "Terminal.Grid", terminalColumnsMissingMessage()), + ); + } + if (segment.selfClosing) { + violations.push( + violation( + "structural-usage-invalid", + "Terminal.Grid", + " holds the panes it lays out, so it is written paired: " + + '.', + ), + ); + } + + let substantive = 0; + for (const [index, child] of segment.children.entries()) { + if (isBlankText(child)) { + continue; + } + substantive++; + if (child.type !== "component" || child.name !== "Terminal") { + violations.push( + violation( + "structural-usage-invalid", + "Terminal.Grid", + ` holds only panes. Found ${describeSegment(child)} ` + + "directly inside it. Write control flow inside a pane instead.", + child.type === "component" ? child : undefined, + ), + ); + continue; + } + violations.push(...terminalPaneViolations(child)); + panes.push({ element: child, index, ordinal: panes.length, form: paneForm(child) }); + } + + if (!segment.selfClosing && substantive === 0) { + violations.push( + violation( + "structural-usage-invalid", + "Terminal.Grid", + " requires at least one pane.", + ), + ); + } + + violations.push(...misplacedTerminalViolations(segment.children)); + return { violations, panes }; +} diff --git a/packages/core/src/structural.ts b/packages/core/src/structural.ts index ad36a80a..0740a8e6 100644 --- a/packages/core/src/structural.ts +++ b/packages/core/src/structural.ts @@ -169,6 +169,29 @@ export const STRUCTURAL_DECLARATIONS: readonly StructuralDeclaration[] = [ as: null, context: "A multiline template, in place of the single-line `template` prop.", }, + { + name: "Terminal.Grid", + syntax: [""], + description: + "Show several interactive terminals at once. " + + '`' + + '` fills `columns` columns with its panes ' + + "in the order they are written, leaving the last row short when the count does not " + + "divide. Only `` panes may be written directly inside it.", + as: null, + context: "The `` panes the grid lays out.", + }, + { + name: "Terminal", + syntax: ['', ''], + description: + "Give one pane of a `` its work. " + + '`` runs that markdown in the pane; ' + + '`` runs the host\'s default interactive shell. `title` ' + + "labels the pane on screen, so two panes may share one.", + as: null, + context: "Markdown the pane runs, in the paired form.", + }, ]; /** diff --git a/packages/core/src/terminal-grid.ts b/packages/core/src/terminal-grid.ts new file mode 100644 index 00000000..59a08a92 --- /dev/null +++ b/packages/core/src/terminal-grid.ts @@ -0,0 +1,69 @@ +/** + * The concrete grid an authored `` derives (spec §6.21). + * + * `structural-rules.ts` decides what the source says: which panes were written, + * in what order, and what is wrong with the way they were written. What it + * cannot decide is where each pane sits, because that also depends on `columns` + * — a value the document may compute. This module is where the two meet, once + * both are known and before anything is opened. + * + * A layout is provider-neutral data. It names no terminal, multiplexer, socket, + * process or window: it says how many columns the author asked for, how many + * rows that many panes fill, and which cell each pane occupies. + */ + +import type { TerminalPane } from "./structural-rules.ts"; + +/** One pane, placed. */ +export interface TerminalGridCell { + /** The pane's structural identity: its position among the panes, from zero. */ + readonly ordinal: number; + /** The row it occupies, from zero. */ + readonly row: number; + /** The column it occupies, from zero. */ + readonly column: number; + /** The label it displays. Two cells may carry the same one. */ + readonly title: string; + /** Whether it runs the markdown the pane holds or the host's default shell. */ + readonly form: TerminalPane["form"]; +} + +/** The complete grid one `` asked for. */ +export interface TerminalGridLayout { + readonly columns: number; + /** How many rows those columns take to hold every pane. */ + readonly rows: number; + /** Every pane, in authored order, which is also row-major order. */ + readonly cells: readonly TerminalGridCell[]; +} + +/** One pane's placeable facts, once its title has been resolved. */ +export interface PlacedPane { + readonly title: string; + readonly form: TerminalPane["form"]; +} + +/** + * Place the panes across `columns` columns in the order they were authored. + * + * Row-major: the first `columns` panes fill the first row, the next fill the + * second, and a count that does not divide leaves the positions at the end of + * the last row unused. Nothing is reordered, padded, or balanced — the author's + * order is the layout, and a pane's ordinal is its identity wherever it lands. + */ +export function terminalGridLayout( + columns: number, + panes: readonly PlacedPane[], +): TerminalGridLayout { + return { + columns, + rows: Math.ceil(panes.length / columns), + cells: panes.map((pane, ordinal) => ({ + ordinal, + row: Math.floor(ordinal / columns), + column: ordinal % columns, + title: pane.title, + form: pane.form, + })), + }; +} diff --git a/packages/core/tests/document-validation.test.ts b/packages/core/tests/document-validation.test.ts index 6c5f6fd9..c37f8c92 100644 --- a/packages/core/tests/document-validation.test.ts +++ b/packages/core/tests/document-validation.test.ts @@ -681,6 +681,160 @@ describe("Tier DV: branch selection", () => { }); }); +describe("Tier DV: terminal grids", () => { + const GRID_DOC = [ + "", + '', + '', + "", + '', + "", + "", + ].join("\n"); + + it("TG3: a well-formed grid is valid, and nothing beneath it runs", function* () { + const { result, seen } = yield* validateText(GRID_DOC, { + tree: { "components/Widget.md": WIDGET }, + }); + + expect(result.outcome).toBe("valid"); + expect(result.diagnostics).toEqual([]); + expect(names(result)).toEqual(["Terminal.Grid", "Terminal", "Widget", "Terminal"]); + expect(named(result, "Terminal.Grid").origin).toEqual({ + kind: "structural", + construct: "Terminal.Grid", + }); + expect(named(result, "Terminal").origin).toEqual({ + kind: "structural", + construct: "Terminal", + }); + // A pane's body is walked like any other region, and none of it — no + // shell, no command, no agent, no terminal — was reached to walk it. + expect(seen.effects).toEqual([]); + // Reserved means selection never looked for a file that could supply + // either construct. + expect(seen.reads.some((read) => read.includes("Terminal"))).toBe(false); + }); + + it("TG3: reports each invalid authored form, with no execution", function* () { + const invalid: [string, string, string][] = [ + [ + "an unknown prop on the grid", + '\n', + ' only accepts a "columns" prop. Got: "layout".', + ], + [ + "a capture on the grid", + '\n', + ' only accepts a "columns" prop. Got: "as".', + ], + [ + "no column count", + '\n', + ' requires a "columns" prop (a positive integer).', + ], + [ + "a column count that is not a positive integer", + '\n', + 'Prop "columns" on must be a positive integer. Got: 0.', + ], + [ + "an unknown prop on a pane", + '\n', + ' only accepts a "title" prop. Got: "shell".', + ], + [ + "no title on a pane", + "\n", + ' requires a "title" prop (the label the pane displays).', + ], + [ + "an empty title", + '\n', + 'Prop "title" on must be a non-empty string. Got: "".', + ], + [ + "a self-closing grid", + "\n", + " holds the panes it lays out", + ], + [ + "a grid with no pane", + "\n", + " requires at least one pane.", + ], + [ + "text written directly in a grid", + 'a note\n', + ' holds only panes. Found text "a note" directly inside it.', + ], + [ + "a direct element that is not a pane", + '\n', + " holds only panes. Found directly inside it.", + ], + [ + "a pane produced by control flow", + '\n', + " holds only panes. Found directly inside it.", + ], + [ + "a nested grid", + '' + + '\n', + " cannot be written inside another .", + ], + [ + "a pane outside every grid", + 'alone\n', + " must be a direct child of .", + ], + [ + "a pane below a grid that is not one of its panes", + '' + + "\n", + " must be a direct child of .", + ], + ]; + + for (const [form, source, message] of invalid) { + const { result, seen } = yield* validateText(source, { + tree: { "components/Widget.md": WIDGET }, + }); + + expect(`${form}: ${result.outcome}`).toBe(`${form}: invalid`); + expect(`${form}: ${codes(result).includes("structural-usage-invalid")}`).toBe( + `${form}: true`, + ); + const said = result.diagnostics.some((diagnostic) => diagnostic.message.includes(message)); + expect(`${form}: ${said}`).toBe(`${form}: true`); + expect(`${form}: ${JSON.stringify(seen.effects)}`).toBe(`${form}: []`); + } + }); + + it("TG3: answers the same way twice", function* () { + const first = yield* validateText("\n"); + const second = yield* validateText("\n"); + + expect(JSON.stringify(second.result)).toBe(JSON.stringify(first.result)); + }); + + it("TG3: a dynamic column count and title are decided by expansion, not here", function* () { + const { result, seen } = yield* validateText( + ["", "", "", ""].join( + "\n", + ), + ); + + // Whether those expressions produce a positive integer and a non-empty + // string is a value the document computes, and evaluating one is + // expansion's alone. + expect(result.outcome).toBe("valid"); + expect(result.diagnostics).toEqual([]); + expect(seen.effects).toEqual([]); + }); +}); + describe("Tier DV: source, target and declaration failures", () => { const ROWS: { readonly id: string; diff --git a/packages/core/tests/syntax-catalog.test.ts b/packages/core/tests/syntax-catalog.test.ts index 711d02ed..a4a78c4f 100644 --- a/packages/core/tests/syntax-catalog.test.ts +++ b/packages/core/tests/syntax-catalog.test.ts @@ -347,6 +347,82 @@ describe("Tier SY: structural vocabulary", () => { expect(find(entries, "Case").as).toBeUndefined(); }); + it("TG3: freezes the and entries the catalog publishes", function* () { + const catalog = yield* catalogFor({}, []); + const entries = structural(catalog); + + expect(catalog.version).toBe(1); + expect(find(entries, "Terminal.Grid")).toEqual({ + kind: "structural", + name: "Terminal.Grid", + origin: { kind: "structural", construct: "Terminal.Grid" }, + syntax: [""], + description: + "Show several interactive terminals at once. " + + '`' + + '` fills `columns` columns with its panes ' + + "in the order they are written, leaving the last row short when the count does not " + + "divide. Only `` panes may be written directly inside it.", + context: "The `` panes the grid lays out.", + }); + expect(find(entries, "Terminal")).toEqual({ + kind: "structural", + name: "Terminal", + origin: { kind: "structural", construct: "Terminal" }, + syntax: ['', ''], + description: + "Give one pane of a `` its work. " + + '`` runs that markdown in the pane; ' + + '`` runs the host\'s default interactive shell. `title` ' + + "labels the pane on screen, so two panes may share one.", + context: "Markdown the pane runs, in the paired form.", + }); + // Neither construct binds, so neither carries an `as` sentence at all. + expect(find(entries, "Terminal.Grid").as).toBeUndefined(); + expect(find(entries, "Terminal").as).toBeUndefined(); + }); + + it("TG3: a repository file cannot supply the grid or a pane, and neither can a registration", function* () { + const catalog = yield* catalogFor( + { + components: { kind: "directory" }, + "components/Terminal.md": markdown("a repository terminal\n"), + "components/Terminal": { kind: "directory" }, + "components/Terminal/Grid.md": markdown("a repository grid\n"), + }, + ["components"], + ); + + for (const name of ["Terminal.Grid", "Terminal"]) { + expect(names(structural(catalog))).toContain(name); + expect(names(userProvided(catalog))).not.toContain(name); + expect(names(builtIn(catalog))).not.toContain(name); + } + + for (const name of ["Terminal.Grid", "Terminal"]) { + let refused: unknown; + yield* scoped(function* () { + try { + yield* registerComponents([ + { + name, + origin: "tier-tg", + props: {}, + *fn() { + return ""; + }, + }, + ]); + } catch (error) { + refused = error; + } + }); + expect(refused instanceof Error ? refused.message : "").toContain( + `cannot register "${name}": it is structural syntax the engine owns`, + ); + } + }); + it("SY5b: a repository file cannot supply or , and neither can a registration", function* () { const catalog = yield* catalogFor( { diff --git a/packages/core/tests/terminal-grid-structure.test.ts b/packages/core/tests/terminal-grid-structure.test.ts new file mode 100644 index 00000000..76c440cb --- /dev/null +++ b/packages/core/tests/terminal-grid-structure.test.ts @@ -0,0 +1,511 @@ +/** + * Tier TG — the authored structure of a terminal grid (spec §6.21). + * + * What an author may write, and where each pane lands, decided before anything + * opens. These rows drive the real expansion path: a grid the grammar accepts + * runs until the point a terminal provider would be asked for one, and this + * build installs none, so it refuses there and carries the layout it derived + * beside the refusal. + * + * Provider non-observation is asserted rather than assumed. Every run traps the + * two boundaries a pane's body would cross — resolving a component and running + * a code block — and a row is evidence only when both stayed empty. That the + * machine running these tests has no tmux is not evidence of anything: nothing + * here would look for one. + */ + +import { describe, it } from "@executablemd/test-support/bdd"; +import { expect } from "@executablemd/test-support/expect"; +import { scoped } from "effection"; +import type { Operation } from "effection"; + +import { Component } from "../src/component-api.ts"; +import { expandSegments } from "../src/expand.ts"; +import { renderSegments } from "../src/render.ts"; +import { scanSegments } from "../src/scanner.ts"; +import { terminalGridLayout } from "../src/terminal-grid.ts"; +import type { Json, Segment } from "../src/types.ts"; + +interface GridRun { + segments: Segment[]; + output: string; + /** Every component the run tried to resolve, in order. */ + imports: string[]; + /** The source of every code block the run ran, in order. */ + blocks: string[]; + /** Every expression the document evaluated, by label, in order. */ + calls: string[]; +} + +/** + * Expand one document with every effect a pane could have trapped. + * + * A component this run resolves, a code block it runs, or an expression it + * evaluates is recorded rather than performed, so "nothing beneath the grid + * happened" is something the row reads back instead of assuming. + */ +function runGrid(source: string, values: Record = {}): Operation { + return scoped(function* () { + const imports: string[] = []; + const blocks: string[] = []; + const calls: string[] = []; + yield* Component.around( + { + // deno-lint-ignore require-yield + *importComponent([name], _next) { + imports.push(name); + throw new Error(`Component not found: ${name}`); + }, + // deno-lint-ignore require-yield + *applyModifiers([_modifiers, context], _next) { + blocks.push(context.content); + return { output: "", exitCode: 0, stderr: "" }; + }, + }, + { at: "min" }, + ); + const testEnv = { + values: { + ...values, + seen: (label: string, value: unknown) => { + calls.push(label); + return value; + }, + }, + }; + yield* Component.around({ env: () => testEnv }, { at: "min" }); + const segments = yield* expandSegments(scanSegments(source), {}, {}, new Set()); + return { segments, output: renderSegments(segments), imports, blocks, calls }; + }); +} + +function errorMessages(segments: Segment[]): string[] { + return segments.filter((segment) => segment.type === "error").map((segment) => segment.message); +} + +/** The one message a run that refused for a single reason reports. */ +function soleError(run: GridRun): string { + const messages = errorMessages(run.segments); + expect(messages).toHaveLength(1); + return messages[0]!; +} + +/** + * The grid a run derived, read from the refusal that carries it. + * + * A run that refused for a grammar or placement reason never derived one, so + * asking for it is also how a row states that the grid was complete. + */ +function derivedLayout(run: GridRun): Json { + const refusal = run.segments.find( + (segment) => segment.type === "error" && segment.source === "Terminal.Grid", + ); + if (refusal === undefined || refusal.type !== "error" || refusal.cause === undefined) { + throw new Error(`no terminal-grid refusal carrying a layout: ${errorMessages(run.segments)}`); + } + return refusal.cause; +} + +/** Every boundary a pane's body would have crossed, and none of them did. */ +function reachedNothing(run: GridRun): void { + expect(run.imports).toEqual([]); + expect(run.blocks).toEqual([]); + expect(run.calls).toEqual([]); +} + +/** + * Work a pane's body would do, so a body that expanded would be recorded. + * + * One of each boundary `reachedNothing()` reads: a component to resolve, an + * expression to evaluate, and a command to run. + */ +const PANE_BODY = [ + "", + "", + 'reached', + "", + "```bash exec", + "echo ran", + "```", +].join("\n"); + +describe("Tier TG — the grid grammar", () => { + it("TG1: accepts a paired grid with positive integer columns and both pane forms", function* () { + const run = yield* runGrid( + [ + "", + 'Instructions.', + '', + "", + ].join("\n"), + ); + + // The grammar accepted it, so the run reached the one thing this build + // cannot do — and stopped there. + expect(soleError(run)).toContain("no terminal provider opened this grid"); + expect(derivedLayout(run)).toEqual({ + layout: { + columns: 2, + rows: 1, + cells: [ + { ordinal: 0, row: 0, column: 0, title: "Agent", form: "paired" }, + { ordinal: 1, row: 0, column: 1, title: "Shell", form: "self-closing" }, + ], + }, + }); + }); + + it("TG1: refuses an unknown prop and `as` on the grid", function* () { + const unknown = yield* runGrid( + '', + ); + expect(soleError(unknown)).toContain( + ' only accepts a "columns" prop. Got: "layout".', + ); + + const captured = yield* runGrid( + '', + ); + expect(soleError(captured)).toContain( + ' only accepts a "columns" prop. Got: "as".', + ); + reachedNothing(unknown); + reachedNothing(captured); + }); + + it("TG1: refuses an unknown prop and `as` on a pane", function* () { + const unknown = yield* runGrid( + '', + ); + expect(soleError(unknown)).toContain(' only accepts a "title" prop. Got: "shell".'); + + const captured = yield* runGrid( + '', + ); + expect(soleError(captured)).toContain(' only accepts a "title" prop. Got: "as".'); + reachedNothing(unknown); + reachedNothing(captured); + }); + + it("TG1: requires columns to be a positive integer, however it was written", function* () { + const missing = yield* runGrid(''); + expect(soleError(missing)).toContain( + ' requires a "columns" prop (a positive integer).', + ); + + for (const literal of ["{0}", "{-1}", "{2.5}", '"2"', "{null}"]) { + const run = yield* runGrid( + ``, + ); + expect(soleError(run)).toContain('Prop "columns" on must be a positive'); + reachedNothing(run); + } + + // The same rule reaches a value the document computes, which the source + // could not have decided about. + const computed = yield* runGrid( + '', + { size: 0 }, + ); + expect(soleError(computed)).toContain( + 'Prop "columns" on must be a positive integer. Got: 0.', + ); + reachedNothing(computed); + }); + + it("TG1: requires a non-empty title on every pane, however it was written", function* () { + const missing = yield* runGrid(""); + expect(soleError(missing)).toContain( + ' requires a "title" prop (the label the pane displays).', + ); + + for (const literal of ['""', "{3}", "{null}"]) { + const run = yield* runGrid( + ``, + ); + expect(soleError(run)).toContain('Prop "title" on must be a non-empty string'); + reachedNothing(run); + } + + const computed = yield* runGrid( + "", + { label: "" }, + ); + expect(soleError(computed)).toContain( + 'Prop "title" on must be a non-empty string. Got: "".', + ); + reachedNothing(computed); + }); + + it("TG1: refuses a self-closing grid", function* () { + const run = yield* runGrid(""); + expect(soleError(run)).toContain(" holds the panes it lays out"); + reachedNothing(run); + }); +}); + +describe("Tier TG — structural placement", () => { + it("TG2: refuses a grid with no pane", function* () { + const run = yield* runGrid(""); + expect(soleError(run)).toContain(" requires at least one pane."); + reachedNothing(run); + }); + + it("TG2: refuses ordinary text written directly in a grid", function* () { + const run = yield* runGrid( + 'a note', + ); + expect(soleError(run)).toContain( + ' holds only panes. Found text "a note" directly inside it.', + ); + reachedNothing(run); + }); + + it("TG2: refuses a direct element that is not a pane", function* () { + const run = yield* runGrid( + '', + ); + expect(soleError(run)).toContain( + " holds only panes. Found directly inside it.", + ); + // The element was refused as authored structure, so it was never resolved. + reachedNothing(run); + }); + + it("TG2: refuses a control structure that would produce the panes", function* () { + const run = yield* runGrid( + [ + "", + '', + '', + "", + "", + ].join("\n"), + ); + + const messages = errorMessages(run.segments); + expect(messages).toHaveLength(2); + expect(messages[0]).toContain( + " holds only panes. Found directly inside it.", + ); + expect(messages[0]).toContain("Write control flow inside a pane instead."); + expect(messages[1]).toContain(" must be a direct child of ."); + // The condition decides which panes would exist, and the grid must know + // that from the source, so it is never evaluated. + reachedNothing(run); + }); + + it("TG2: refuses a grid nested inside a pane", function* () { + const run = yield* runGrid( + [ + "", + '', + '', + "", + "", + ].join("\n"), + ); + expect(soleError(run)).toContain( + " cannot be written inside another .", + ); + reachedNothing(run); + }); + + it("TG2: refuses a pane written outside every grid", function* () { + const alone = yield* runGrid('Instructions.'); + expect(soleError(alone)).toContain(" must be a direct child of ."); + + // Below a grid but not one of its panes is the same mistake, reported where + // the pane was written. + const buried = yield* runGrid( + [ + "", + '', + '', + "", + "", + ].join("\n"), + ); + expect(soleError(buried)).toContain(" must be a direct child of ."); + reachedNothing(alone); + reachedNothing(buried); + }); + + it("TG2: treats whitespace between panes as nothing at all", function* () { + const run = yield* runGrid( + [ + "", + "", + ' ', + "", + ' ', + "", + "", + ].join("\n"), + ); + + expect(soleError(run)).toContain("no terminal provider opened this grid"); + expect(derivedLayout(run)).toEqual({ + layout: { + columns: 2, + rows: 1, + cells: [ + { ordinal: 0, row: 0, column: 0, title: "A", form: "self-closing" }, + { ordinal: 1, row: 0, column: 1, title: "B", form: "self-closing" }, + ], + }, + }); + }); + + it("TG2: a complete grid refuses before any pane body or default shell", function* () { + const run = yield* runGrid( + [ + "", + '', + "", + PANE_BODY, + "", + '', + "", + ].join("\n"), + ); + + expect(soleError(run)).toContain("no pane expanded its content and no default shell started."); + // The pane held a component and a command; neither was reached, and the + // grid rendered nothing of its own. + reachedNothing(run); + expect(run.output).toContain("no terminal provider opened this grid"); + }); +}); + +describe("Tier TG — row-major layout", () => { + const positions = (columns: number, panes: number) => + terminalGridLayout( + columns, + Array.from({ length: panes }, (_unused, index) => ({ + title: `pane ${index}`, + form: "self-closing" as const, + })), + ).cells.map((cell) => [cell.row, cell.column]); + + it("TG4: places one through five panes row-major across two columns", function* () { + expect(positions(2, 1)).toEqual([[0, 0]]); + expect(positions(2, 2)).toEqual([ + [0, 0], + [0, 1], + ]); + expect(positions(2, 3)).toEqual([ + [0, 0], + [0, 1], + [1, 0], + ]); + expect(positions(2, 4)).toEqual([ + [0, 0], + [0, 1], + [1, 0], + [1, 1], + ]); + expect(positions(2, 5)).toEqual([ + [0, 0], + [0, 1], + [1, 0], + [1, 1], + [2, 0], + ]); + // The last row is left short rather than balanced or padded. + expect([1, 2, 3, 4, 5].map((panes) => terminalGridLayout(2, filler(panes)).rows)).toEqual([ + 1, 1, 2, 2, 3, + ]); + }); + + it("TG4: places one through five panes row-major across three columns", function* () { + expect(positions(3, 1)).toEqual([[0, 0]]); + expect(positions(3, 2)).toEqual([ + [0, 0], + [0, 1], + ]); + expect(positions(3, 3)).toEqual([ + [0, 0], + [0, 1], + [0, 2], + ]); + expect(positions(3, 4)).toEqual([ + [0, 0], + [0, 1], + [0, 2], + [1, 0], + ]); + expect(positions(3, 5)).toEqual([ + [0, 0], + [0, 1], + [0, 2], + [1, 0], + [1, 1], + ]); + expect([1, 2, 3, 4, 5].map((panes) => terminalGridLayout(3, filler(panes)).rows)).toEqual([ + 1, 1, 1, 2, 2, + ]); + }); + + it("TG4: an executed grid derives those same positions", function* () { + const run = yield* runGrid( + [ + "", + '', + '', + '', + '', + '', + "", + ].join("\n"), + ); + + expect(derivedLayout(run)).toEqual({ + layout: { + columns: 2, + rows: 3, + cells: [ + { ordinal: 0, row: 0, column: 0, title: "One", form: "self-closing" }, + { ordinal: 1, row: 0, column: 1, title: "Two", form: "self-closing" }, + { ordinal: 2, row: 1, column: 0, title: "Three", form: "self-closing" }, + { ordinal: 3, row: 1, column: 1, title: "Four", form: "self-closing" }, + { ordinal: 4, row: 2, column: 0, title: "Five", form: "self-closing" }, + ], + }, + }); + }); + + it("TG4: duplicate titles stay valid, and identity is the ordinal", function* () { + const run = yield* runGrid( + [ + "", + 'first', + '', + 'third', + "", + ].join("\n"), + ); + + // Three panes sharing one label are three panes: the ordinal separates + // them, and the form each one was written in travels with it. + expect(derivedLayout(run)).toEqual({ + layout: { + columns: 2, + rows: 2, + cells: [ + { ordinal: 0, row: 0, column: 0, title: "Agent", form: "paired" }, + { ordinal: 1, row: 0, column: 1, title: "Agent", form: "self-closing" }, + { ordinal: 2, row: 1, column: 0, title: "Agent", form: "paired" }, + ], + }, + }); + }); +}); + +/** Panes that differ only in count, for a row about rows. */ +function filler(panes: number): { title: string; form: "self-closing" }[] { + return Array.from({ length: panes }, (_unused, index) => ({ + title: `pane ${index}`, + form: "self-closing" as const, + })); +} From 97be06321cbdf08d84ef53dbefb88407f54326fe Mon Sep 17 00:00:00 2001 From: Taras Mankovski <74687+taras@users.noreply.github.com> Date: Wed, 2 Sep 2026 12:45:27 -0400 Subject: [PATCH 2/2] =?UTF-8?q?=F0=9F=93=9D=20Shorten=20the=20terminal-gri?= =?UTF-8?q?d=20component=20descriptions=20(#729)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Both descriptions lead with what the author can do and show the invocation to copy. The placement rule, the row-major detail and the title's display role are the catalog's structured fields and §6.21's to state, not a second reference entry beside the forms. The frozen catalog entries in `syntax-catalog.test.ts` mirror the two declarations, so they move with them. --- packages/core/src/structural.ts | 14 +++++--------- packages/core/tests/syntax-catalog.test.ts | 14 +++++--------- 2 files changed, 10 insertions(+), 18 deletions(-) diff --git a/packages/core/src/structural.ts b/packages/core/src/structural.ts index 0740a8e6..4823aca3 100644 --- a/packages/core/src/structural.ts +++ b/packages/core/src/structural.ts @@ -173,11 +173,8 @@ export const STRUCTURAL_DECLARATIONS: readonly StructuralDeclaration[] = [ name: "Terminal.Grid", syntax: [""], description: - "Show several interactive terminals at once. " + - '`' + - '` fills `columns` columns with its panes ' + - "in the order they are written, leaving the last row short when the count does not " + - "divide. Only `` panes may be written directly inside it.", + "Open several terminals in one view. " + + '``', as: null, context: "The `` panes the grid lays out.", }, @@ -185,10 +182,9 @@ export const STRUCTURAL_DECLARATIONS: readonly StructuralDeclaration[] = [ name: "Terminal", syntax: ['', ''], description: - "Give one pane of a `` its work. " + - '`` runs that markdown in the pane; ' + - '`` runs the host\'s default interactive shell. `title` ' + - "labels the pane on screen, so two panes may share one.", + "Expand Markdown or open a shell in a pane. " + + '`` runs content; ' + + '`` opens a shell.', as: null, context: "Markdown the pane runs, in the paired form.", }, diff --git a/packages/core/tests/syntax-catalog.test.ts b/packages/core/tests/syntax-catalog.test.ts index a4a78c4f..e58b0d0b 100644 --- a/packages/core/tests/syntax-catalog.test.ts +++ b/packages/core/tests/syntax-catalog.test.ts @@ -358,11 +358,8 @@ describe("Tier SY: structural vocabulary", () => { origin: { kind: "structural", construct: "Terminal.Grid" }, syntax: [""], description: - "Show several interactive terminals at once. " + - '`' + - '` fills `columns` columns with its panes ' + - "in the order they are written, leaving the last row short when the count does not " + - "divide. Only `` panes may be written directly inside it.", + "Open several terminals in one view. " + + '``', context: "The `` panes the grid lays out.", }); expect(find(entries, "Terminal")).toEqual({ @@ -371,10 +368,9 @@ describe("Tier SY: structural vocabulary", () => { origin: { kind: "structural", construct: "Terminal" }, syntax: ['', ''], description: - "Give one pane of a `` its work. " + - '`` runs that markdown in the pane; ' + - '`` runs the host\'s default interactive shell. `title` ' + - "labels the pane on screen, so two panes may share one.", + "Expand Markdown or open a shell in a pane. " + + '`` runs content; ' + + '`` opens a shell.', context: "Markdown the pane runs, in the paired form.", }); // Neither construct binds, so neither carries an `as` sentence at all.