Skip to content

Sub-languages and inline code

Screenplay carries languages it does not itself read. The Projection Declaration Language (PDL) and the Change Data Capture Language (CDL) are the two built-in ones, and an inline block carries a general purpose language for the parts a declaration cannot express.

Two of those sets are open, and they are open in different places. Read this section before assuming which.

Inline languages are open. The five an inline block ships with — csharp, typescript, react, html, sql — are what the surrounding tooling understands end to end: a Stage renders them, an editor highlights them. Hand the compiler a registry and it recognizes more:

var compiler = new ScreenplayCompiler(new ScreenplayLanguageRegistry(["python", "kotlin"]));

A registered block parses, carries its language tag, and holds its text in the syntax tree. The compiler does not read it — whoever registered the language is what makes sense of it, and that boundary is exactly what lets the set be open. Nothing else changes: new ScreenplayCompiler() still recognizes the built-in five and nothing more.

Construct keywords are not open. The words that introduce a construct inside a slice — projection, capture, command, and the rest — are fixed in the compiler. PDL and CDL are built in rather than registered, and a third construct keyword means changing the compiler. The editor is a different story, and the rest of this page is about that.

The executable semantic compiler does not run inline code or file-backed implementations. Reducer transitions with bodies, named rule predicates, command and concept code validations, and inline or file-backed policy predicates bind as opaque ESM v3 attachments. The reference executor cannot run them: execution that depends on their outcomes returns Unsupported. Other code roles, such as command handlers, still block executable admission with PLAY0268. ImplementationRequirements lists each attachment’s RequirementId, owning semantic address and role (with a member discriminator where needed), context and result contract versions, RequiredCapability, AttachmentResolution, inline language or authored file path, and source-map location. The SHA-256 content hash is present only for inline code or file contents supplied by the host. File attachments without host-supplied contents are reported unresolved, never hashed from the path; the binder does not open files. Hosts with a trusted model root can use AttachmentFiles.Load(root, documents) and supply the result to SemanticDocumentSet.Create or an in-memory workspace’s WithAttachmentContents. Only implementation attachments are read, with confined access and warning diagnostics for refusals (#244). PlayFileCompiler and the standalone Tool compile syntax only and do not read attachments.

Use the MCP read-workspace view implementation-requirements to page these gaps at a pinned revision. This is an inventory for tooling, not an implementation contract or an indication that the reference executor can run the code. See MCP for the read arguments.

A host editor can put the dedented inline body into a virtual document. In the C# BodyLines API, each zero-based body line index gives the original one-based line and the column of its first character; add the zero-based UTF-16 character offset within the virtual line to bodyLines[i].column when mapping completion, hover, diagnostics or edits back to the authored document. The MCP view encodes consecutive lines sharing a column as runs; expand each {line, column, count} entry before using a body line index. Check the result against bodySpan before applying edits, particularly across lines or near fences. Tabs count as one column, not a visual tab stop; CRLF takes two source offsets but one line break. A file attachment instead maps to its entire file from line 1, column 1 and has no per-line dedent map. Unresolved files have no known body span. Screenplay emits this mapping data; the host owns the virtual document and language service.

Plugging a new sub-language into the editor

Section titled “Plugging a new sub-language into the editor”

An editor extension registers a construct keyword together with its token rules, completions and hover documentation, so highlighting and IntelliSense compose cleanly.

The editor can highlight the registered construct’s indented body as a sub-language. This does not add a production to the Screenplay grammar.

A construct keyword the compiler does not know inside a slice, including one registered only in the editor, produces warning PLAY0029, and the parser skips its body so later declarations still parse; the document still compiles, but the construct contributes nothing to the model. Unknown words at the top level or in a module or feature body are errors. Editor registration is a reading aid, not compiler acceptance. The TypeScript compiler also skips some known, shipped constructs that it does not yet model; those are not extension keywords.

The @cratis/screenplay-language package has its own registry for editor tooling. A sub-language registers its Monarch token rules and IntelliSense under a construct keyword:

import { registerSubLanguage } from '@cratis/screenplay-language';
registerSubLanguage('workflow', {
tokens: [
[/\b(?:start|step|branch|join|end)\b/, 'keyword'],
],
completions: [
{
label: 'step',
insertText: 'step ${1:Name}',
documentation: 'A step in the workflow.',
},
],
hovers: {
step: 'Workflow — a step executed in sequence.',
},
});
MemberPurpose
tokensMonarch token rules applied inside the construct’s indented body. The host tokenizer switches into these rules when the keyword is encountered (via Monarch’s @push/@pop state stack) and switches back out when a Screenplay construct keyword starts a line. Strings, numbers, operators, context variables, and comments are inherited from the host — a sub-language only declares what is specific to it.
completionsCompletion items offered when the cursor is inside the construct. insertText supports Monaco snippet syntax.
hoversKeyword → one-line documentation, shown on hover inside the construct.

Registration is dynamic: registerSubLanguage can be called before or after register(monaco) — the tokenizer is recomposed on the fly, and every Monaco instance the language service was registered on picks up the new sub-language immediately.

PDL and CDL are registered through exactly this API when register(monaco) runs — see sub-languages/pdl.ts and sub-languages/cdl.ts in the package for the reference implementations.