METAPROGRAMMING
Source files can extend their own grammar¶
A Capy source file isn't limited to the functions its library
declares. With define NAME ... end blocks, the source itself
can introduce new patterns mid-file. The rest of the source — and
any @imported file — can then use them.
This is "macros, but typed" — every define is a real Capy function
with the same template, type-validation, and run-block machinery
as a library-defined one.
A worked example¶
The library here is deliberately minimal — one function, print.
Everything else is declared inline by the source:
lib.capy (5 lines of useful content):
extension md
function print
arg literal "print"
arg capture text string
write `${unquote text}
`
end
script.capy (extends the grammar with three new patterns):
define heading
arg literal "heading"
arg capture text string
write `# ${unquote text}
`
end
define quote
arg literal "quote"
arg capture text string
arg capture who string
write `> ${unquote text}
>
> — *${unquote who}*
`
end
define checklist_item
arg literal "todo"
arg capture done ident
arg capture text string
if eq done "yes"
write `- [x] ${unquote text}
`
else
write `- [ ] ${unquote text}
`
end
end
heading "Today's todos"
todo yes "Ship metaprogramming"
todo no "Update the docs"
quote "Description over implementation." "Capy"
Output:
Today's todos - [x] Ship metaprogramming - [ ] Update the docs Description over implementation.
— Capy
Full sample → samples/metaprogramming/
The define block¶
define NAME ... end has the same body shape as a function
declaration in a .capy library file:
| Inside a define block | Meaning |
|---|---|
arg literal "TEXT" |
Match a literal token in the source. |
arg capture NAME TYPE |
Capture one typed value. |
write \...`| Emit text (multi-line backticks,${EXPR}` interpolation). |
|
set / append / prepend / merge / delete |
Mutate context.*. |
if / else / for |
Control flow inside the body. |
block_closer NAME |
This function opens a block, closed by NAME. |
block_open "{" close "}" |
Or: explicit delimiter-pair blocks. |
priority N |
Disambiguation when two functions overlap. |
The full reference is in library authoring;
everything that works in a library function works in a define.
Rules¶
- Top-level only.
definemust be at column 0; matchingendalso at column 0. Indenteddefineis treated as regular content (won't be picked up as a meta-block). - Defined before use. The pre-pass scans the WHOLE file before
parsing, so a
defineat the bottom of the file is still visible at the top. But for readability, conventionally put defines at the top. - Source wins on conflict. If the library declares
function fooand the source declaresdefine foo, the source version wins. Use this to specialize without forking the library. - Identifier names only.
define greetworks;define "weird name"doesn't — function names must be valid identifiers because the source-side parser couldn't tokenize them anyway. @importcomposes. Defines from imported files are visible in the importing file. Put shared metaprogramming in acommon/directory and@importit.
When to use it¶
| Pattern | Right tool |
|---|---|
| Repeating boilerplate in one source file | define |
| Used across many sources, same project | shared .capy file + @import |
| Used across many projects | library-level function in lib.capy |
| Truly project-specific UI / behavior | define (don't pollute the shared library) |
The progression is natural: prototype with define, promote to a
shared file via @import when reused, promote to the library when
the whole team should have it.
How it composes with everything else¶
- Types. A
definecan usearg capture name EmailwhereEmailis a library-defined type — the validation kicks in just like for library functions. - State mutation. A
definebody canset/append/ etc. the samecontext.*as library functions. Use it for source-side state that the rest of the file consumes. - Block functions.
defineblocks can haveblock_closer endto introduce indented body blocks. Inline a whole DSL extension if you need one. - Source
@import. Define a primitive inshared/macros.capy,@importfrom many scripts. Same primitive, defined once. - Errors. Type violations, missing closers, regex-pattern mismatches all surface the same caret-pointed hints as library- function errors.
Implementation notes¶
The pre-pass is a tiny string-level scanner in infra/define_extractor.go.
It:
- Walks the source line-by-line.
- At each column-0
define NAMEline, collects until the matching column-0end. - Rewrites each block as
function NAME ... endand gathers them into a synthetic.capylibrary. - Runs the synthetic library through the normal library loader.
- Returns the cleaned source (defines removed) plus the augment.
The orchestrator merges the augment into the loaded library before lexing. From the parser's perspective there's only one library — it can't tell which functions came from the library file and which came from the source.
Caveats¶
- Defines are local to one Capy invocation. They don't persist across runs; they're not stored in the library.
- No CLI flag to list them yet.
capy checkshows library functions; source-defined ones are visible only when running the source. We may add a--print-definesflag later. - Defines run in the WASM playground too. Try the
metaprogramming sample in the playground — paste
your own
defineblocks and watch them work in the browser.