prisma/psl-ast-layers
>- How to use the PSL syntax tree layers (green tree, red tree, PSL interpreters (contract-psl), helpers inside the psl-parser package, the language server, formatters, or anything else that consumes `parse()` output from @prisma-next/psl-parser.
npx skills add https://github.com/prisma/prisma --skill psl-ast-layers
The PSL parser (packages/1-framework/2-authoring/psl-parser) produces a three-layer syntax tree. Each layer has exactly one job — pick the right one and the code stays lossless, typed, and cheap.
| Layer | Types | Job | Use in consumer code? |
|-------|-------|-----|-----------------------|
| Green tree | GreenNode, GreenToken (syntax/green.ts) | Immutable, position-independent storage. Foundation only. | Never |
| Red tree | SyntaxNode, SyntaxToken (syntax/red.ts, syntax/navigation.ts) | Navigation with offsets and parents: findAncestor(), tokenAtOffset(), nextToken/prevToken, nonTriviaSibling() | Navigation *outside* the current node |
| Typed AST | ModelDeclarationAst, FieldDeclarationAst, … (syntax/ast/) | Structural information about a *known* node via getters (name(), fields(), lbrace(), value()) | Default choice |
Everything is exported from @prisma-next/psl-parser/syntax (and re-exported from the package root). parse(source) returns { document: DocumentAst, diagnostics, sourceFile } — you start in the typed layer.
ModelDeclarationAst, a FieldAttributeAst, …) and want its parts → call the typed getters. Never dig through children yourself.cast: // enclosing model (tests the node itself first, then walks ancestors)
const model = node.syntax.findAncestor(ModelDeclarationAst.cast);
// enclosing model OR composite type — combine casts with any(…)
const owner = node.syntax.findAncestor(
any(ModelDeclarationAst.cast, CompositeTypeDeclarationAst.cast),
);
Sideways and token-level movement all have dedicated helpers — do not hand-roll the walks:
nextSiblingOrToken / prevSiblingOrToken — adjacent element within the same parent (works from both nodes and tokens)token.nextToken / token.prevToken — document order, crossing node boundariesnonTriviaSibling(element, 'next' | 'prev'), skipTriviaToken(token, direction), isTrivia(token) (from syntax/navigation.ts) — trivia-aware movement; never write your own whitespace/comment-skipping loopcast back into the typed layer immediately: // cursor → token: seam-aware, no descendant scanning
const token = document.syntax.tokenAtOffset(offset).leftBiased();
const attr = token?.parent.findAncestor(FieldAttributeAst.cast);
// selection range → smallest enclosing element
const covering = document.syntax.coveringElement(start, end);
tokenAtOffset returns a TokenAtOffset that models the offset-on-a-seam case explicitly — pick leftBiased() or rightBiased() deliberately (completions usually want left, hover often wants right). Reach for a manual descendants() walk only when no offset anchors the search, and even then the loop body's first move is a cast (castExpression(child), ModelDeclarationAst.cast(child), …).
psl-parser itself (parser, GreenNodeBuilder, red-tree internals). If consumer code touches node.green, that's a bug.Every typed AST class exposes readonly syntax: SyntaxNode (the AstNode interface), so switching layers is always one property access away — there is no excuse to stay in the wrong layer.
If a typed AST class lacks a getter for the structure you need, add the getter to the class in syntax/ast/ (test-first, exported via exports/syntax.ts) rather than hand-rolling child iteration at the call site. The helpers findChildToken, findFirstChild, and filterChildren from ast-helpers.ts are the building blocks for those getters — they belong inside AST classes, not scattered through consumer code.
Never round-trip through text: neither printSyntax(node) nor slicing the SourceFile by offsets, followed by string matching / regex / re-parsing. The tree already holds the structure; text extraction throws away parsing work and breaks on comments, whitespace, and escapes.
// BAD: stringify then string-hack
const text = printSyntax(attr.syntax);
const isUnique = text.includes('@unique');
// BAD: slicing the source file by offsets
const raw = source.slice(node.syntax.offset, node.syntax.offset + node.syntax.textLength);
const name = raw.split(' ')[1];
// GOOD: ask the tree
const isUnique = attr.name()?.identifier()?.token()?.text === 'unique';
const name = model.name()?.token()?.text;
Same rule for values: StringLiteralExprAst.value() returns the *decoded* string (escapes resolved, quotes stripped); slicing quotes off raw text yields wrong results for \n, \u…., etc.
printSyntax and SourceFile offsets have legitimate uses — producing output for humans: error-message snippets, formatter output, positionAt for LSP ranges. Extracting *structural facts* from that text is the anti-pattern.
node.green exists so the red tree can do its job. Consumer code must not inspect green children, kinds, or text — green elements have no offsets and no parents, so any information you pull from them is positionally blind and will not survive refactors of the storage layer.
// BAD: peeking into green storage
const first = model.syntax.green.children[0];
if (first?.type === 'token' && first.text === 'model') { … }
// GOOD: red/typed access
const keyword = model.keyword(); // SyntaxToken with a real offset
children(), childNodes(), descendants(), fields(), attributes(), declarations() are lazy generators on purpose. Materializing them just to index or filter allocates for nothing and hides intent.
// BAD: collect then poke
const fields = Array.from(model.fields());
const idField = fields.filter((f) => f.name()?.token()?.text === 'id')[0];
// GOOD: iterate lazily, stop early
let idField: FieldDeclarationAst | undefined;
for (const field of model.fields()) {
if (field.name()?.token()?.text === 'id') {
idField = field;
break;
}
}
If you already know the node is a ModelDeclarationAst, iterating its red children to find tokens or sub-nodes manually re-implements the typed getters — badly.
// BAD: manual token hunt on a known node
let lbrace: SyntaxToken | undefined;
for (const child of model.syntax.children()) {
if (child instanceof SyntaxToken && child.kind === 'LBrace') {
lbrace = child;
break;
}
}
// GOOD: the getter already exists
const lbrace = model.lbrace();
Likewise use field.typeAnnotation(), attr.argList()?.args(), kv.value() — and if the getter you want is missing, add it to the AST class (see above) instead of spelunking.
The same rule applies to navigation: a hand-written ancestor loop, whitespace-skipping loop, or offset-scanning descendants() walk re-implements findAncestor, skipTriviaToken / nonTriviaSibling, or tokenAtOffset / coveringElement. Use the helper.
parse(source) → ParseResult { document, diagnostics, sourceFile }SomeAst.cast(syntaxNode) (returns undefined on kind mismatch), castExpression(node) for expression unions, any(CastA, CastB, …) to combine casts into one predicateastNode.syntaxfindAncestor(cast) (checks self first), ancestors(), parentnextSiblingOrToken / prevSiblingOrToken; trivia-aware: nonTriviaSibling, skipTriviaToken, isTriviatoken.nextToken / token.prevToken (crosses node boundaries); subtree edges: node.firstToken / node.lastTokentokenAtOffset(offset) (seam-aware TokenAtOffset), coveringElement(start, end), endOffset, isInside(offset) / isOutside(offset)sourceFile.positionAt(token.offset) / sourceFile.offsetAt(position) — offsets live only on red SyntaxToken / SyntaxNode, never greenfindChildToken, findFirstChild, filterChildren, any, and the BracedBlock interface (for lbrace()/rbrace() blocks) in syntax/ast-helpers.tsTake prisma/psl-ast-layers from the repository into ~/.claude/skills for personal
use, or into .claude/skills inside a project.
The agent identifies a skill by the name field in its header. Two skills with the
same name cannot sit side by side — one of them will be ignored.