Part 18 — Projection micro-DSL: bindings Concept → render kind
This is the article where the suite earns the projectional in its description. Articles 06–17 covered the textual surface — highlighting, completion, snippets, hover, diagnostics, lenses, commands, views, formatting, symbols, refactoring — every one of them shaped to a text-based editor. This article introduces the projection layer: a Concept can be rendered as text, as a table, as a diagram, or as a form, and the same AST drives all four. Co-editing across projections is coherent because every mutation goes through the PatchBus; the canonical text representation remains the single source of truth on disk because the text projection is first-class, not an export.
That last point is the one bet that distinguishes the suite from JetBrains MPS. MPS treats text as a second-class artefact — the AST lives in MPS' database, projections render it, and text is produced only on demand for export or for systems MPS does not own. The cost is that git diff does not work against MPS source; grep does not work; every collaborator needs MPS to edit. We invert the bet: the text projection is the canonical on-disk format; the AST is reconstructed deterministically from the text on every load; the table, diagram, and form projections render the same AST but never replace text on disk. We pay a parsing cost on every file load to keep git, grep, code review, and the rest of the developer ecosystem working as it does for any other language.
Concern
The author opens feature-checkout.req.ts. The default projection is text — they see TypeScript with the Requirements DSL decorators, formatted by the Formatter (article 15), highlighted by Syntax (article 07). They right-click and choose "Open as Table"; the file re-renders as an editable grid where each row is one Feature, each column is one Property of Feature. They edit a cell; the change goes through PatchBus, the AST mutates, and when they save, the canonical text projection is re-emitted (idempotently, by Banner). They open "as Diagram"; the same file renders as a graph where Features are nodes and @ReferenceLink('blocks') are edges; dragging a node updates positions in the sidecar .ide.json (article 05's persistence note); creating a new edge by drag-and-drop sends a setReference PatchBus op. Three projections, one AST, one canonical text on disk.
Hand-writing this requires implementing a VSCode Custom Editor, building a WebView per projection kind, wiring bidirectional sync, ensuring text and projections never disagree. The Projection micro-DSL declares the bindings; the Custom Editor host (article 21) provides the runtime.
The Surface
import { Projection, ProjectionKind } from '@frenchexdev/ide-dsl-projection';
import { Concept, Property, ChildLink, ReferenceLink } from '@frenchexdev/ide-dsl-kernel';
@Concept({ id: 'cmf.req.Feature' })
@Projection({ kind: 'text', default: true }) // text projection (round-trip canonical)
@Projection({ kind: 'form' }) // auto-generated form from properties
@Projection({ kind: 'diagram', graphOver: ['blocks'] }) // diagram of blocks references
export class Feature {
@Property({ type: 'string' })
id!: string;
@Property({ type: 'string', i18n: true })
title!: string;
@ChildLink({ target: 'cmf.req.AcceptanceCriterion', card: '1..*' })
@Projection({ kind: 'table', columns: ['id', 'title', 'satisfied'] })
acceptance!: AcceptanceCriterion[];
@ReferenceLink({ target: 'cmf.req.Feature', card: '0..*' })
blocks!: FeatureRef[];
}import { Projection, ProjectionKind } from '@frenchexdev/ide-dsl-projection';
import { Concept, Property, ChildLink, ReferenceLink } from '@frenchexdev/ide-dsl-kernel';
@Concept({ id: 'cmf.req.Feature' })
@Projection({ kind: 'text', default: true }) // text projection (round-trip canonical)
@Projection({ kind: 'form' }) // auto-generated form from properties
@Projection({ kind: 'diagram', graphOver: ['blocks'] }) // diagram of blocks references
export class Feature {
@Property({ type: 'string' })
id!: string;
@Property({ type: 'string', i18n: true })
title!: string;
@ChildLink({ target: 'cmf.req.AcceptanceCriterion', card: '1..*' })
@Projection({ kind: 'table', columns: ['id', 'title', 'satisfied'] })
acceptance!: AcceptanceCriterion[];
@ReferenceLink({ target: 'cmf.req.Feature', card: '0..*' })
blocks!: FeatureRef[];
}@Projection is multivalued — a Concept can declare any number of projections. Each declares a kind ('text', 'form', 'table', 'diagram', plus an 'other' extension point); kind-specific options (columns for tables, graphOver for diagrams) further parameterise the rendering. The text projection is special: it is the default, it is bidirectional, and its renderer is the Formatter from article 15.
The default: true flag matters. Exactly one projection per Concept is marked as default; that projection is what VSCode shows when the file opens, and what the Formatter and Generator emit on save. Other projections are opened on demand through the Custom Editor host's "Switch projection" command.
Kernel boundary
Reads:
- The Structure Model — for projection registration and validation.
- The current document AST — to render.
Writes:
- Through PatchBus only, in response to user edits in any projection. The Custom Editor host (article 21) bridges WebView events to PatchBus calls; the Projection micro-DSL exposes the per-projection event-to-patch translators.
The text projection is bidirectional: edits in the text view re-parse to update the AST, edits in the AST (from another projection or another file's Refactoring transaction) re-format to update the text. The kernel exposes both directions through its standard load and save flow; the Projection micro-DSL plugs in as a renderer for the AST-to-text path and as a parse callback for the text-to-AST path. When the parse fails (the user has typed half a token), the kernel keeps the last-good AST sub-tree and surfaces a BrokenText placeholder for the broken region — modelled after MPS' grey cell for half-typed input.
Emitted artefacts
// projection registry (generated, in customeditor/_projection-registry.generated.ts)
export const projectionRegistry = {
'cmf.req.Feature': [
{ kind: 'text', default: true, render: textRender, parse: textParse },
{ kind: 'form', render: formRender, applyEdit: formToPatch },
{ kind: 'diagram', graphOver: ['blocks'], render: diagramRender, applyEdit: diagramToPatch },
],
// ...
};// projection registry (generated, in customeditor/_projection-registry.generated.ts)
export const projectionRegistry = {
'cmf.req.Feature': [
{ kind: 'text', default: true, render: textRender, parse: textParse },
{ kind: 'form', render: formRender, applyEdit: formToPatch },
{ kind: 'diagram', graphOver: ['blocks'], render: diagramRender, applyEdit: diagramToPatch },
],
// ...
};The renderer functions are pure: (ast, options) => RenderTree. The edit functions translate WebView events into PatchBus operations: (event, ast) => PatchBusOp[]. Both are kernel-aware (they consult the Structure Model) and projection-private (they live in the Projection micro-DSL package).
The Projection micro-DSL does not contribute to package.json directly; the Custom Editor host (article 21) consumes the projection registry and produces the appropriate contributes.customEditors entries.
Composition with peers
- Custom Editor host (article 21) — consumes the projection registry, mounts WebViews, owns the per-projection rendering runtime. The Projection micro-DSL declares; the host renders.
- Formatter (article 15) — is the text projection's renderer; the same
@Formatdeclarations drive both surfaces. Sharing prevents drift. - Generator (article 19) — uses the text projection as the canonical on-disk format.
- PatchBus (kernel, article 05) — every edit goes through it.
MPS aspect referent
MPS Editor aspect's cell language — the central pattern of MPS, where the projectional editor is built from cell layout rules per Concept. We adopt three things directly: the per-Concept binding (@Projection parallels MPS' editor declaration), the multi-projection support (multiple @Projection declarations are MPS' multiple editor aspects), and the BrokenText placeholder pattern (MPS' grey cell). We diverge on three things: text is a first-class projection (MPS treats it as export), the renderer runs in a WebView (MPS' Swing), and the on-disk format is text not a database blob (MPS persists the AST, we persist the text and rebuild the AST).
Boundary justification
Why not in the Custom Editor host? Because the projection bindings are DSL-specific and the host is generic. The host should not know that cmf.req.Feature has a table projection over its acceptance children; the host knows how to host whatever projections are registered. Splitting keeps the host neutral and lets each DSL declare its own projections without modifying the host.
Why is text first-class instead of an export? Cost-benefit: the parse-on-load cost is small (a .req.ts file is at most a few thousand lines, ts-morph parses in milliseconds); the benefit is enormous (git, grep, code review, every existing tool works). MPS pays the inverse trade — the AST is always live, no parse cost — and pays for it by needing MPS to be present for every collaborator. Our consumers (TypeScript developers in 2026) value git diff more than they value the tiny latency saving.
Requirements
FEAT-MICRODSL-18 in assets/features.ts:
- projectionDeclarativeSurfaceShownForFourKinds — the Surface section shows
@Projectionwith kindstext,form,table,diagramand the multi-valued binding. - roundTripContractWithPatchBusStated — the Kernel boundary section names PatchBus as the only mutation channel, with bidirectional text-AST sync.
- textProjectionAsFirstClassDefendedAgainstMps — the article opens with the bet, the MPS aspect referent section spells out the divergences, the Boundary justification section names the cost-benefit.
- boundaryAgainstCustomEditorHostJustified — the Boundary justification section names the declare-vs-render split.
Article 19 picks up with Generator — the source-side counterpart that takes Concepts to emitted target artefacts.