DSLs GitHub issue

vibecode
{"vibecode": {
    "doc": "dsl",
    "role": "canonical doc on Caspian's DSL architecture — first-class commitment to using the language's own DSL machinery wherever practical",
    "key_concepts": ["four_tier_token_model", "dsl_on_dispatcher_per_yield",
        "virtual_getters_and_setters_via_name_and_name_equals_dispatch",
        "loop_dsl_with_control_and_structural_bwcs", "class_def_is_a_dsl",
        "cheat_clause_when_dsl_is_impractical"],
    "related": ["lucy.md § DSL Receivers", "index.md § Classes",
        "syntax/loops.md"]
}}

Caspian commits to using its own DSL machinery for as much of the language surface as is practical. Things that look like keywords — field, return, break, before, after, pass, commit, etc. — are mostly bare-word commands (bwcs) resolved through a DSL, not entries in the parser's keyword list. The parser handles only what genuinely requires structural parsing.

At a glance — implementing a DSL GitHub issue

The smallest end-to-end example. A function defines a DSL on its dispatcher and yields; the caller passes a do-block that uses the DSL bwcs.

caspian
# Define the function
$logger = function()
    $dispatcher = %call.dispatcher
    $dispatcher.dsl['info']  = $log_handler    # bwc 'info' → $log_handler.info(...)
    $dispatcher.dsl['warn']  = $log_handler    # bwc 'warn' → $log_handler.warn(...)
    $dispatcher.dsl['error'] = $log_handler    # bwc 'error' → $log_handler.error(...)
    $dispatcher.yield
end

# Call it with a do-block; inside, info/warn/error are bare-word commands
&logger do
    info 'starting up'
    warn 'queue is filling'
    error 'fatal'
end

The DSL exists only for the duration of the yield. Outside the block, info/warn/error aren't bound to anything.

The mechanism that backs this is documented in lucy.md § DSL Receivers. This file is about how we use it across the language.

Philosophy GitHub issue

Use the DSL mechanism wherever practical. Parser shortcuts are allowed when they aren't — pragmatism beats purity — but the default direction is toward DSLs.

Why:

The four-tier token model GitHub issue

Every word that shows up in Caspian source falls into one of four tiers. The tier determines whether the word is parser-baked, reserved, or DSL.

Tier 1: Parser-built structural GitHub issue

Multi-token constructs that require structural parsing. Cannot be DSL.

These define block boundaries; the parser needs to know about them to build the AST.

Tier 2: Reserved invariants GitHub issue

Single tokens whose meaning is identity-critical and cannot be overridden by any DSL.

These are language-level constants. A DSL cannot rebind them because doing so would break every assumption the engine makes about boolean and null semantics.

(See object.md § Identity Guarantees for the engine-enforced read-only character that backs this.)

Tier 3: DSL-overridable with system defaults GitHub issue

Single tokens that look like keywords but are really named method calls. They have default bindings in an ambient core DSL every scope inherits; specific scopes can override them.

Canonical members:

break and next are loop-scoped — they're registered by the loop (each, while, etc.) on the dispatcher's DSL before yielding to the block. The code inside the block may or may not get the $loop variable explicitly as a do-block parameter; even when it doesn't, the bwcs break / next route to it. Outside any loop, these have no binding and raise.

Specific scopes can override. A Bryton test runner can rebind return to "add to test report"; a transaction block can rebind raise to "rollback and rethrow." The overrides apply only within the directly yielded block (per lucy.md § DSL Receivers).

Tier 4: Pure DSL GitHub issue

Words that only have meaning inside a specific scope, with no default binding outside it. The classic example is the class body: field / helper / inherits / join / abstract mean something inside a class definition block and nothing outside it.

How DSLs work GitHub issue

A DSL is configured per-yield on the function's dispatcher and travels with the dispatcher into the yielded block. It exists for the lifetime of the yield and goes away when the block returns. This matches the actual scope of a DSL binding — DSLs only apply when a function yields, so the dispatcher (which only exists during a yield) is their natural home.

Underlying machinery: lucy.md § DSL Receivers.

Basic shape GitHub issue

Inside the function, get the dispatcher, set entries on its DSL hash mapping bwc names to receivers, then yield:

caspian
$myfunc = function()
    $dispatcher = %call.dispatcher
    $dispatcher.dsl['foo'] = $bar
    $dispatcher.dsl['gup'] = $baz
    $dispatcher.yield
end

Calling the function with a do-block uses the DSL bwcs inside the block:

caspian
&myfunc do
    foo            # resolves to $bar.foo
    gup            # resolves to $baz.gup
end

When the block runs, bare-word foo resolves to $bar.foo; gup resolves to $baz.gup. The resolution order (reserved bwcs → DSL entries → scope variables) is documented in lucy.md § DSL Receivers.

Virtual getters and setters GitHub issue

The dispatcher treats read and write dispatch as separate keys: name is one entry, name= is another. Wire both to the same receiver and the block reads and writes a value as if it were a local variable, with method calls happening underneath.

caspian
$foo = function()
    $dispatcher = %call.dispatcher

    $bear = some_object
    $bear.height = 300

    $dispatcher.dsl['height']  = $bear
    $dispatcher.dsl['height='] = $bear

    $dispatcher.yield
end

&foo do
    puts height       # calls $bear.height under the hood
    height = 400      # calls $bear.height=
end

This unlocks configuration-style blocks, builder blocks, and scope-like blocks where the block's apparent "local variables" are actually method calls on a backing object. The name= convention is the natural assignment-dispatch pattern — it's already how property setters work on regular objects, so the dispatcher just reuses the same name-to-receiver mechanism for the bwc layer.

No-DSL block accept — just yield GitHub issue

When a function just wants to accept a block without configuring any DSL, the long-form ceremony isn't needed. Bare yield (the Tier 3 bwc for %call.yield) works directly:

caspian
$f = function()
    yield
end

Equivalent to:

caspian
$f = function()
    $dispatcher = %call.dispatcher
    $dispatcher.yield
end

The dispatcher form is for when you need to configure the DSL before yielding. If you don't, bare yield is the right shape.

Sugar to come later GitHub issue

Long-form dispatcher setup is fine for the general case, but for some common patterns (single-receiver DSL, inline DSL declaration, etc.) a more compact form is worth designing. TBD; not blocking.

Loop DSLs GitHub issue

A loop is fundamentally a function that takes a block. Before yielding, it configures its dispatcher's DSL to expose:

  1. Loop-control bwcsbreak, next (overridable per loop type but with sensible defaults).
  2. Structural bwcsbefore, between, after, noloop — block-accepting commands that attach lifecycle hooks to the loop.
  3. Domain-specific bwcs — whatever the loop's purpose calls for (pass/fail/skip for a test runner, commit/rollback for a transaction, step/cache for a builder).

Standard loop example GitHub issue

caspian
$items.each do($item)
    if $item.empty?
        next            # bwc → $loop.next
    end

    if found($item)
        break           # bwc → $loop.break
    end

    puts $item.name
end

Custom loop: test runner GitHub issue

caspian
$tests.run do($test)
    if $test.passed?
        pass            # bwc → %test.pass($test)
    elseif $test.error?
        fail $test.error_message
    else
        skip
    end
end

Custom loop: transaction GitHub issue

caspian
$db.transaction do
    if $balance < $amount
        rollback        # bwc → %tx.rollback
    end
    debit $account, $amount
    credit $other, $amount
    commit              # bwc → %tx.commit
end

Custom loop: build pipeline GitHub issue

caspian
$builder.pipeline do
    step :compile
    step :test, parallel: true
    cache :artifacts
end

Structural blocks as bwcs GitHub issue

The structural blocks before / between / after / noloop (described in loops.md) are themselves bwcs the loop's DSL provides — not parser keywords. Each takes a block:

caspian
$items.each do($item) as $loop
    before do
        $loop.summary = ''
    end

    $loop.summary += $item.name

    between do
        $loop.summary += ', '
    end

    after do
        puts $loop.summary
    end
end

Class definition is a DSL GitHub issue

The class definition block is the canonical tier-4 DSL. Words like field, helper, inherits, join, abstract are bwcs the class-definer's DSL provides — not parser keywords.

class
    inherits 'foo.com/person'

    field :name, class: :string, required: true
    field :age,  class: :number, min: 0
    field :nickname

    method &greet(name:)
        'Hello, ' + name
    end
end

Each of inherits, field, helper is a bwc resolved through the class-definer's DSL. See caspian.md § Classes for the class-DSL command set in detail.

DSLs should be documented GitHub issue

Because DSL commands look like first-class keywords to the reader, the receivers wired into a dispatcher's DSL should come with documentation describing what each method does, what its arguments are, and what scope it applies in. The exact form (a .doc property on each receiver, a separate .md file, an introspection method that lists the dispatcher's (name, receiver, method) triples, all of the above) is TBD; the requirement that it exists is not.

The cheat clause GitHub issue

Parser shortcuts are allowed when DSL would be impractical, costly, or premature. Cheating is expected for:

A cheat is OK if (a) the alternative isn't worth the engineering cost yet, (b) the surface behavior is identical to what the DSL form would produce, or (c) the construct genuinely can't be expressed as a DSL (operator precedence is the strongest example).

When we cheat, we say so — in the doc for the construct and in this file's open questions if a refactor is owed.

Open questions GitHub issue


© 2026 Puck.uno