Langium Grammar
The Langium grammar at packages/shp-checker/src/language/shape.langium defines which text parses into a ShapeModule AST. Its entry rule, ShapeModule, is an optional module name, then import declarations, then any number of top-level declarations; the Declaration rule lists the 15 declaration kinds. The grammar decides only what parses: lowering and rules give the parsed claims their meaning. The syntax users write is documented in Language Syntax.
Location and regeneration
Section titled “Location and regeneration”langium-config.json at the repository root points Langium at the grammar. bun run langium:generate (langium-cli generate) writes three files to packages/shp-checker/src/language/generated/:
ast.ts: the AST node types and theiris*type guards;grammar.ts: the serialized grammar;module.ts: the generated Langium service modules.
language/shape-module.ts is hand-written. Its createShapeServices injects the generated modules into the Langium core services, and parseShapeModule in packages/shp-checker/src/parser.ts uses those services to return either a ShapeModule or parse diagnostics.
Never hand-edit the generated files: change the grammar and regenerate. CI reruns bun run langium:generate and then git diff --exit-code -- packages/shp-checker/src/language/generated, so a stale or edited generated file fails the build.
Keywords are global
Section titled “Keywords are global”Every ID-shaped quoted literal in shape.langium, such as 'role' or 'transform', becomes a keyword everywhere in the language. It can no longer appear as a bare identifier, such as a module-name segment or a lowercase function name: module policy.history and fn role both fail to parse. Keywords are case-sensitive, so PascalCase names such as Policy are unaffected. A rule that must accept a keyword where an identifier is expected lists it explicitly, as ProtectsPropertyKind does with 'description' and RelationKindName does with the prelude relation kinds.
Add every new keyword to SHAPE_RESERVED_WORDS in packages/shp-checker/src/ast-generation-utils.ts. The AST generator escapes generated names that match an entry, so it never emits an unparsable bare keyword. The test “reserved words cover every ID-shaped grammar keyword” in packages/shp-checker/src/checker.guard-syntax.test.ts extracts every quoted literal from the grammar and fails when one is missing from the set.
Grammar decisions to preserve
Section titled “Grammar decisions to preserve”- Grouped context blocks are the only guard-member syntax.
RationaleMemberandMemoryMemberacceptProtectsBlock,GuardsBlock,WhoBlock, andWhenBlock, so there is one canonical on-disk form.lowerContextMemberflattens block entries into the shared context info, and the formatter merges repeated blocks of one kind into one. ProtectsBlockentries are comma-separated. AProtectsEntryhas an optional value, which would otherwise swallow the next entry’s kind. The value-bearing form isprotects { shape PreserveInline }and the valueless form isprotects { description }; both can share one block.ProtectsPropertyKindis'description' | ID. Onlydescription, which is already a keyword, is listed. Listing'shape'would reserve it globally and break identifiers such as the module segments inshape.generated.ast.GuardsBlockentries are self-delimiting. Each starts withon_change('on_change' 'require' ContextTypeName) orforbid('forbid' 'transform' ID), so they need no separator.WhoBlockandWhenBlockare single-valued. Each holds at most oneOwnerDeclorReviewByDecl, matching the single-valued lowering, so the formatter cannot reorder repeated entries into a different winner.transformbelongs toModifyFunctionChange. Amodify fnentry may carryTransformDecl('transform' ID (',' ID)*) after its shape-trait list; the labels feedforbid transformguards.- Rule headers are plain names. The subject of a rule-derived final forbid is introduced by a
when T has TraitNamemember, not by rule-level type parameters. effect candidatestays separate fromeffects complete. A candidate carries generated evidence for review, and authored Shape remains responsible for final effect claims.- Bindings are a language feature, not CI shell logic. A binding is an architecture claim: one surface cannot change without another being reviewed.
Grammar change checklist
Section titled “Grammar change checklist”Make every change in the same branch as the grammar edit:
- Edit
shape.langium, then runbun run langium:generateand commit the regenerated files. - Add each new ID-shaped keyword to
SHAPE_RESERVED_WORDS. - Add each new keyword to the docs highlighter,
docs-site/src/syntax/shape-language.mjs, and toKEYWORD_COMPLETIONSinpackages/shp-checker/src/editor.tswhen users type it. Add hovers for user-facing constructs. - Add parser tests (
packages/shp-checker/src/parser.test.ts) for the accepted and rejected forms. - Update the formatter (
packages/shp-checker/src/formatter.ts) so its output stays canonical and round-trips. - Lower new semantic concepts into the
Model(Fact Lowering). Add a rule only if the syntax has semantic meaning (Rule Evaluation). - Update the AST generator and authoring helpers if they emit the construct.
- Update
docs-site/src/content/docs/reference/language-syntax.mdand this page. TheGrammarDocsbinding requires one of them when the grammar, the generated files,docs-site/src/syntax/**, orshape/language.shapechanges. Every completeshapeblock in the docs must parse; mark an intentional fragmentshape no-verify. - Update
shape/language.shapeif the modeled grammar surface changes, and add or update bindings when the syntax affects docs, CLI behaviour, or another review surface. - Run
bun run langium:generate,bun test,bun run typecheck, andbun run docs:check.CONTRIBUTING.mdlists the full local check sequence.