C+
Language · View as Markdown

Builder blocks

A builder block is @ctx { ... }, an expression that builds a value declaratively. It is the one piece of dedicated DSL syntax in C+, and it was the final language feature: as of v0.0.22 the language surface is frozen, and everything else grows in packages. @ was previously an invalid character, so no existing source changes meaning.

A block holds item expressions, leading-dot modifier lines that apply to the item above them, let setup bindings, and nested blocks:

import "ui/view" as view;

let panel = @view {
    let title = "Settings";

    view::text(title)
        .font = bigger          // modifier: assign a field on the item above
        .color(blue)            // modifier: call a method on it

    view::spacer(8)
};

It is not magic — it lowers to a protocol

There are no macros and no compiler-blessed UI types. A builder block lowers to ordinary calls against a fixed protocol the context package provides:

  • ctx::Builder::new() starts the block.
  • each item becomes a temporary with its modifiers applied (__i.font = v, __i.color(args)), then builder.add(__i).
  • builder.finish() is the block's value.

So any package that ships a Builder (new / add / finish), an item type, and constructor functions becomes a construction DSL. Here is a complete one:

// ui/view.cplus — a context package
pub struct Node { pub value: i32, pub weight: i32 }

pub fn text(v: i32) -> Node { return Node { value: v, weight: 1 }; }

impl Node {
    pub fn boost(mut self, by: i32) { self.weight = self.weight + by; return; }
}

pub struct Builder { sum: i32 }

impl Builder {
    pub fn new() -> Builder { return Builder { sum: 0 }; }
    pub fn add(mut self, item: Node) { self.sum = self.sum + item.value * item.weight; return; }
    pub fn finish(move self) -> Node { return Node { value: self.sum, weight: 1 }; }
}
let tree = @view {
    view::text(8)
        .weight = 2          // field assignment modifier
    view::text(3)
        .boost(1)            // method modifier
};
// lowers to: new(); t1 = text(8); t1.weight = 2; add(t1);
//            t2 = text(3); t2.boost(1); add(t2); finish()

Contextual name lookup

Inside @view { ... }, a bare item name resolves through the context, so text(...) means view::text(...) with no qualification. Precedence is locals → same-file top-level → contextual: a let or a same-file function of the same name shadows the package member. Item field and method names in modifiers (.font, .boost(...)) are never contextual. Because the rewrite produces real view::text references before the graph is built, code-graph and LSP navigation resolve them automatically.

Containers and control flow

A bare name { ... } inside a block is a container element of the same context: vstack { ... } builds view::vstack, and its children resolve in view too. A container takes a filled Builder (fn vstack(b: Builder) -> Node), so the whole feature lowers to new/add plus a finisher — the compiler's output never names a collection type, which is why DSL packages work even on targets where Vec is gated.

if/for are collection control flow, Flutter-style: their items add into the same builder. if needs no else.

let menu = @view {
    view::text(header)

    vstack {
        for item in items {
            view::row(item)
        }
        if logged_in {
            view::button(logout)
        }
    }
};

What the block rejects

Modifier lines are line-oriented: a .name that starts a line attaches to the current item, while a same-line .name is ordinary postfix access. Inside call arguments, indexing, grouping parentheses, and nested expression blocks the rule is off, so wrapped subexpressions are unaffected.

A few shapes are parse-time errors, reported on the offending DSL line: a modifier with no current item (including right after a let), and return / break / continue / yield / await / loops / defer / guard inside a block. A nested different @-DSL is rejected too — use a same-context container instead. Because the desugar reuses your spans, ordinary sema diagnostics land where you wrote them: a wrong item type at the item line, an unknown modifier field at the modifier line, a context without a Builder at the @ctx line.

cpc fmt keeps @ctx glued to its block and round-trips the whole thing — containers and if/for included.

See the worked example, Build a construction DSL, for a package and a block you can compile and run.