AST, Finder and Passes¶
This page is for people writing tools against RIDDL — generators, IDE plugins, linters, anything that reads a parsed model rather than writing one. It documents three layers:
| Layer | Answers |
|---|---|
| AST | What is in the model? |
| Finder | Where in the model is X? |
| Passes | How do I walk the whole model and produce something? |
Source of truth
Everything here is taken from language/.../AST.scala, Finder.scala and
passes/.../Pass.scala on riddl's release/2 branch. Where the scaladoc
explains why a thing is the way it is, that reasoning is reproduced here
rather than paraphrased away — it is usually the part that saves you time.
The AST¶
RiddlValue, the root of everything¶
Every node in a parsed model is a RiddlValue. It carries four things worth
knowing about before you build anything on top of it.
| Member | Type | Notes |
|---|---|---|
loc |
At |
Where the value was parsed from |
span |
Option[(Int, Int)] |
Character offsets (start, end) into declaringFile |
declaringFile |
Option[String] |
The file that declared this value |
kind |
String |
The node's class name, for messages |
format |
String |
A short rendering, for error messages |
Two of these exist specifically for tools that edit source, and both have a trap attached:
span is character offsets, not line/column. A definition needs a start
and an end, which costs two integers this way and two pairs the other. It is
None when the location is unknown — a value built programmatically, or
rebuilt from a serialization that carried no offsets. If you are editing RIDDL
in place, use this; deriving the range by re-scanning the text is how you
delete the wrong thing.
declaringFile survives FlattenPass. It comes from the parser input the
value was parsed from, not from the enclosing Include wrapper, so it is still
correct after includes are folded away. That is exactly what a multi-file
editing tool needs — which file do I write this change into?
Do not reconstruct provenance yourself
Two tempting approaches are both wrong: reading Include.origin before
flattening, and keying definitions by a synthetic (kind, id, line, col)
tuple — which collides across files. declaringFile is the supported
answer.
Containers, Branches, Leaves and Definitions¶
RiddlValue
├── Container[CV] — holds `contents: Contents[CV]`
│ └── Branch[CV] — a Container that is also a Definition
└── Definition — has an Identifier and metadata
└── Leaf — a Definition with no contents
Parents is Seq[Branch[?]] — the chain from a node up to the root.
isEmpty is semantic, not structural
A container is empty when it holds no definitions; comments do not
count. context C is { // TODO } is a stub, not a defined context, and
treating it as non-empty let it slip past every completeness check.
If you need to know whether there are any children at all — a text
emitter deciding whether to open a brace, say — ask contents.isEmpty
directly instead.
Content accessors¶
Rather than filtering contents by hand, every container mixes in accessor
traits that name what it can hold — WithTypes gives types, WithEntities
gives entities, and so on. There are 35 declarations across 34 names:
comments types constants invariants functions handlers
inlets outlets states groups outputs inputs
statements contexts authors versions copyrights users
epics domains projectors repositories entities streamlets
connectors adaptors sagas sagaSteps cases shownBy
modules fields methods clauses
repositories means two different things
It is the one name declared twice, and the return types differ:
| Where | Returns |
|---|---|
WithRepositories (contexts, domains…) |
Seq[Repository] — the definitions |
Projector |
Seq[RepositoryRef] — the references |
A tool that treats projector.repositories as definitions gets refs, and
the mistake type-checks only if you were not looking.
They see through includes and imports¶
All 35 are implemented with filterThroughWrappers, which descends into
Include and BASTImport wrappers before applying the type test:
def filterThroughWrappers[T <: RiddlValue: ClassTag]: Seq[T] =
val theClass = classTag[T].runtimeClass
def loop(items: Seq[RiddlValue]): Seq[T] =
items.flatMap {
case inc: Include[?] => loop(inc.contents.toSeq)
case bi: BASTImport => loop(bi.contents.toSeq)
case x if theClass.isAssignableFrom(x.getClass) => Seq(x.asInstanceOf[T])
case _ => Seq.empty
}
loop(container.toSeq)
This is a recent change, and it fixed a real hole. Previously
context.entities was empty whenever the entity happened to be written in an
included file, while context.repositories in the same context returned its
one repository — the same model giving different answers depending only on
which file the author typed a definition into. riddl-generator produced 582
files for reactive-bbq without a single entity class in them, and nothing
failed: the model validated, the generator succeeded, and the output merely
had a hole.
The principle settled on: provenance is riddl's bookkeeping, not the reader's. A client asking what is in a container wants the full list; whether a member was written inline, included, or imported is not a distinction any consumer has a stake in.
Do not add your own include walk
If you have code like context.entities ++ context.includes.flatMap(...),
delete the second half. It now returns every included definition twice.
riddl had seven such hand-rolled walks internally — helpers like
getEntities that existed purely to paper over the gap. They are now thin
aliases for the accessor they were compensating for. They remain public
(@JSExport) so nothing breaks, but new code should call the accessor.
Three that deliberately stay literal¶
processors, definitions and vitals still use plain filter and do not
descend wrappers. They are consumed by riddl's own passes, whose callers
already reach included definitions another way, so making them transparent
would double-count rather than fix anything.
includes also stays on filter — a wrapper is matched before the type
test, so filterThroughWrappers is the wrong tool for finding the wrappers
themselves.
Reading and resolving diverge for imports
domain.types reports a .bast-imported type, but a reference to that
type still fails to resolve until an explicit flatten, because the symbol
table is built by traversal rather than by these accessors.
This is defensible — reading is the client's question, resolving is the
model's — but it means a model can name a type its own accessors report.
Whether SymbolsPass should index wrapper contents is an open question in
riddl's backlog.
Entity intentions¶
As of RIDDL 2.0-rc.9, an entity's semantics are keywords before entity
rather than options in metadata (see
Entity Intentions for the language
side). On the AST they are a first-class field:
case class Entity(
loc: At,
id: Identifier,
contents: Contents[EntityContents] = Contents.empty(),
ascribedShape: Option[StreamletShape] = None,
intentions: Seq[EntityIntention] = Seq.empty,
metadata: Contents[MetaData] = Contents.empty()
) extends Processor[EntityContents] with WithStates[EntityContents]:
def hasIntention(intention: EntityIntention): Boolean = intentions.contains(intention)
Ask hasIntention rather than searching metadata for an option. The parser
stores intentions canonically sorted, because Definition.equals
compares the field — write order must never make two otherwise-identical
entities structurally unequal.
The deprecated option spellings are consumed by the parser: they set the intention and are removed from metadata, so a tool never sees both forms.
Finder¶
Finder wraps a container and searches it. Construct one with
Finder(container).
| Method | Returns |
|---|---|
find(select: CV => Boolean) |
every value matching a predicate |
findByType[T] |
every value of a type |
recursiveFindByType[T] |
as above, but descends into nested statements |
findWithParents[T](select) |
matches paired with their Parents |
findParents(node) |
the parent chain of one definition |
findAllPaths |
a HashMap[Definition, Parents] for the whole tree |
findEmpty |
every empty definition, with parents |
findInParents[T](...) |
search upward instead of downward |
transform[TT](select)(f) |
rewrite matching nodes |
findByType and recursiveFindByType cache per type, so repeated queries
for the same type do not re-traverse the tree. The cache lives on the Finder
instance — keep one around rather than constructing a fresh Finder per query.
Finder is @JSExportTopLevel, so it is available from JavaScript as
Finder, as are its methods.
Passes¶
A Pass walks the model once and produces a PassOutput. Passes are how
riddl itself is built — symbols, resolution and validation are all passes — and
how a tool should do any whole-model analysis.
The shapes available¶
| Base class | Use when |
|---|---|
Pass |
You want full control of traversal |
DepthFirstPass |
Depth-first over every value |
HierarchyPass |
You care about entering and leaving containers |
VisitingPass[VT] |
You want a separate visitor object to receive the callbacks |
CollectingPass[ET] |
You are accumulating a Seq[ET] |
HierarchyPass is the one most tools want. Its callbacks:
protected def openContainer(definition: Definition, parents: Parents): Unit
protected def processLeaf(definition: Leaf, parents: Parents): Unit
protected def processValue(value: RiddlValue, parents: Parents): Unit
protected def closeContainer(definition: Definition, parents: Parents): Unit
// Wrappers get their own hooks, all defaulting to no-op:
protected def openInclude(include: Include[?], parents: Parents): Unit = ()
protected def closeInclude(include: Include[?], parents: Parents): Unit = ()
protected def openBASTImport(bi: BASTImport, parents: Parents): Unit = ()
protected def closeBASTImport(bi: BASTImport, parents: Parents): Unit = ()
protected def traverseBASTImportContents(bi: BASTImport): Boolean = true
Note that a Pass traverses wrappers rather than seeing through them — the
opposite of the accessors. That is deliberate: a pass building a symbol table
cares where a definition came from, while a client reading a container does
not. Override traverseBASTImportContents to skip imported content.
Declaring one¶
Every pass has a name, may declare prerequisites, and returns a result:
def name: String
protected final def requires[OPT <: PassOptions](passInfo: PassInfo[OPT]): Unit
def preProcess(root: PassRoot): PassRoot = root
protected def process(definition: RiddlValue, parents: ParentStack): Unit
def postProcess(root: PassRoot): Unit = ()
def result(root: PassRoot): PassOutput
def close(): Unit = ()
PassRoot is Branch[?]. requires is how a pass states that it must run
after another — call it in the constructor.
Running them¶
Pass.standardPasses gives the three that every model needs, in order:
SymbolsPass— builds the symbol tableResolutionPass— resolves path referencesValidationPass— validates
Only run your own pass after these three
A model is not processable until all three have succeeded. Anything you build on an unresolved AST is reading references that do not yet point anywhere.
Two variants exist:
Pass.quickValidationPasses— the same three, but validation in Quick mode, skipping expensive streaming analysis and handler classification. Intended for interactive and LSP use, where speed beats exhaustiveness.standardPasseswith completeness warnings enabled additionally runsMessageFlowPass,EntityLifecyclePass,UseCaseWitnessPassandUseCaseTracePass, in that order — the first two feed the second two. They are gated so a plainvalidatepays nothing for them.
Reading the results¶
PassesResult is what you get back:
| Member | Gives you |
|---|---|
root |
the PassRoot that was processed |
messages |
every message from every pass, plus any added |
symbols |
SymbolsOutput — the symbol table |
resolution |
ResolutionOutput |
validation |
ValidationOutput |
refMap |
ReferenceMap — what each reference resolved to |
usage |
Usages — who uses what |
outputOf[T](passName) |
any other pass's output, by name |
hasOutputOf(passName) |
whether a pass ran |
refMap and usage are shortcuts into resolution, and they are the two most
tools actually want: refMap turns a Reference into the Definition it
points at, which is the step every generator needs and the reason a model must
be resolved before you read it.
Pass the name from the pass's companion object — SymbolsPass.name, not a
string literal.
Related¶
- Design Principles — why the language is shaped this way
- Language Reference — the surface syntax
- EBNF Grammar — the parser's grammar