DSLs GitHub issue
- At a glance — implementing a DSL
- Philosophy
- The four-tier token model
- How DSLs work
- Loop DSLs
- Class definition is a DSL
- DSLs should be documented
- The cheat clause
- Open questions
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.
# 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:
- Consistency. Fewer "magic words." Everything you can do is a method on something.
- Extensibility. Library code can introduce control-flow-shaped commands (transaction
commit, test runnerpass, builderstep) without inventing new syntax. - Pedagogy. "Everything is a method call on a receiver" is one rule; "here's a list of 40 keywords" is 40 rules.
- Eating our own dog food. The DSL machinery has to be good enough to power Caspian's own definition. That's the proving ground.
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.
if/elsif/elseif/else/endwhile/do/endclass/endfunction/end
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.
true,false,null
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:
return→%call.returnyield→%call.yieldbreak→$loop.break(where$loopis the loop's manager variable, registered as a DSL bwc by the enclosing loop)next/continue→$loop.next(same)raise→%exception.raisecatch/heed→%exception.catchexit→%process.exit
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:
$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:
&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.
$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:
$f = function()
yield
end
Equivalent to:
$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:
- Loop-control bwcs —
break,next(overridable per loop type but with sensible defaults). - Structural bwcs —
before,between,after,noloop— block-accepting commands that attach lifecycle hooks to the loop. - Domain-specific bwcs — whatever the loop's purpose calls for (
pass/fail/skipfor a test runner,commit/rollbackfor a transaction,step/cachefor a builder).
Standard loop example GitHub issue
$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
$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
$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
$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:
$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:
- Operator precedence. Infix operators like
+,-,*,==,&&need precedence rules in the parser; the parser resolves them, even if the underlying operation routes through DSL-style methods at runtime. - Logical word operators.
and,or,nothave precedence and short-circuit semantics; parser-level for now, even if their dispatch could be DSL-resolvable underneath. - V0.01 walking-skeleton expedience. The V0.01 parser bakes class-body keywords (
field,helper,inherits,join) into its keyword table rather than going through a DSL receiver. This is a deliberate shortcut to keep V0.01 small; the refactor to pure-DSL handling is tracked separately.
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
- Loop-control scope. Settled:
break/nextare loop-local — each loop type registers them on the dispatcher's DSL before yielding, pointing at the$loopmanager variable. There's no ambient%loop. Open follow-up: should there be a shared helper / mixin that loop authors can include so they don't repeat the boilerplate of registeringbreakandnextthemselves? self. Identity-critical (tier 2, reserved) or DSL-overridable (tier 3)? Probably reserved given its role in method dispatch, but worth being explicit.- DSL documentation shape.
.docproperty, separate.mdfile, introspection method, all of the above? Pick a convention so DSLs are uniformly discoverable. - DSL-stacking semantics. When a loop body opens another loop, the inner loop's DSL takes priority over the outer's for name collisions. Per lucy.md § DSL Receivers, DSL settings don't propagate down the call stack; the dispatch chain at any point is "innermost DSL → ... → ambient core DSL → scope variables." Worth a worked example with intentional collision.
- Parser-refactor schedule for V0.01 cheats. The class-body keyword shortcut in
parser.luaneeds a refactor pass; when does that land — V0.02, V0.1, or later?