Project Settings (brink.toml)
Every surface that compiles the same project — brink compile, brink ide,
an embedded editor session, the Studio player — chooses a
dialect and a type policy
(gradual or strict). Before brink.toml, each mount picked its own
default independently: brink compile had --dialect/--types flags,
brink ide had none at all, and an embedder set the wasm editor session’s
dialect with setLanguageDialect/setTypePolicy calls hardcoded in its own
lens code. Two mounts compiling the same project could silently disagree
about which syntax/typing surface it’s written in — an author writes syntax
one surface accepts and another rejects.
brink.toml, at the project root beside the root .ink file, is the one
config every mount reads.
Schema
[project]
dialect = "brink" # "brink" | "strict-ink" (default: "strict-ink")
types = "gradual" # "gradual" | "strict" (default: dialect-keyed —
# strict for "brink", gradual for "strict-ink")
entry = "story.ink" # a project-relative path to the project's entry
# file, superseding the embedder's own
# constructor-time entry-file argument (issue
# #2331, ruled 2026-08-07). Honored only by the
# wasm editor session (`ProjectSession`) today — see
# "Per mount" below; every other mount parses the
# key but does not act on it.
unprune-dirs = ["node_modules"] # directory names a native (.brink) compile
# must not prune from discovery, on top of
# the default target/.git/node_modules list
# — see "Directory discovery pruning" below
# (issue #1407)
drafts = ["scratch/**", "*.draft.ink"] # path globs naming deliberately
# unfinished work — see "Drafts"
# below (issue #3145)
conventions = "conventions.brink" # a project-relative path, or a bare
# built-in preset name (e.g.
# "screenplay"), pointing at the
# project's conventions module (issue
# #1844; see the dialect spec's
# "Where conventions live" section)
[lints]
deny-warnings = true # promote every Warning-severity diagnostic to
# Error (the `-D warnings` equivalent; issue #1160)
E014 = "deny" # per-code severity override:
# "allow" | "warn" | "deny" | "info" | "hint"
# ("info"/"hint" down-level to an advisory tier below
# Warning — issue #1162)
[fix]
E033 = "auto" # promote a Suggested fixer to batch (fix-all, on-save) for
# this project — see "Fix policy" below (issue #3419)
E014 = "off" # never offer this code's fixer here
# absent ⇒ "ask": offered per click only (Suggested) /
# already batchable (Safe)
[project] elements is a deprecated alias for conventions (issue #2180):
it still sets the same value, but emits a ConfigWarning naming the
rename — migrate to conventions at your own pace.
All keys are optional. An empty or absent [project]/[lints] table — or
no brink.toml at all — changes nothing on a first apply: a missing
file is exactly today’s behavior, no regression. For a long-lived caller
that re-applies brink.toml on every change (the wasm editor session, see
below), [lints] is the one exception: each apply replaces the
resolved lint policy wholesale from whatever the file currently says
(issue #1397), so an empty or absent [lints] table on a later apply
reverts any codes a previous, non-empty [lints] table had set.
Unknown keys — a stray top-level table, a key inside [project], or a
[lints] entry naming a code this version of brink doesn’t recognize (or
one whose default severity is Error, so it isn’t overridable at all —
see Lint severity below) — are reported as
warnings, never compile failures. This is a forward-compatibility
guarantee: a brink.toml written against a newer schema still compiles with
an older brink binary, just with a warning about the keys it didn’t
understand.
Drafts
[project] drafts names work the author has deliberately not wired into
the story — scratch scenes, cut material, notes-to-self. A file is a
draft when both halves hold:
draft(file) := matches(file, drafts) && !reachable_from_entry(file)
Reachability wins (ruled 2026-08-27). A file that matches a glob but the
entry still INCLUDEs is not a draft: it compiles normally, with no
special treatment. There is deliberately no “marked draft but included”
state to diagnose, which is what makes draft status unable to break a
story — the only files it can touch are files compilation never reached
anyway.
Being a draft means:
- no “not included in the project” banner in the editor;
- the file is marked as a draft wherever the studio names it — the Binder row, the Continuous view’s section heading, the Single File header, and the Code view’s tab. (The rule is that a file’s name and its draft status never appear apart, so a naming surface added later inherits it.)
Glob syntax
Patterns match the whole project-relative, /-separated path, and
are case-sensitive.
| Token | Matches |
|---|---|
? | exactly one character, never / |
* | any run of characters (including none), never / |
** | any run of characters, / included |
| anything else | itself, literally |
A trailing / is sugar for /**, so scratch/ and scratch/** are the
same. Note that a bare directory name does not cover its contents —
scratch matches a file called scratch, and nothing else. Write
scratch/** for everything under a directory. (This is the one place the
dialect departs from .gitignore, whose bare-name rule is a common
source of surprise matches; in a short, hand-written list, saying what
you mean is cheaper than a silent over-match.)
A pattern that is absolute (/tmp/**) or escapes the project (../**)
parses but never matches anything, and is reported as a warning.
Lint severity
[lints] (issue #1160) is shaped like Rust’s own [lints] table, but is
not a semantic drop-in for it. Each key other than the reserved
deny-warnings names a diagnostic code ("E014") mapped to a severity:
deny— alwaysError, regardless ofdeny-warnings.warn— the code’s ordinary behavior:Warning, promoted toErrorbydeny-warningslike any other unconfigured warning.allow— unlike Rust’sallow, this does not remove the diagnostic. It only buys immunity fromdeny-warnings; the diagnostic still resolves toWarningand is still reported. To actually suppress a diagnostic at a specific site, use a//brink-disablecomment, or — in a.brinkfile — an@[allow(…)]annotation on the declaration. Both are per-site mechanisms, not project-wide policy knobs.info/hint(issue #1162) — down-level the diagnostic to theInfoorHintseverity tier respectively, belowWarning. Likeallow, both are immune todeny-warnings(escalating a deliberate downgrade back up would defeat the point of it). These map to the LSP client’sInformation/HintDiagnosticSeverity— the tier IDE conventions use for advisory findings that would be too loud as aWarningsquiggle (e.g. unused-symbol dimming). OnlyE157defaults to either tier (Info); a project opts any other non-Error-default code into one explicitly, per code.
A source-level @[allow] wins
In a .brink file, @[allow(E151)] written above a declaration removes
that diagnostic for the declaration’s whole span, and it beats this
table — including E151 = "deny" and deny-warnings = true. The
annotation names one declaration and was written deliberately; brink.toml
cannot be that specific. What the annotation cannot do is widen the
suppressible set: it accepts only codes whose default severity is not
Error, so no [lints] entry can make an error-tier code suppressible,
and none can make a non-error-tier code unsuppressible. Naming an unknown
code (E153) or an error-tier one (E154) is itself a compile error — a
suppression that silently does nothing is never allowed.
Only codes whose default severity is not Error are overridable at all —
a diagnostic that is a hard error by default (e.g. a parse error) can never
be downgraded through [lints]; the table is never even consulted for it.
E063 (annotation-vs-inference mismatch) is a special case worth knowing:
its own base severity is types-policy-dependent (Error under types = strict), so a [lints] entry for it is only ever consulted under types = gradual.
A key that isn’t a real diagnostic code, or names a non-overridable one, is
never merged into the resolved policy — it’s reported as a warning (the same
channel unknown top-level/[project] keys use), never silently dropped.
Every mount now has a CLI/API override tier for [lints]/deny-warnings,
same as dialect/types below — always winning over the same code in a
discovered brink.toml (see Precedence
below):
brink compileandbrink ide(issue #1373, extended tobrink ideby #1417): repeatable--deny/--warn/--allow <CODE>flags, plus-D warnings(mirroringrustc’s own flag) fordeny-warnings. Seebrink compile/brink ide.bevy-brink’s dev-modeInkLoader, viaBrinkPlugin::with_config(ProjectConfig { lints, deny_warnings, .. })(issue #1394) — the same override also reachescompile_story_inline(issue #1380), as long as it’s called after theBrinkPlugin/BrinkAssetsPluginthat carries the override has been added to the app.brink-lsp(issue #1417), viainitializationOptions.lints/.denyWarnings— see Per mount below.- The wasm editor session (issue #1417), via
EditorSessionHandle.setLintOverrides(json)/.setDenyWarningsOverride(bool)/.clearDenyWarningsOverride()— see Per mount below.
Fix policy
[fix] (docs/autofix-spec.md §6.1, issue #3419) is shaped exactly like
[lints]: each key names a diagnostic code, mapped to one of three
policies, least to most aggressive:
off— never offer or batch a fixer for this code in this project.ask— the code’s ordinary behavior when[fix]doesn’t mention it: a Safe fixer is batchable (fix-all,brink fix, on-save); a Suggested fixer is offered only per explicit click.auto— promote a Suggested fixer to batchable here too. (A Safe fixer is already batchable regardless of[fix].)
Same dependency-free split as [lints]: brink-project-config validates
the value (a wrong TOML type, or a spelling outside the three above, is a
compile error — never a panic) but not the code. For [lints], a
downstream crate raises that diagnostic (validate_lint_code, in
brink-analyzer); as of issue #3447, [fix] gets the same treatment from
a validate_fix_code sibling in the same crate. An unrecognized [fix]
code is a warning, never a compile failure or a panic — [fix] E9999 is not a recognized diagnostic code; ignored — surfaced through
AnalysisOptions::apply_project_config’s returned warnings on both of
[lints]’s own reader roads: brink_environment::resolve_options (the
compile road) and EditorSession::apply_parsed_config (the studio/db
road, @brink-lang/web’s Problems panel).
An app (the Studio, an embedder) may pass its own ceiling — a personal
“how far may fix-on-save go” setting, in the same three-way space — which
only ever narrows the project’s [fix] entry, never widens it: a team
promoting E033 to auto in brink.toml does not force it onto an author
whose app ceiling says ask, and an author’s auto ceiling cannot make a
project-off code run. ProjectConfig::effective_fix_policy(code, app_ceiling) is the one function both the project entry and the ceiling
resolve through.
Edited in the Studio’s Settings → Diagnostics section as a Fix column
beside severity, through the same write path the severity picker already
uses for [lints] — a different table, same file, same code.
Discovery
A mount discovers brink.toml by walking up from the entry .ink
file’s directory through each ancestor, stopping at the first brink.toml
it finds. The file doesn’t have to sit directly beside the entry point — a
multi-file project with story.ink in src/chapters/ and brink.toml at
the repo root still finds it.
For the real-filesystem mounts — brink compile, brink ide, and
brink-lsp — the walk is bounded two ways, either of which stops it. It
never climbs past a directory containing a .git entry (an ordinary
repository’s .git/ directory, or a linked worktree’s .git pointer file);
and, independent of that, it never climbs more than a fixed number of
ancestor directories, so a non-repository tree (no VCS at all, hence no
.git boundary to stop at) doesn’t climb all the way to the filesystem root
either. Either way, a brink.toml that lives outside the bound is never
picked up by these mounts, even by accident — but it isn’t treated as
silently as if it didn’t exist: discovery reports it back as a warning
(logged by brink-lsp; returned alongside the result by
brink_project_config::load_from_entry), naming the skipped file so an
author can tell why it wasn’t applied.
The virtual mounts have no filesystem or .git to bound against, so each is
bounded at its own tree instead: the wasm editor session’s
discoverProjectConfig never looks past the document tree’s own root (see
below), and bevy-brink’s dev-mode InkLoader never climbs past the asset
source root it was loaded from.
my-project/
├── brink.toml ← found even though the entry is nested
└── src/
└── chapters/
└── story.ink ← brink compile src/chapters/story.ink
Directory discovery pruning
A native (.brink) compile’s discovery walk enumerates every .brink file
under the project root — but never descends into a directory named target,
.git, or node_modules. These are build output and VCS/dependency
metadata, never a valid source location, and can be enormous; pruning them
is the default with no opt-in required.
Before issue #1407, that pruning was absolute: a project that legitimately
kept .brink sources under one of those names got no file and no error —
the source was silently invisible to every compile. Three things changed:
- An escape hatch.
[project] unprune-dirs(the Schema block above) names directories that should not be pruned, on top of the default list. Only entries that are actually one oftarget/.git/node_moduleshave any effect — a value outside that set is a no-op (nothing was ever pruned there) and is reported as a warning, the same “unknown key” channel described above, on the theory it’s more likely a typo than a deliberate no-op. - A diagnostic. When discovery prunes a directory that, within a bounded
scan of itself, contains a
.brinkfile — the shape of “an author probably meant for this to be found” — it’s reported as a warning naming the directory and theunprune-dirsfix, rather than saying nothing. The scan is bounded by depth and by a total-entry budget (not a full recursive descent), deep enough to catch thenode_modules/<package>/lib.brinkshape an npm-style dependency tree actually uses, but never turning a cheap prune into an expensive walk of the very tree being skipped. A directory named byunprune-dirsis, naturally, never reported this way — it wasn’t pruned in the first place. .gitignoreis deliberately not consulted, and that’s a decision, not a gap. Discovery is a deterministic-compilation input: the same tree, compiled by anyone, must discover the same files..gitignoreresolution depends on more than a repository’s tracked content — a local uncommitted edit, a per-clone.git/info/exclude, a user’s globalcore.excludesFile— any of which could make two checkouts of byte-identical tracked source compile differently.unprune-dirsavoids exactly that: it lives inbrink.toml, itself tracked, versioned source, so it resolves the same way on every clone.
Both the escape hatch and the diagnostic live once, as opt-in builders
(Walk::allow, Walk::warn_on_pruned_sources) on the shared recursive walk
every native discovery traversal goes through — so a new traversal never
has to reimplement the pruning policy itself. But each builder is still
opt-in per traversal: today only the brink compile / brink ide path
(brink-driver’s RealFs::list) wires them up. brink-lsp’s own
workspace-scan walk calls the shared Walk unadorned, so an LSP-open
project honors neither unprune-dirs nor the silent-skip diagnostic yet —
tracked as a follow-up to wire both into brink-lsp.
Precedence: the file is the default, code wins
An explicit API call or CLI flag always overrides brink.toml. The file
supplies the default for a project; an author who reaches for
--dialect/--types on a single invocation, or an embedder that calls
setLanguageDialect/setTypePolicy explicitly, is making a deliberate
one-off choice that the file must not silently overrule.
[project] entry (issue #2331, ruled 2026-08-07 “[project] entry beats
mountStudio’s entryFile”) is the one key that inverts this rule, on
the one mount that honors it: a brink.toml naming a valid entry wins
over the wasm editor session’s/ProjectSession’s own constructor-time
entry-file argument, not the other way around. The argument is only the
fallback for a configless project (no brink.toml, or one that doesn’t set
entry) — see “Per mount” below.
| Source | Wins over |
|---|---|
--dialect brink / --types strict (CLI flag actually passed) | brink.toml, defaults |
--deny/--warn/--allow <CODE> / -D warnings (brink compile/brink ide, CLI flag actually passed) | brink.toml, defaults |
initializationOptions.lints/.denyWarnings (brink-lsp, key actually set at initialize) | brink.toml, defaults |
setLanguageDialect(...) / setTypePolicy(...) (explicit call) | brink.toml, defaults |
setLintOverrides(...) / setDenyWarningsOverride(...) (wasm editor session, explicit call) | brink.toml, defaults |
BrinkPlugin::with_config(...) / BrinkAssetsPlugin::with_config(...) (bevy-brink, field actually set — reaches InkLoader and compile_story_inline) | brink.toml, defaults |
brink.toml’s [project] dialect/types | defaults only |
brink.toml’s [lints]/deny-warnings (for a code without a CLI/API override above) | defaults only |
brink.toml’s [project] entry, when it resolves to a real project file (wasm editor session / ProjectSession only — the one row in this table that inverts the rule above it) | the embedder’s constructor-time entry-file argument |
Dialect-keyed default (brink → strict, strict-ink → gradual) | — |
Per mount
-
brink compilediscoversbrink.tomlfrom the entry file you pass it.--dialect/--types, when actually given, override the file field-by-field (setting only--dialectleaves the file’stypes, if any, in effect).[lints]/deny-warningsapply too — a build that previously succeeded with a warning can now fail.--deny/--warn/--allow <CODE>and-D warningsoverride the file the same way, per code (issue #1373): passing--allow E014wins over abrink.tomlE014 = "deny"for that code, while any other code in the file’s[lints]table still applies. Seebrink compile. -
brink idehas no--dialect/--typesflags of its own — the file (or the plain defaults, absent one) is the only source for those two. It does have a--deny/--warn/--allow <CODE>/-D warningstier for[lints]/deny-warnings(issue #1417), identical tobrink compile’s and applied the same way — an explicit flag wins over the file for that code, every other code in the file’s[lints]table still applies. Every subcommand that loads a project honors it. Seebrink ide. -
brink-lspdiscoversbrink.tomlfrom the workspace roots the client declares atinitialize, resolving[project] dialect/typesand[lints]/deny-warningsinto its sharedLanguageOptions. A laterworkspace/didChangeConfigurationnotification or a watched edit tobrink.tomlre-resolves and re-stores the policy (reload_brink_toml), so published diagnostic severity picks up a[lints]change without a client restart.initializationOptions.lints(issue #1417) is an object{ "<CODE>": "deny" | "warn" | "allow" | "info" | "hint" }(the last two added by issue #1162), andinitializationOptions.denyWarningsa boolean — both resolved once atinitialize(mirroringinitializationOptions.dialect/.types) and applied last, so they always win over the same code in the discoveredbrink.toml. An unrecognized per-code level string, or an unrecognized/ non-overridable code, is reported through the server’s usualtracing::warn!channel, never silently dropped. A second, independent mechanism also dims text in the client (issue #1618):E033(unreachable code after a divert) andE095(#@wasself-alias) publish with LSP’sDiagnosticTag::UNNECESSARY, which VS Code and similar clients render as faded/dimmed rather than underlined. This tag is orthogonal to severity — it rides alongside whatever severity the code is published at (including theWarningdefault these two carry today), not another tier likeInfo/Hintabove. -
The wasm editor session (
@brink-lang/web’sEditorSessionHandle) has no filesystem of its own — but it is inherently virtual, so it discoversbrink.tomlthe same waybrink compile/brink idedo: by walking its own document tree, not a real filesystem (issue #1414). Servebrink.tomlas an ordinary document —updateFile("brink.toml", text), at the entry’s directory or any ancestor of it — and calldiscoverProjectConfig(entry).[project] entryis honored only here (issue #2331, ruled 2026-08-07): a valid, resolvableentrysupersedes the argumentProjectSession/mountStudiowere constructed with — see “Precedence” above.EditorSessionHandle.getConfiguredEntry()returns the discovered value (nullif unset or unresolved);ProjectSessionis the layer that validates it against the session’s actual file set and applies it togetEntryFile()/compileProject(). Every other mount in this section —brink compile,brink ide,brink-lsp,bevy-brink— parsesentryas a recognized key (no “unknown key” warning) but does not act on it; it is inert there today. It applies[project] dialect/typesand[lints]/deny-warnings(issue #1366) — diagnostic severity rendered through this surface now reflects the file the same waybrink compile,brink ide, andbrink-lspalready did. Because this session is long-lived, a repeatedapplyProjectConfig/discoverProjectConfigcall fully re-resolves[lints]from the file each time rather than merging onto the previous result (issue #1397) — a code ordeny-warningspresent in an earlierbrink.tomlbut absent from the current one reverts to its default severity:import { EditorSessionHandle } from "@brink-lang/web"; const handle = new EditorSessionHandle(); const toml = await readProjectFile("brink.toml"); // your own host API if (toml !== null) { handle.updateFile("brink.toml", toml); } handle.updateFile("story.ink", await readProjectFile("story.ink")); const warnings = handle.discoverProjectConfig("story.ink"); for (const w of warnings) console.warn(w);Call
discoverProjectConfigonce, after the project’s files are loaded and before any explicitsetLanguageDialect/setTypePolicycall — a field the session already has an explicit value for is left untouched, so a later explicit call always wins over an earlierdiscoverProjectConfig, matching the CLI’s flag precedence. Returns[](never throws) when nobrink.tomlis found anywhere from the entry’s directory up to the tree root.entrymust use the same root-relative spelling (no leading/) as every document path given toupdateFile/updateSource— the walk-up matches keys by exact string equality. Mixing a/-prefixed path with unprefixed ones is a silent no-op: discovery finds nothing anddiscoverProjectConfigreturns[]exactly as if nobrink.tomlexisted, with no warning.If your embedder reads
brink.toml’s text with its own host file API (Nodefs, the browser File System Access API, a bundler import, …) and would rather hand that text in directly than load it as a document, useapplyProjectConfig(toml)instead — the same application/precedence rules apply, just without the discovery step.An embedder that wants to set
[lints]/deny-warningspolicy programmatically — without shipping abrink.tomlat all, or to override one it doesn’t control — callssetLintOverrides(json)(issue #1417): a JSON object{ "<CODE>": "deny" | "warn" | "allow" | "info" | "hint" }(the last two added by issue #1162) that replaces the session’s explicit override map ("{}"clears it), plussetDenyWarningsOverride(bool)/clearDenyWarningsOverride()for the blanket flag. Both always win over the same code in an appliedbrink.toml’s[lints]table, in either call order — a laterapplyProjectConfig/discoverProjectConfigre-applies the explicit overrides on top of whatever it just resolved from the file, so abrink.tomlreload can never silently drop a previously-set override. Returns the unrecognized-level/unrecognized-code warnings as JSON (astring[]), the same channelapplyProjectConfiguses.
Driving the compiler as a library
AnalysisOptions itself has no notion of a config file — it’s the plain
input every mount eventually builds. If you’re driving brink-compiler
directly (not through the CLI), read and apply brink.toml with
brink-project-config:
#![allow(unused)]
fn main() {
extern crate brink_compiler;
extern crate brink_project_config;
use std::path::Path;
use brink_compiler::{AnalysisOptions, compile_path_with_options};
let entry = Path::new("story.ink");
let mut options = AnalysisOptions::default();
let (loaded, discovery_warnings) = brink_project_config::load_from_entry(entry)?;
// A `brink.toml` the bounded discovery walk stepped over (a workspace/git
// boundary, or the ancestor-depth cap for a VCS-less tree) is reported here
// rather than silently ignored — never applied, but worth telling the
// author about (issue #1435).
for warning in &discovery_warnings {
eprintln!("{warning}");
}
if let Some(loaded) = loaded {
for warning in &loaded.warnings {
eprintln!("{warning}");
}
// `false, false`: no explicit override in this example — an embedder
// with its own flags would pass `true` for any field it's setting itself.
options.apply_project_config(&loaded.config, false, false);
}
let output = compile_path_with_options(entry, options)?;
Ok::<(), Box<dyn std::error::Error>>(())
}
dialect/types remain mount-time-only: never embedded in .inkb,
never delivered to the runtime, exactly as before brink.toml existed (see
Enabling the Dialect).