Skip to content

Multi-file output & library imports

Two features for libraries that need to scale beyond a single file — either because they generate a directory tree as output, or because the library itself wants to split shared bits across files.

Multi-file output (file "path" ... end)

A library can declare any number of file "..." blocks at the top level. Each block has a relative path (subdirectories with / are fine) and an inner-DSL body of write calls (plus optional for / if control flow). The engine runs each block against the same final context + body and writes the result to disk under the --out-dir you pass to capy run.

Basic shape

extension py

file "README.md"
    write `# ${unquote context.title}
Generated by Capy.
`
end

file "src/main.py"
    write `"""Generated app."""
print("hello from ${unquote context.title}")
`
end

file "src/__init__.py"
    write `# auto-generated package marker
`
end

Run it:

capy run --out-dir generated lib.capy script.capy
wrote generated/README.md (...)
wrote generated/src/main.py (...)
wrote generated/src/__init__.py (...)

Subdirectories are created automatically.

Dynamic filenames

The path on each file "..." block is itself rendered against the same context + body data — ${EXPR} interpolations inside the path work just like inside a write literal:

file "${pascalCase (unquote context.name)}Page.tsx"
    write `export function ${pascalCase (unquote context.name)}Page() { ... }
`
end

file "components/${dasherize (unquote context.name)}.css"
    write `.${dasherize (unquote context.name)} { ... }
`
end

A source that says page "Account Settings" then emits AccountSettingsPage.tsx and components/account-settings.css side by side. See samples/design-system-components/ for a real one.

Tip: subdirectories work fine in the filename template too — "${context.feature}/index.ts" lets the source choose its own folder.

What's in scope inside file blocks

Same as file_template:

  • context.X — the final accumulated context after every function body has executed.
  • body — the concatenated rendered output from every top-level statement.
  • All template helpers via ${func arg arg} interpolation: indent, unquote, upper, lower, toQuoted, toJSON, toJSONIndent, add, percent, stars, trimSuffix, trimPrefix, split, join, pascalCase, dasherize, …
  • for / if control flow with write calls inside.

State-mutation statements (set / append / …) are NOT allowed inside file / file_template blocks — those are pure renderers; the context is already finalised by then.

When file blocks coexist with file_template

If your library has both, --out-dir is required to write the file blocks; the file_template output is what the CLI prints to stdout (or writes to --out). Most multi-file libraries omit file_template entirely.

The killer sample

samples/multi-file-project/ turns a 9-line route declaration into a 6-file FastAPI project tree (README, pyproject.toml, .gitignore, src/main.py, src/handlers.py, tests/test_smoke.py).

Library imports (import)

Any library can import other libraries by path. Imported types, functions, context entries, and file blocks are merged in before the importer's own declarations.

# lib.capy
import "common/types.capy"
import "common/syntax.capy"

function post
    ...
end
# common/types.capy
type Email
    pattern "^[^@]+@[^@]+\\.[^@]+$"
end

type Semver
    pattern "^[0-9]+\\.[0-9]+\\.[0-9]+(-[a-zA-Z0-9.]+)?$"
end
# common/syntax.capy
function tag
    arg literal "tag"
    arg capture name ident
    append context.tags name
end

function note
    arg literal "note"
    arg capture text string
    write `> **Note:** ${unquote text}
`
end

After loading lib.capy, the engine sees one merged library with the post function from lib.capy plus tag, note from syntax.capy plus Email, Semver from types.capy.

Conflict resolution

If the importer and an import both declare the same function name, the importer wins. Imports fill in defaults; the main library can specialize.

This lets you import a shared base and override one or two functions without forking the whole thing.

Path resolution

Import paths are relative to the file containing the import directive. So lib.capy importing common/types.capy resolves to <dir-of-lib.capy>/common/types.capy.

Absolute paths also work but are discouraged — they break repo portability.

Cycles

If a.capy imports b.capy and b.capy imports a.capy, the loader stops with import cycle detected at .... Cycles are tracked by absolute path so symlinks and .. shortcuts don't fool it.

Source-file inclusion (library-declared @import)

The library-import mechanism above is for the LIBRARY file. For SCRIPT files, Capy offers an OPT-IN line-level preprocessor: a library can declare one or more inclusion directives, and the engine recognises only the ones the library named. Zero directives are built in — Capy's "no predefined grammar" promise extends to source-level inclusion.

A library opts in with a top-level preprocess block:

preprocess
    include "@import"      # canonical name
    include "@include"     # optional synonym
end

With this in place, lines like @import "shared/drinks.capy" (or the synonym, or any other names you declared, e.g. @use "...") are replaced with the contents of the referenced file BEFORE lexing. With no preprocess block, the same @import line is just regular text and the lexer will treat it as unknown tokens.

menu "Capy Cafe — Spring 2026"

    section "Mains"
        item "House pasta"               "$16"
        item "Sheet-pan salmon"          "$22"
    end

    @import "shared/drinks.capy"
    @import "shared/desserts.capy"

    note "All dishes are made fresh."
end

Each @import is replaced with the contents of the referenced file, with the same leading indentation as the @import line — so imported content nests naturally inside the surrounding block.

Rules

  • Path resolution is relative to the file containing the @import directive.
  • Indentation auto-tracks: a @import "x" (4 spaces of indent) inlines the imported content with 4 spaces prepended to each non-blank line.
  • Cycles are detected by absolute path.
  • Directive names are whatever the library declared. @import and @include are conventional, but a library could equally name the directive @use, @require, or @from — the script must use whatever names the library opts into via preprocess.
  • Default = nothing. A library with no preprocess block recognises NO inclusion directive. That's the zero-grammar promise: even universal-looking constructs are library-declared.

When to reach for source vs. library imports

Library import Source @import
Where it appears Top of lib.capy Inside script.capy, any indent
What it imports Functions, types, context Verbatim source statements
Processing stage Library load Source preprocessing
Conflict rule Importer-wins merge n/a (text inclusion)
Typical use Share DSL building blocks Share pieces of authored content

See samples/source-imports/ for a worked menu example.

When to split a library

Split when…

  • A type or function is reused across more than one library (Email validation, semver checks, common HTML primitives).
  • The main library grows past ~200 lines and the boilerplate is drowning out the project-specific bits.
  • A team wants to publish a "base library" that downstream projects extend.

Don't split prematurely — a single 100-line lib.capy is easier to read than five 20-line files. Refactor when the pain shows up.

The composition sample

samples/lib-composition/ ships a minimal layout that demonstrates the pattern end-to-end:

lib-composition/
├── lib.capy
├── common/
│   ├── types.capy        ← Email, URL, Semver, Slug
│   └── syntax.capy       ← meta, tag, note
└── script.capy

capy check lib.capy reports 6 functions and 4 types — three of the functions and all four types come from imports, three are local. The merged library behaves exactly as if it had been a single 100-line file.

Putting it together

The two features compose: a multi-file project generator can import shared scaffolding from a base library, override one or two file templates, and emit a fully customized project tree:

# base/project-scaffold.capy
file "README.md"
    write `# ${unquote context.name}
`
end

file ".gitignore"
    write `*.pyc
.venv/
`
end

file "pyproject.toml"
    write `[project]
name = ${toQuoted context.name}
...
`
end
# my-project/lib.capy
import "../base/project-scaffold.capy"

# Override the README with something more elaborate.
file "README.md"
    write `# ${unquote context.name}

## Custom intro just for this project
...
`
end

The base scaffold ships the standard files; the consumer overrides specific ones. Same pattern as software libraries everywhere — defaults + overrides.