Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
46 changes: 46 additions & 0 deletions packages/cli/tests/syntax-cli.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<never> {
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(["<Terminal.Grid columns={2}>…</Terminal.Grid>"]);
expect(pane?.syntax).toEqual([
'<Terminal title="Agent">…</Terminal>',
'<Terminal title="Shell" />',
]);
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 <Session> without minting an execution claimant", function* () {
const catalog = yield* syntaxCatalog([]);
const session = catalog.categories[1].entries.find((entry) => entry.name === "Session");
Expand Down Expand Up @@ -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("### `<Terminal.Grid>`");
expect(markdown.stdout).toContain("### `<Terminal>`");
expect(markdown.stdout).toContain("<Terminal.Grid columns={2}>…</Terminal.Grid>");
expect(markdown.stdout).toContain('<Terminal title="Agent">…</Terminal>');
expect(markdown.stdout).toContain('<Terminal title="Shell" />');

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(
{
Expand Down
24 changes: 24 additions & 0 deletions packages/core/src/document-validation.ts
Original file line number Diff line number Diff line change
Expand Up @@ -74,7 +74,9 @@ import {
strayCaseMessage,
strayElseMessage,
strayStructuralMessage,
strayTerminalMessage,
switchStructure,
terminalGridStructure,
} from "./structural-rules.ts";
import type { StructuralViolation } from "./structural-rules.ts";
import type {
Expand Down Expand Up @@ -314,6 +316,8 @@ interface LexicalContext {
readonly insideIf: boolean;
/** Whether a `<Switch>` in this source lexically encloses this point. */
readonly insideSwitch: boolean;
/** Whether a `<Terminal.Grid>` in this source lexically encloses this point. */
readonly insideTerminalGrid: boolean;
/** Whether the immediate parent is an `<Answers>`. */
readonly underAnswers: boolean;
}
Expand Down Expand Up @@ -492,6 +496,7 @@ class ValidationState {
insideLoop: false,
insideIf: false,
insideSwitch: false,
insideTerminalGrid: false,
underAnswers: false,
});
}
Expand Down Expand Up @@ -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.

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Redundant comment — restates what the code does.

Suggested change
// below the grid that the grid does not lay out.

return terminalGridStructure(segment).violations;
case "Terminal":
// A well-placed `<Terminal>` 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.

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Redundant comment — restates what the code does.

Suggested change
// 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 `<Else>` is its `<If>`'s, and one placed wrongly under
// an `<If>` is already reported by that `<If>`'s own structure. What is
Expand Down Expand Up @@ -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",
};
}
Expand Down
156 changes: 154 additions & 2 deletions packages/core/src/expand.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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 <Terminal> is consumed by its <Terminal.Grid> 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;
Expand Down Expand Up @@ -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<Result<Json | undefined>> {
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<void> {
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<Result<string>> {
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" };
}
Expand Down
Loading
Loading