Decorators

A decorator is an annotation written above a declaration: @block("service"), @doc("the edge tier"), @inline(0). It attaches structured data to the thing below it, and it does nothing on its own. Something else reads it.

That last sentence is the chapter. Everything hard about decorators is the question who reads this one. The schema vocabulary of Schemas is read by the language. A @wdoc.file is read by wdoc. A decorator you declare yourself is read by you. This chapter covers the mechanism all three share, then the handful of decorators whose reader is the language itself, and where each of the rest is documented.

Every file and every command below was run before it was written down. Type them and compare.

§ 1The shape of a decorator

A decorator is @ followed by a name, optionally followed by a parenthesised argument list. The name may be dotted, in which case everything before the last segment is a namespace qualifier.

wcl
@non_empty                          # no arguments, no parentheses
@inline(0)                          # one positional argument
@block("service")                   # a positional string
@children("route", max = 8)         # positional, then named
@wdoc.file("data/services.wcl")     # namespace-qualified

Three rules govern the argument list:

§ 1.1Where a decorator may sit

Eleven positions accept decorators. The language names them in a built-in symbol set, DecoratorPosition, and the whole vocabulary is exactly this:

PositionWhat it annotatesWritten above
:typeA type declarationtype Service { … }
:interfaceAn interface declarationinterface Named { … }
:unionA union declarationunion Scale { … }
:variantOne variant inside a unionSmall { … }
:symbol_setA symbol_set declarationsymbol_set Tier { … }
:symbolOne symbol inside a symbol setgold
:type_fieldA field declared on a type or interfaceport: u32
:connectionA connection declarationconnection DependsOn: …
:fieldA field written in a documentowner = "platform"
:blockA block instance written in a documentservice web { … }
:fnA fn itemfn label(s: utf8) -> utf8 { … }

Note the pair that is easy to run together. :type_field is the declaration port: u32 inside a type; :field is the assignment owner = "platform" inside a document. They are different positions because they are different things, and a decorator may be legal on one and not the other.

One file exercising every position checks green:

positions.wclwcl
@decorator("note")
type Note { @inline(0) text: utf8 }

@note("on a symbol set")
symbol_set Tier {
  @note("on a symbol") gold
  silver
}

@note("on a union")
union Scale {
  @note("on a variant")
  Small { replicas: u32 }
  Large { replicas: u32  shards: u32 }
}

@note("on an interface")
interface Named { name: utf8 }

@note("on a block type")
@block("service")
type Service {
  @inline(0) name: identifier
  @note("on a type field") port: u32
}

symbol_set EdgeKind { uses }

@note("on a connection declaration")
connection DependsOn: Service -> Service : EdgeKind

@document
type Cfg {
  @children("service") services: list<Service>
  owner: utf8
  @connections(DependsOn) deps: list<DependsOn>
}

@note("on a fn item")
fn label(s: utf8) -> utf8 { s }

@note("on a field")
owner = label("platform")

@note("on a block instance")
service web { port = 8080u32 }

service db { port = 5432u32 }

web -> db :uses

The item forms outside that list accept no decorator at all, and each says so at parse time rather than at check time:

text
$ wcl check import.wcl
wcl::parse

  × decorators are not allowed on import statements

$ wcl check namespace.wcl
wcl::parse

  × decorators are not allowed on namespace/use declarations

$ wcl check table.wcl
wcl::parse

  × decorators are not allowed on table headers

§ 2Every decorator is declared

There is no open set of annotations. Writing @stability(:beta) without declaring @stability is a schema violation, not a comment the toolchain shrugs at. A decorator is declared by a type carrying @decorator("name"), and that type's fields are the decorator's arguments.

stability.wclwcl
# stability.wcl — a decorator of your own.

symbol_set Stability { stable  beta  deprecated }

# The decorator is a type. Its fields are the arguments.
@decorator("stability")
@applies_to(on = [:type, :type_field])
type StabilityDec {
  @inline(0) level: Stability   # positional: @stability(:beta)
  since: utf8?                  # named only: since = "0.4"
}

@doc("Everything the platform runs.")
@stability(:beta, since = "0.4")
@block("service")
type Service {
  @inline(0) name: identifier
  @doc("Listening port; must be free at startup.")
  port: u32
  @stability(:deprecated) legacy_port: u32?
}

@document
type Cfg {
  @children("service") services: list<Service>
}

service web { port = 8080u32 }

Read the declaration as a type, because it is one. Each field is one argument slot:

A decorator may appear at most once on one node — @inline(0) @inline(1) a: utf8 is refused, not merged. Declare it @decorator("tag", repeatable = true) when repetition is the point. Exactly one built-in takes that opt-in: @unit, so one numeric alias can carry KiB, MiB and GiB at once.

§ 2.1Six ways to get it wrong

Every mistake below is one edit to the @stability(:beta, since = "0.4") line of stability.wcl, and every message is what wcl check actually printed.

Writtenwcl check says
@stabilty(:beta, since = "0.4")decorator 'stabilty' has no @decorator declaration
@stability(:beta, sinse = "0.4")argument 'sinse' is not declared by decorator schema 'StabilityDec'
@stability(since = "0.4")decorator '@stability' is missing required argument 'level'
@stability(:beta, "0.4")positional argument 2 for decorator '@stability' has no @inline(1) slot in schema 'StabilityDec'
@stability(:experimental)field 'level' declared as symbol_set 'Stability' but ':experimental' is not one of its members
@stability(:beta) @stability(:stable)decorator '@stability' may appear at most once on one node

The fourth row is the one worth reading twice. A surplus positional is not reported as "too many arguments": it is reported as a missing @inline(1) slot, because that is the actual gap. Adding @inline(1) to since would make @stability(:beta, "0.4") legal.

§ 2.2@applies_to: restricting the positions

By default a declared decorator is legal in all eleven positions. @applies_to(on = [...]) narrows that to a list, and kinds = [...] narrows the :block position further to named block kinds.

wcl
@decorator("stability")
@applies_to(on = [:type, :type_field])
type StabilityDec { @inline(0) level: Stability  since: utf8? }

@decorator("oncall")
@applies_to(on = [:block], kinds = ["service"])
type Oncall { @inline(0) rota: utf8 }

Both halves are checked, at the use site and at the declaration:

text
# @stability written above a `service web { … }` instance:
× decorator '@stability' is not applicable in the 'block' position

# @oncall written above a `job nightly` instance:
× decorator '@oncall' is not applicable to block kind 'job'

# `kinds` without the :block position in `on`:
× @applies_to 'kinds' requires the 'block' position in 'on'

# `kinds = ["srevice"]` — a kind no @block declares:
× @applies_to names unknown block kind 'srevice'

A bad declaration is reported once

When @applies_to names a block kind that does not exist, the decorator's every use site stops being checked against kinds. That is deliberate: a misspelt kind is one declaration error, and repeating it at forty otherwise-correct call sites would bury the line that needs fixing.

§ 2.3Namespaced decorators

A decorator declared inside a namespace is reachable both bare and qualified:

lib.wclwcl
namespace ops

@decorator("owner")
type Owner {
  @inline(0) team: utf8
}
use.wclwcl
import "./lib.wcl"

@ops.owner("platform")   # qualified
@block("service")
type Service { @inline(0) name: identifier  port: u32 }

@owner("platform") resolves too — the import brings the namespace into scope. Reach for the qualifier when two libraries declare the same decorator name, or when the reader needs to know whose vocabulary this is. A qualifier that resolves to nothing has its own message: decorator '@nope.owner' has no declaration in the qualified namespace. See Namespaces and imports.

§ 3Three vocabularies, one mechanism

Nothing above distinguished a decorator you declared from one the language ships. That is the design: @block is declared by a @decorator("block") type like any other, and it is registered into every document the same way your @stability is. What differs is only who declares it and who reads it.

VocabularyDeclared byRead byExample
Built-inThe language, in every documentThe language@block, @inline, @children
LibraryA .wcl file you importThat librarywdoc's @only / @except / @wdoc.file
HostA Rust program, through EnvironmentThat programAnything an embedder registers
YoursA @decorator("…") type in your documentYou, by reflection@stability above

The built-in vocabulary is twenty-two decorators. Every one is listed below with the chapter that covers it, because most of them are somebody else's chapter — this one owns only the rows marked here.

DecoratorWhat it saysCovered in
@documentThis type is the document-root schemaSchemas
@block("kind")This type schemas a block of that kindSchemas
@table("kind")This type schemas a pipe-table rowDocuments, fields and blocks
@child("kind")This field holds one nested blockSchemas
@children("kind")This field holds a list of nested blocksSchemas
@inline(N)This field is filled by positional slot NSchemas
@default(expr)The value this field takes when omittedSchemas
@ref("kind")This field names a block of that kindSchemas
@min(v) / @max(v)Numeric bounds on this field's valueTypes
@non_emptyThis string or list may not be emptyTypes
@unit(name, factor)A unit suffix on a numeric alias (repeatable)Values and primitives
@connections(S)This field gathers -> statements as S recordsConnections, here
@schemalessDo not validate this nodeSchemas
@dynamicThis connection's endpoints need not name literal blocksConnections
@decorator("name")This type declares the @name decoratorHere
@applies_to(on = […])Which positions that decorator is legal inHere
@doc("text")Documentation metadataHere
@by_refA block of this kind reifies as a reference, not a copyHere
@contextualThis block's placement and children come from contextHere
@declares_kind(…)Instances of this type declare block kinds of their ownHere
@block_slotA type used as a slot marks a nested-block holeHere

The comparison that matters: describe or change

Split the twenty-two by what the language does with one. Most of them describe a shape: @block, @child, @children, @inline, @default, @table, @document, @ref, @min, @max, @non_empty, @unit. The language reads every one, and what it reads is a fact about the declaration it sits on. Five of them change how the language handles the node: @schemaless turns validation off, @by_ref changes what reification produces, @contextual changes where a block may sit and where its children come from, @block_slot changes what a slot derives to, and @declares_kind makes an instance declare kinds. Writing one of those is not annotating a declaration; it is changing how every instance of it is placed, checked or reified. And exactly one built-in is read by nobody: @doc. The language declares it and never looks at it, so documentation metadata has one agreed spelling — which is the shape a library vocabulary takes too. wdoc declares @only, @except and @wdoc.file; wdoc, not the language, reads them.

Four of the five changers get a section below. @schemaless is the fifth, and it belongs to Schemas beside the checks it switches off. @doc and @connections get one each as well — the first because it is the decorator you will write most, the second because it is the one whose field is populated by statements rather than by a value.

§ 4@doc

@doc("text") attaches documentation to a declaration or one of its fields. It sits beside the doc comment — the unbroken run of # or // lines above a declaration that Documents, fields and blocks covers — and the two are read by two different builtins.

stability.wcl above already carries both, one on the type and one on port. Give its @document schema two more fields and read them back:

wcl
@document
type Cfg {
  @children("service") services: list<Service>
  type_doc:  utf8
  field_doc: utf8
}

type_doc  = decorator_arg(Service,      "doc", "text")
field_doc = decorator_arg(Service.port, "doc", "text")
text
$ wcl get stability.wcl type_doc
"Everything the platform runs."
$ wcl get stability.wcl field_doc
"Listening port; must be free at startup."

Which to reach for is a question about the reader. A doc comment is prose for a person reading the file; doc_comment() reads it back for a hover card or a generated page. @doc is an argument — a checked utf8 slot on a declared decorator, evaluated like any other expression — so reach for it when the text is data your program consumes rather than a note to the next author.

§ 5@by_ref

When a block is reified — turned into a record so an expression can consume it — its nested blocks are reified with it, in place. @by_ref on a block type changes that: a @child or @children slot of that kind becomes a resolvable reference to the content instead of a copy of it.

Run the difference. This file reifies its card blocks with map:

byref.wclwcl
@by_ref @block("body")
type Body {
  text: utf8
}

@block("card")
type Card {
  @inline(0) name: identifier
  title: utf8
  @child("body") body: Body
}

@document
type Cfg {
  @children("card") cards: list<Card>
  @schemaless first: list<Card>
}

card intro {
  title = "Welcome"
  body { text = "the long form" }
}

first = map(cards, fn(c: any) -> any { c })
text
$ wcl get byref.wcl first
[Card { body: &cards.intro.body<block>, name: intro, title: "Welcome" }]

# ...and with the @by_ref removed from Body:
$ wcl get byref.wcl first
[Card { body: Body { text: "the long form" }, name: intro, title: "Welcome" }]

&cards.intro.body<block> is an address, not content. The consumer decides when — and whether — to resolve it. That is the point: a long body reified into every record that mentions it is duplicated work and duplicated storage, and a consumer that renders the body once and links to it wants the address, not forty copies.

§ 6@connections

@connections(S) on a field says: gather this scope's -> statements and project each one as a record of the connection schema S.

graph.wclwcl
symbol_set EdgeKind { uses }

@block("service")
type Service {
  @inline(0) name: identifier
  port: u32
}

connection DependsOn: Service -> Service : EdgeKind

@document
type Cfg {
  @children("service")    services: list<Service>
  @connections(DependsOn) deps:     list<DependsOn>
}

service web { port = 8080u32 }
service db  { port = 5432u32 }

web -> db :uses
text
$ wcl get graph.wcl deps
[DependsOn { destination: db, kind: :uses, source: web }]

The decorator is the whole of the wiring: it names which connection declaration this field collects, and the arrow statements supply the edges. Connections covers the connection declaration, the endpoint types, the edge-kind symbol set and @dynamic.

§ 7@contextual

A nested block is normally legal only where its parent's schema declares it as a child kind. @contextual on a @block type lifts that rule for the kind: a contextual block is legal wherever nested blocks are legal at all, and no parent has to name it.

The kind that needs this is the one whose meaning is repetition, or instantiation — a block that is not content itself but produces content. Its body cannot be validated where it sits, because the body only means something once it is expanded with bindings.

deck.wclwcl
@block("repeat") @contextual
type Repeat {
  @schemaless each: list<utf8>
  as: symbol
}

@block("card")
type Card {
  @inline(0) name: identifier
  title: utf8
}

@block("deck")
type Deck {
  @inline(0) name: identifier
  @children("card") cards: list<Card>
}

@document
type Cfg {
  @children("deck") decks: list<Deck>
}

deck main {
  card literal { title = "authored" }
  repeat { each = ["one", "two"]  as = :m
    card $"g_${m}" { title = $"generated ${m}" }
  }
}

Deck declares one child kind, card. It never mentions repeat, and wcl check prints OK. Drop the @contextual and the same file fails:

text
$ wcl check deck.wcl
OK

# with `@contextual` removed from Repeat:
$ wcl check deck.wcl
wcl::eval::schema_violation

  × block kind 'repeat' is not allowed inside 'deck'

Placement is only half. @contextual also marks the block as one whose children are generated, and the language does not know how. It cannot: "iterate each, bind each element to the symbol named by as" is behaviour, and behaviour belongs to whoever defined the vocabulary. So the host registers an expander — a Rust callback on the Environment — and the language consults it when it projects a @children("card") slot, so the generated cards land in the slot exactly like the authored one.

A decorator declares that, never how

Demanding a @contextual block's generated children from a document opened with no registered expander is a hard error naming the kind. The language declines to guess. Parsing and formatting never demand them, which is why deck.wcl above checks green with no host in sight: wcl check, wcl parse and wcl fmt never ask what the repeat expands to. Anything that evaluates the expansion must open the document with the host's environment.

§ 8@declares_kind

Here is new ground, and it is worth naming as such. Everything so far attached data to a declaration. @declares_kind attaches data to a declaration whose instances declare block kinds of their own.

It is how a host adds a template-like concept — a component, a widget, a macro — without the language ever learning that concept's name. The author writes one component block; from then on metric_card is a block kind like any other.

components.wclwcl
# components.wcl — one block declares a kind, and instances of it are checked.

@block("component")
@declares_kind(name = 0, params = "slots", body = "body")
type Component {
  @inline(0) name: identifier
  @children("slot") slots: list<Slot>
  @child("body")    body:  ComponentBody
}

@block("slot")
type Slot {
  @inline(0) name: identifier
  default: utf8?
}

@block("body") @schemaless
type ComponentBody {}

@block("page")
type Page {
  @inline(0) name: identifier
}

@document
type Site {
  @children("component") components: list<Component>
  @children("page")      pages:      list<Page>
}

# The declaration...
component metric_card {
  slot label
  slot status { default = "ok" }
  body { text = "a card" }
}

# ...and an instance of the kind it declared.
page dash {
  metric_card { label = "CPU" }
}

The decorator takes three arguments, all of them pointing back at the declarer's own schema:

ArgumentMeansDefault
name = NWhich @inline(N) label of the declarer carries the kind's name0
params = "field"The declarer field holding its parameter blocksrequired
body = "field"The declarer field holding the template bodyoptional

body is carried, never read. What an instance expands to is the host's expander again — see @contextual — and the derived schema below carries @contextual for exactly that reason.

§ 8.1The derived schema

metric_card is not declared by any type. Kind lookup falls back to a schema derived from the declarer: one field per param block, optional when the param carries a default, required otherwise. From there an instance is an ordinary block, checked by the ordinary checks.

text
# metric_card { labl = "CPU" }
× field 'labl' is not declared by schema 'metric_card'
× block 'metric_card' is missing required field 'label'

# metric_card { }
× block 'metric_card' is missing required field 'label'

Both messages name metric_card as the schema, and neither the message nor the code that produced it knows the word "component". That is the whole point: the host contributed a vocabulary, and the language's generic checks apply to it.

Membership, not types

An instance of a declared kind gets exactly two checks: every field it writes is a declared param, and every required param is written. The value in a param is not type-checked. slot count: u32 filled with count = "three" passes wcl check. The declared type is preserved on the derived schema so a host can read it; deciding what a param may hold is that host's business, because it is the host that binds it.

§ 8.2@block_slot

A param may be declared with a type: slot title: utf8. One type marker changes what that means. A type carrying @block_slot is not a scalar parameter at all — it is a nested-block hole, and the derivation skips it.

wcl
@block_slot
type Content {}

component panel {
  slot title: utf8      # a scalar param — a field of the derived schema
  slot count: u32       # likewise
  slot inner: Content   # a hole: not a field, filled with blocks
  body {}
}

page panels {
  panel {
    title = "CPU"
    count = 3u32
    inner { text = "anything the host understands" }
  }
}

title and count become fields of the panel schema. inner becomes nothing the language checks: what may go in the hole, and what it means when it gets there, is the host's contract, resolved when it fills the slot. The marker exists so that contract stays host-neutral — the language never learns that wdoc calls its hole type content.

§ 8.3Three consequences to respect

A derived schema is not a declaration, and three things follow from that. A consumer that gets any of them wrong fails quietly rather than loudly.

The second is the one you can watch. Add two fields to components.wcl and ask for each. Component is a declaration and reflects; metric_card is a derived schema and does not resolve as a name at all:

wcl
component_kind = decorator_arg(Component,   "block", "name")
card_kind      = decorator_arg(metric_card, "block", "name")
text
$ wcl get components.wcl component_kind
"component"
$ wcl get components.wcl card_kind
wcl::eval::unresolved_reference

  × unresolved reference 'metric_card'

The third is the one that bites in a build. Add a type that declares the same kind — @block("metric_card") type MetricCard { label: utf8 }, with a @children("metric_card") slot on Page so it is placed — and the one message names both halves:

text
$ wcl check components.wcl
wcl::eval::schema_violation

  × block 'component' declares the kind 'metric_card', which collides with the
  │ @block/@table kind "metric_card" declared by type 'MetricCard' — rename it

§ 9Reading decorators back

Decorators are data, and a document can read its own. Four builtins do it, all of them taking a reference — a declaration's name, or a dotted path to one of its fields. Give stability.wcl from Every decorator is declared three more fields, replacing its @document schema with this one:

wcl
@document
type Cfg {
  @children("service") services: list<Service>
  service_level: Stability
  service_since: utf8
  service_decs:  list<utf8>
}

service_level = decorator_arg(Service, "stability", "level")
service_since = decorator_arg(Service, "stability", "since")
service_decs  = decorator_names(Service)
text
$ wcl get stability.wcl service_level
:beta
$ wcl get stability.wcl service_since
"0.4"
$ wcl get stability.wcl service_decs
["doc", "stability", "block"]

Read service_decs closely. The list holds @doc, @stability and @block, in source order — a built-in decorator and one of your own come back through the same call, because there was never a difference between them to preserve.

BuiltinAnswers
decorator_names(&T)Every decorator name on a declaration, in source order
decorator_arg(&T, dec, slot)One resolved argument slot, or none when absent
decl_info(&T)A record describing the declaration: its name, its kind, its doc comment (not its @doc), and its block / table / decorator / document classification
decorators_for_kind(kind)References to every decorator schema applicable to a block kind

decorator_arg reads the resolved slot, not the syntax: @block("service") answers "service" for the name slot whether it was written positionally or as name = "service", and an absent slot with a declared default answers the default. Builtins documents all four in full, and doc_comment beside them.

These builtins take a reference, not a value

decorator_names(Service) works. decorator_names(services.web) does not — a block reached through a gather field has already reified to a record, and the call fails with expected data path (e.g. a type or block reference), found record. Nor does a member access chain directly off one of these calls: bind the result to a let first, then read the field.

§ 10Declaring decorators from a Rust host

A Rust embedder registers its vocabulary on an Environment, which is merged into every document opened with it. TypeBuilder, TypeFieldBuilder and DecoratorBuilder build the declarations programmatically — a host-registered @decorator type is the same declaration a .wcl file would have written, and nothing downstream can tell them apart.

On the reading side, TypeDecl::decorators() and the Decorator view mirror the builtins above: resolved_arg_value is what decorator_arg calls, declares_kind() reads the @declares_kind contract as a struct, and Document::derived_block_schema / TypeDecl::is_derived are the two entry points to a schema that is not a declaration. Environment::set_expander registers the callback @contextual needs.

The API surface changes with the crate, so it is documented where it lives rather than here. Run cargo doc --open and read wcl_lang::Environment, wcl_lang::doc::TypeDecl and wcl_lang::doc::Decorator.

§ 11Where to go next