Part 15 — Formatter micro-DSL: per-Concept rules, deterministic pretty-printing
The Formatter micro-DSL produces deterministic textual layout: given an AST, there is exactly one canonical text representation, byte-for-byte. The deterministic property is what makes "format on save" safe (no merge conflicts on whitespace), what makes the text projection round-trippable (article 18), and what lets the Generator micro-DSL (article 19) emit text that survives idempotent regeneration.
The rules are declared per Concept: how a Feature is laid out in source, how its child AcceptanceCriterion items are indented, how @ReferenceLink siblings are ordered. The Formatter and the Projection micro-DSL share the same rules — the text projection's pretty-printer is literally the Formatter, called from a different host. This sharing prevents drift between "format on save" and "open in projection-text" producing different bytes.
Concern
The author has a Feature declaration that has been edited many times: properties added in different orders, blank lines accumulated, child AcceptanceCriterion items in arbitrary positions. They invoke "Format Document". The expected result: a canonical layout where properties are ordered as declared on the Concept, child items are sorted by a Concept-declared key, blank lines normalised, indentation aligned. Two authors who format the same AST produce the same bytes; two PRs that format the same AST diff in zero lines.
Hand-writing this requires implementing textDocument/formatting, walking the AST, emitting tokens in canonical order, handling indentation, handling line breaks. The Formatter micro-DSL collapses this into @Format declarations per Concept.
The Surface
import { Format, FormatRule } from '@frenchexdev/ide-dsl-formatter';
import { Concept, Property, ChildLink } from '@frenchexdev/ide-dsl-kernel';
@Concept({ id: 'cmf.req.Feature' })
@Format({
template: ({ id, title, priority, scheduled, acceptance, notes }, fmt) => fmt.lines(
`@Satisfies(${title})`,
`export abstract class ${id}Feature extends Feature {`,
fmt.indent(
`readonly id = '${id}';`,
`readonly title = '${title}';`,
`readonly priority = Priority.${priority};`,
scheduled ? `readonly scheduled = new Date('${scheduled.toISOString()}');` : null,
'',
...acceptance.map(ac => fmt.invoke(ac)),
...notes.map(note => fmt.invoke(note)),
),
`}`,
),
})
export class Feature {
// properties as before
}
@Concept({ id: 'cmf.req.AcceptanceCriterion' })
@Format({
template: ({ id, title }, fmt) => fmt.lines(
`abstract ${id}(): ACResult; // ${title}`,
),
sortKey: ac => ac.id,
})
export class AcceptanceCriterion {}import { Format, FormatRule } from '@frenchexdev/ide-dsl-formatter';
import { Concept, Property, ChildLink } from '@frenchexdev/ide-dsl-kernel';
@Concept({ id: 'cmf.req.Feature' })
@Format({
template: ({ id, title, priority, scheduled, acceptance, notes }, fmt) => fmt.lines(
`@Satisfies(${title})`,
`export abstract class ${id}Feature extends Feature {`,
fmt.indent(
`readonly id = '${id}';`,
`readonly title = '${title}';`,
`readonly priority = Priority.${priority};`,
scheduled ? `readonly scheduled = new Date('${scheduled.toISOString()}');` : null,
'',
...acceptance.map(ac => fmt.invoke(ac)),
...notes.map(note => fmt.invoke(note)),
),
`}`,
),
})
export class Feature {
// properties as before
}
@Concept({ id: 'cmf.req.AcceptanceCriterion' })
@Format({
template: ({ id, title }, fmt) => fmt.lines(
`abstract ${id}(): ACResult; // ${title}`,
),
sortKey: ac => ac.id,
})
export class AcceptanceCriterion {}@Format declares: a template function returning a layout structure built from fmt.lines, fmt.indent, fmt.invoke (recurse into a child Concept's formatter), plus optional sort keys for @ChildLink collections. The template receives the typed Concept instance plus a fmt helper exposing the layout primitives. The deterministic property is built into fmt: fmt.indent always uses two spaces; fmt.lines always joins with \n; fmt.invoke recurses via the registered child formatter; sort keys are stable.
The output is a single string per file — the canonical text — produced by formatting the file's root Concept and recursing into its children. The Formatter never produces partial layouts; "format range" requests format the smallest enclosing Concept and emit a text edit replacing the original range.
Kernel boundary
Reads:
- The Structure Model — to find child formatters.
- The current document AST — to format.
- The
LanguageRegistry— to scope the format rules per language.
Writes:
- Through the LSP host's text-edit response when invoked by
textDocument/formatting. - Through the Projection micro-DSL when it emits the text projection of a node.
The Formatter is a function over the AST; it never mutates state. The text edits it produces go through the LSP-standard channel; the AST-side mutation (when the user accepts the format) is then re-parsed and the AST updates through the standard load path.
Emitted artefacts
// LSP server registration (generated, in server/handlers/formatting.generated.ts)
import { TextEdit, DocumentFormattingParams } from 'vscode-languageserver';
import { kernel } from '@frenchexdev/ide-dsl-kernel';
import { formatRegistry } from './_registry.generated';
export async function handleFormatting(params: DocumentFormattingParams): Promise<TextEdit[]> {
const doc = kernel.getDocument(params.textDocument.uri);
if (!doc) return [];
const canonical = formatRegistry.formatDocument(doc);
const original = doc.text;
return canonical === original ? [] : [TextEdit.replace(doc.fullRange, canonical)];
}// LSP server registration (generated, in server/handlers/formatting.generated.ts)
import { TextEdit, DocumentFormattingParams } from 'vscode-languageserver';
import { kernel } from '@frenchexdev/ide-dsl-kernel';
import { formatRegistry } from './_registry.generated';
export async function handleFormatting(params: DocumentFormattingParams): Promise<TextEdit[]> {
const doc = kernel.getDocument(params.textDocument.uri);
if (!doc) return [];
const canonical = formatRegistry.formatDocument(doc);
const original = doc.text;
return canonical === original ? [] : [TextEdit.replace(doc.fullRange, canonical)];
}The handler returns an empty array if the document is already canonical (no edits needed) — this preserves modification times when nothing has actually changed.
Composition with peers
- Projection (article 18) — the text projection's renderer is the Formatter; the same
@Formatdeclarations drive both surfaces. Sharing the rule set prevents drift. - Generator (article 19) — emitters often produce TypeScript, which the Generator can format through ts-morph's printer. For DSL-shaped outputs, the Generator may invoke the Formatter to canonicalise the text before writing.
- Syntax (article 07) — orthogonal: Syntax decorates tokens with colour; Formatter decides where the tokens go.
- LSP host (article 20) — routes
textDocument/formattingandtextDocument/rangeFormatting.
MPS aspect referent
MPS Editor aspect's cell layout language. MPS authors declare cell layouts that the projectional editor renders directly; the same layout, when serialised to text (when MPS is asked to export), produces the textual rendering. We adopt the layout-rules-are-data-on-the-Concept idea, drop the cell model (we render straight to text), and share the rules between formatter and text projection.
Boundary justification
Why not in Syntax? Syntax decorates tokens; Formatter places tokens. Coupling them would force every grammar change to touch every format rule. Splitting them lets the grammar and the layout evolve independently — the regex for featureId does not change when the indentation rule for Feature changes.
Why not in the kernel? Layout rules are consumer-facing (different DSLs prefer different layouts) and VSCode-shaped in their consumption (TextEdit, DocumentFormattingParams). The kernel cannot host them without violating criterion 3 from article 02. Keeping the layout per-DSL also lets a different host (a CLI formatter, a CI formatter check) reuse the rules with its own surfacing.
Requirements
FEAT-MICRODSL-15 in assets/features.ts:
- formatRuleDeclarativeSurface — the Surface section shows
@Format({ template, sortKey }). - determinismGuaranteeStated — the article opens with the byte-for-byte canonical claim; the Surface section names the determinism baked into
fmt. - integrationWithProjectionTextExplained — the Composition with peers section spells out the shared rule set with article 18.
- boundaryAgainstSyntaxJustified — the Boundary justification section names the decorate-vs-place argument.
Article 16 picks up with Symbols, the structural-navigation surface.