Skip to main content
Welcome. This site supports keyboard navigation and screen readers. Press ? at any time for keyboard shortcuts. Press [ to focus the sidebar, ] to focus the content. High-contrast themes are available via the toolbar.
serard@dev00:~/cv

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 {}

@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)];
}

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 @Format declarations 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/formatting and textDocument/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.

⬇ Download