Skip to content
Merged
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
136 changes: 126 additions & 10 deletions architecture.md

Large diffs are not rendered by default.

2 changes: 2 additions & 0 deletions packages/cli/tests/syntax-cli.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -326,6 +326,7 @@ describe("Tier SX — the run profile the command describes", () => {
// The whole structural vocabulary this profile publishes, so a construct
// returning to it has to be written down here.
expect([...names(structural.entries)].sort()).toEqual([
"All",
"Answer",
"Answers",
"Break",
Expand All @@ -339,6 +340,7 @@ describe("Tier SX — the run profile the command describes", () => {
"Output",
"PrintErrors",
"Return",
"Spawn",
"Switch",
]);
for (const name of ["Terminal.Grid", "Terminal"]) {
Expand Down
9 changes: 8 additions & 1 deletion packages/core/src/body-structure.ts
Original file line number Diff line number Diff line change
Expand Up @@ -81,13 +81,20 @@ function collectOutputs(bodySegments: Segment[], minimumDepth: number): Componen
* this walk does not see is the only thing that still cannot declare one —
* another component's definition, and markdown produced at runtime — because
* it reads this body's source AST and nothing else.
*
* A `<Spawn>` is the one region of this body's own source the walk stops at.
* The spawned child owns no value body, so a `<Return>` inside one neither
* satisfies this body's declaration nor is an undeclared return of it: the
* `<All>` rule owns that diagnostic, and counting it here would report one
* mistake as two while letting a spawn stand in for the return the body still
* has to write.
*/
function collectReturns(bodySegments: Segment[]): ComponentElement[] {
const declared: ComponentElement[] = [];

const walk = (segments: Segment[]): void => {
for (const segment of segments) {
if (segment.type !== "component") {
if (segment.type !== "component" || segment.name === "Spawn") {
continue;
}
if (segment.name === "Return") {
Expand Down
5 changes: 5 additions & 0 deletions packages/core/src/component-answers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -114,6 +114,11 @@ export function componentAnswerRegistrar(
*around(handler: ComponentAnswerHandler): Operation<void> {
yield* Component.around({
*importComponent([name, position], next) {
// One request for this handler invocation, capturing the resolution
// window that is open right now. Minted here rather than carried on
// the installation: one installation serves every name it registered,
// and a retained handle states nothing about which resolution is
// being decided.
const asked = installation.open(name, position);
try {
return yield* handler(
Expand Down
129 changes: 124 additions & 5 deletions packages/core/src/component-api.ts
Original file line number Diff line number Diff line change
Expand Up @@ -175,16 +175,92 @@ export interface ComponentApi {
registry: ComponentRegistry;
}

export const Component: Api<ComponentApi> = createApi<ComponentApi>("Component", {
/**
* The mark an empty public terminal puts on what it raises: a namespaced,
* non-enumerable own property carrying the name that was asked for.
*
* A property rather than a class, because the readers are in a different copy of
* this module than the writer as often as not. A component loaded from disk with
* `--include`, and a middleware package holding its own copy of core, each build
* their own `Component` descriptor with their own empty terminal and their own
* error class — and an execution has to recognize "nothing answered this" across
* exactly that seam, which `instanceof` cannot do. The name is namespaced so it
* collides with nothing, and non-enumerable so it does not travel into whatever
* a reporter serializes.
*
* It conveys one fact — *this call reached an empty public terminal, asking for
* this name* — and no authority. Anything may put it on anything; all it can
* cause is the ordinary resolution an unanswered import gets anyway.
*/
const MISSING_IMPORT_PROVIDER = "@executablemd/core/missing-import-provider";

/**
* What the public descriptor's own terminal raises when nothing answered.
*
* An execution's provider delegates to `next` first and performs its ordinary
* resolution only for this, so a descriptor that supplied a terminal of its own
* is answered by that terminal and never resolved twice. Exported for core,
* published from no package entry point, and carrying nothing but the name that
* was asked.
*/
export class MissingImportProvider extends Error {
override name = "MissingImportProvider";
constructor(asked: string) {
super(
`Component.importComponent("${asked}") has no provider. Install one with ` +
`Component.around({ importComponent }, { at: "min" }) before expansion.`,
);
Object.defineProperty(this, MISSING_IMPORT_PROVIDER, {
value: { asked },
enumerable: false,
writable: false,
configurable: false,
});
}
}

/**
* Whether `error` is an empty public terminal reporting that nothing answered an
* import of exactly `asked`.
*
* The whole mark is parsed rather than trusted: an own, non-enumerable property
* under the namespaced name, holding an object whose complete own-key set is one
* member, `asked`, whose value is the name this call asked for. Nothing is cast;
* a payload carrying anything besides that member, however hidden, is not the
* mark; and a mark that names another component — a foreign terminal's report
* about a *different* import, arriving here on some other failure — is not this
* call's.
*/
export function isMissingImportProvider(error: unknown, asked: string): boolean {
if (typeof error !== "object" || error === null) {
return false;
}
const mark = Object.getOwnPropertyDescriptor(error, MISSING_IMPORT_PROVIDER);
if (mark === undefined || mark.enumerable) {
return false;
}
const value: unknown = mark.value;
// The complete own-key set, so "one member" means one: `Object.keys()` would
// report a payload carrying hidden extras — a non-enumerable member, a symbol —
// as the single-member mark it is not.
if (typeof value !== "object" || value === null || Reflect.ownKeys(value).length !== 1) {
return false;
}
return Object.getOwnPropertyDescriptor(value, "asked")?.value === asked;
}

/**
* Every default but the registry, which each descriptor is given its own of:
* a `Map` living at module scope would be one table shared by every run in the
* process, and the lint rule that says so is right.
*/
const COMPONENT_DEFAULTS: Omit<ComponentApi, "registry"> = {
// deno-lint-ignore require-yield
*importComponent(
name: string,
_position?: Readonly<SourcePosition>,
): Operation<ComponentDefinition | FunctionComponentDefinition> {
throw new Error(
`Component.importComponent("${name}") has no provider. Install one with ` +
`Component.around({ importComponent }, { at: "min" }) before expansion.`,
);
throw new MissingImportProvider(name);
},
// deno-lint-ignore require-yield
*applyModifiers(_modifiers: Modifier[], block: CodeBlockContext): Operation<CodeBlockResult> {
Expand Down Expand Up @@ -259,9 +335,52 @@ export const Component: Api<ComponentApi> = createApi<ComponentApi>("Component",
*handleFailure(failure: ComponentFailure): Operation<ErrorSegment> {
throw failure.error;
},
};

export const Component: Api<ComponentApi> = createApi<ComponentApi>("Component", {
...COMPONENT_DEFAULTS,
registry: new Map(),
});

/**
* Ask one import through a descriptor whose terminal belongs to that import.
*
* The name is what shares middleware: a descriptor created with the stable
* `Component` name receives every handler installed anywhere, in the order they
* were installed, exactly as the public descriptor does. What a name does not
* share is the default handler — each `createApi()` instance owns its own — so
* the chain composed here terminates in the continuation this caller passed and
* in nothing a handler can reach, replace or reorder.
*
* That is the whole of the correlation. A middleware may delegate `next` through
* any descendant Effection scope and still arrive here; two imports asking at
* the same time have two descriptors and two terminals; and a counterfeit
* descriptor built with the same name changes only the calls made through it.
*/
export function importThroughTerminal(
name: string,
position: Readonly<SourcePosition> | undefined,
terminal: (
asked: string,
at: Readonly<SourcePosition> | undefined,
) => Operation<ComponentDefinition | FunctionComponentDefinition>,
): Operation<ComponentDefinition | FunctionComponentDefinition> {
const owned = createApi<ComponentApi>("Component", {
...COMPONENT_DEFAULTS,
// Its own, and empty: what a registration installed is middleware, which
// this descriptor shares by name, so a registry asked through it is answered
// there exactly as it is through the public one.
registry: new Map(),
*importComponent(
asked: string,
at?: Readonly<SourcePosition>,
): Operation<ComponentDefinition | FunctionComponentDefinition> {
return yield* terminal(asked, at);
},
});
return owned.operations.importComponent(name, position);
}

export const importComponent: Operations<ComponentApi>["importComponent"] =
Component.operations.importComponent;
export const applyModifiers: Operations<ComponentApi>["applyModifiers"] =
Expand Down
23 changes: 22 additions & 1 deletion packages/core/src/component-failures.ts
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,7 @@
*/

import { Component, raise } from "./component-api.ts";
import { attributeCause, ErrorMode } from "./errors.ts";
import { attributeCause, documentationError, ErrorMode } from "./errors.ts";
import type { ComponentFailure, ErrorSegment, FunctionComponent } from "./types.ts";
import type { Operation } from "effection";

Expand Down Expand Up @@ -92,6 +92,27 @@ export function containedLedger(inherited: CheckedFailures | undefined): Checked
return { authorized: inherited?.authorized ?? false };
}

/**
* Refuse a successful outcome for work that suffered an unauthorized checked
* command failure.
*
* The failure was already raised where the command ran, and something enclosing
* it — a `printErrors(fn)` component like `<TempDir>`, or one that caught the
* `ContentError` its projected content raised — printed it and returned. Those
* boundaries decide how a failure of their own is reported. Whether a command
* that exited nonzero failed the run is not theirs to decide (#441).
*
* Whoever owns the ledger says so: the execution for the run's own, and a
* spawned child for the one it keeps to itself, which is how a checked failure
* inside one `<Spawn>` fails that child rather than stopping a sibling's walk.
*/
export function* refuseCheckedFailure(checkedFailures: CheckedFailures): Operation<void> {
const segment = checkedFailures.failure;
if (segment !== undefined) {
throw yield* documentationError(segment, "output");
}
}

/**
* Continue after this component fails, reporting the failure as a printed error.
*
Expand Down
Loading
Loading