brink_environment/lib.rs
1//! The compilation **environment as a deterministic input** (#1306).
2//!
3//! brink already compiles as a pure query over salsa inputs, so a determinism
4//! boundary *exists* — but the inputs were only ever pushed imperatively
5//! (`set_file`/`set_entry`/`set_analysis_options`) with no nameable value to
6//! hold, hash, serialize, cache on, or diff. This crate reifies that boundary:
7//!
8//! - [`Environment`] is the **pure input value** — a serializable,
9//! content-addressed reification of "the sources being compiled + the
10//! resolved policy + the (reserved) resolved dependency set + the entry."
11//! It is the whole compilation universe as a single hashable artifact.
12//! - [`Project::load`] is the **effectful producer** — mount-specific, where
13//! all ambient reads (a filesystem walk, a drained `AssetReader`, an LSP
14//! store) and future dependency resolution live. It walks a
15//! [`SourceTree`](brink_source_tree::SourceTree), reads + hashes the
16//! sources, discovers + parses `brink.toml` over the *same* tree, applies
17//! override precedence, and freezes an [`Environment`].
18//! - [`compile`] is the **pure function over the input** — it seeds a fresh
19//! salsa `ProjectDb` from an [`Environment`] and pulls the memoized
20//! `story_data` query. No ambient reads, no walk-up, no I/O: everything it
21//! needs is already in the frozen value.
22//!
23//! ```text
24//! [mount-specific resolution] → Environment → compile(&Environment)
25//! RealFs walk / drained reified, content- PURE, deterministic
26//! AssetReader / LSP store addressed input value (no ambient reads)
27//! = Project::load (effectful) {sources+hashes, = salsa query pull
28//! config, deps, entry}
29//! ```
30//!
31//! The `Environment` is serialized/reified **now**, not deferred to when
32//! external libraries arrive (ruled 2026-07-23): the boundary's whole value is
33//! its explicitness — a reproducible, hashable ([`Environment::content_hash`])
34//! input artifact enabling build caching, reproducible builds, and input
35//! diffing from day one. The [reserved `resolved_deps`](Environment::resolved_deps)
36//! slot (#1093) is where external module artifacts will land, mounted at
37//! compile time with the same identity/linking machinery load-time DLC/UGC
38//! modules use — designed-for, not built.
39//!
40//! [`Project::load`] also mounts the **stdlib** into the manifest (#2080,
41//! ruled 2026-08-03) — built-in preset/convention `.brink` source
42//! (`std/conventions/screenplay.brink`), embedded via `include_str!` so it
43//! mounts identically on hosts with no filesystem (wasm). The stdlib is
44//! *source*, not a [`ResolvedDep`] (that slot stays reserved for #1093's
45//! compiled per-module artifacts): it joins the manifest exactly like any
46//! project file, under the same string-key convention, so it needs no
47//! parallel identity or resolution mechanism.
48
49use std::borrow::Cow;
50use std::collections::BTreeMap;
51use std::io;
52use std::path::Path;
53use std::sync::Arc;
54
55use brink_compiler::{CompileError, CompileOutput, ResolvedDiagnostic};
56use brink_driver::{AnalysisOptions, Dialect, Driver, LintLevel, TypePolicy};
57use brink_ir::Diagnostic;
58use brink_project_config::{ConfigError, discover_from_entry_in_tree, parse_str_at};
59use brink_source_tree::SourceTree;
60
61// ── Content addressing ───────────────────────────────────────────────
62
63/// FNV-1a 64-bit — a small, dependency-free, fully deterministic hash. Chosen
64/// over `std`'s `DefaultHasher` (whose output is not guaranteed stable across
65/// Rust versions) and over pulling in a crypto crate: a content-addressed
66/// store keyed within one project's source set does not need cryptographic
67/// collision resistance, only a stable, platform-independent digest. The v2
68/// `ContentStore` swap (a persistent variant) is the natural point to revisit
69/// the digest if one is ever needed.
70fn fnv1a_64(bytes: &[u8]) -> u64 {
71 const OFFSET: u64 = 0xcbf2_9ce4_8422_2325;
72 const PRIME: u64 = 0x0000_0100_0000_01b3;
73 let mut hash = OFFSET;
74 for &byte in bytes {
75 hash ^= u64::from(byte);
76 hash = hash.wrapping_mul(PRIME);
77 }
78 hash
79}
80
81/// A content hash: the deterministic digest of one source file's text. The
82/// [`Environment::manifest`] keys files by their (root-relative) path and maps
83/// each to a `ContentHash`; the [`ContentStore`] maps a `ContentHash` back to
84/// its text. Two files with byte-identical content share one hash (and one
85/// stored copy) — the store is content-addressed, so it deduplicates.
86#[derive(
87 Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash, serde::Serialize, serde::Deserialize,
88)]
89pub struct ContentHash(u64);
90
91impl ContentHash {
92 /// The content hash of `text`.
93 #[must_use]
94 pub fn of(text: &str) -> Self {
95 Self(fnv1a_64(text.as_bytes()))
96 }
97}
98
99impl std::fmt::Display for ContentHash {
100 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
101 write!(f, "{:016x}", self.0)
102 }
103}
104
105/// The hash of a whole [`Environment`] — over its manifest, entry, resolved
106/// options, and resolved deps. This is the reproducible-build / build-cache
107/// key the "serialize now" ruling exists to enable: two environments with the
108/// same `EnvHash` compile to the same `StoryData`.
109#[derive(
110 Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash, serde::Serialize, serde::Deserialize,
111)]
112pub struct EnvHash(u64);
113
114impl std::fmt::Display for EnvHash {
115 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
116 write!(f, "{:016x}", self.0)
117 }
118}
119
120/// Where an [`Environment`]'s source text actually lives — the seam that makes
121/// the future content-cache an impl swap, not an API break.
122///
123/// v1 is [`Inline`](ContentStore::Inline): the text is bundled directly into
124/// the value, so a serialized `Environment` is fully self-contained (portable,
125/// diffable, cacheable as one blob). A later v2 can add a `Persistent` variant
126/// backed by an on-disk content-addressed store; consumers are unaffected
127/// because they only ever read through [`Environment::source_text`], never a
128/// concrete store field.
129#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
130pub enum ContentStore {
131 /// Self-contained: every hash maps to its text, bundled in the value.
132 Inline(BTreeMap<ContentHash, String>),
133}
134
135impl ContentStore {
136 /// The text for `hash`, if this store holds it.
137 fn get(&self, hash: ContentHash) -> Option<&str> {
138 match self {
139 Self::Inline(map) => map.get(&hash).map(String::as_str),
140 }
141 }
142}
143
144/// A resolved external module artifact (a library) — **reserved** (#1093).
145///
146/// Empty in v1: external libraries are not on the roadmap. The slot exists so
147/// that when they arrive, dependency *resolution* (ambient + pinned, on the
148/// producer side, à la `Cargo.lock`) freezes its *resolved set* into the
149/// `Environment`, keeping compilation pure. `the-tree-is-the-universe`
150/// generalizes to `the-environment-is-the-universe`:
151/// `{ local module tree } + { resolved external module set }`.
152#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
153pub struct ResolvedDep {
154 /// The stable `(module, name)`-style identity of the resolved artifact.
155 /// A placeholder field so the reserved struct is nameable and
156 /// round-trippable; its shape is defined when #1093 is built.
157 pub module: String,
158}
159
160// ── The pure input value ─────────────────────────────────────────────
161
162/// The reified, content-addressed compilation input — the determinism
163/// boundary (#1306).
164///
165/// Everything [`compile`] needs is frozen here: the source set (as a
166/// path→hash [`manifest`](Self::manifest) plus a hash→text
167/// [`content`](Self::content) store), the designated [`entry`](Self::entry),
168/// the fully [resolved `options`](Self::options), and the reserved
169/// [`resolved_deps`](Self::resolved_deps). Because it is a plain serializable
170/// value, it can be hashed ([`content_hash`](Self::content_hash)), cached on,
171/// diffed, and round-tripped.
172///
173/// Consumers read sources **only** through [`source_keys`](Self::source_keys)
174/// and [`source_text`](Self::source_text) — never a public sources field.
175/// That is the whole point of the hash-addressed shape: the inline store can
176/// later become a persistent one with no breaking migration.
177#[derive(Clone, Debug, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
178pub struct Environment {
179 /// key (root-relative, forward-slash) → content hash. Deterministic
180 /// (`BTreeMap`); native module identity derives from these keys.
181 /// Hash-addressed from day one.
182 manifest: BTreeMap<String, ContentHash>,
183 /// The text backing store. v1 = [`ContentStore::Inline`]: text bundled,
184 /// self-contained. A v2 `Persistent` variant swaps in later; consumers,
185 /// reading only through [`source_text`](Self::source_text), are unchanged.
186 content: ContentStore,
187 /// Designated entry key — its top-level content is the start flow
188 /// (compilation universe != execution entry, #1296).
189 pub entry: String,
190 /// The **resolved** effective policy — the producer already applied
191 /// override precedence (CLI/API > `brink.toml` > default), so [`compile`]
192 /// does no further resolution.
193 pub options: AnalysisOptions,
194 /// Reserved (#1093): resolved external module artifacts (libraries).
195 /// Empty in v1.
196 pub resolved_deps: Vec<ResolvedDep>,
197}
198
199impl Environment {
200 /// Every source key (root-relative path), in deterministic sorted order.
201 pub fn source_keys(&self) -> impl Iterator<Item = &str> {
202 self.manifest.keys().map(String::as_str)
203 }
204
205 /// The source text for `key`, resolved key → hash → store.
206 ///
207 /// Returns [`Cow`] (not `&str`) so the accessor signature is identical for
208 /// the inline store (borrows) and a future persistent store (which would
209 /// own the read-back text). `None` if `key` is not in the manifest.
210 pub fn source_text(&self, key: &str) -> Option<Cow<'_, str>> {
211 let hash = *self.manifest.get(key)?;
212 self.content.get(hash).map(Cow::Borrowed)
213 }
214
215 /// The hash of the whole environment — over manifest + entry + options +
216 /// deps. A reproducible-build / cache key (see [`EnvHash`]).
217 pub fn content_hash(&self) -> EnvHash {
218 let mut buf: Vec<u8> = Vec::new();
219 for (key, hash) in &self.manifest {
220 buf.extend_from_slice(key.as_bytes());
221 buf.push(0);
222 buf.extend_from_slice(&hash.0.to_le_bytes());
223 }
224 buf.push(0xff);
225 buf.extend_from_slice(self.entry.as_bytes());
226 buf.push(0xff);
227 // serde_json is deterministic for these value shapes (BTreeMap is
228 // sorted; the option/dep structs are field-ordered), so this is a
229 // stable digest of the resolved policy + reserved deps.
230 buf.extend_from_slice(&serde_json::to_vec(&self.options).unwrap_or_default());
231 buf.push(0xff);
232 buf.extend_from_slice(&serde_json::to_vec(&self.resolved_deps).unwrap_or_default());
233 EnvHash(fnv1a_64(&buf))
234 }
235}
236
237// ── Producer-side policy overrides ───────────────────────────────────
238
239/// Explicit policy a mount supplies that **wins over `brink.toml`** — the
240/// `CLI/API > file > default` precedence rule (#1005). A field left `None`
241/// (or, for `lints`, absent from the map) means "the caller has no explicit
242/// value," so the discovered `brink.toml` (or the default) governs that
243/// field.
244#[derive(Debug, Clone, Default, PartialEq, Eq)]
245pub struct OptionOverrides {
246 /// An explicit dialect (e.g. the CLI `--dialect` flag), if the caller set
247 /// one.
248 pub dialect: Option<Dialect>,
249 /// An explicit type policy (e.g. the CLI `--types` flag), if set.
250 pub types: Option<TypePolicy>,
251 /// Explicit per-code lint-level overrides (e.g. the CLI's repeatable
252 /// `--deny`/`--warn`/`--allow <CODE>` flags, issue #1373), keyed by the
253 /// diagnostic code's string form. Always wins over the same code in a
254 /// discovered `brink.toml`'s `[lints]` table — folded in by
255 /// [`AnalysisOptions::apply_lint_overrides`], which validates each code
256 /// the same way the file's table is validated (#1160: only codes whose
257 /// *default* severity is `Warning` are overridable).
258 pub lints: BTreeMap<String, LintLevel>,
259 /// An explicit `deny-warnings` (e.g. the CLI's `-D warnings` flag), if
260 /// set. `None` means "the caller has no explicit value," same as
261 /// `dialect`/`types`.
262 pub deny_warnings: Option<bool>,
263 /// D6 (`docs/debugger-spec.md` §1.2/§2, issue #3184): the CLI's
264 /// explicit `--debug-info` flag. Unlike `dialect`/`types`/
265 /// `deny_warnings`, this is a plain `bool` rather than `Option<bool>` —
266 /// there is no `brink.toml` project-file spelling for it to defer to
267 /// (the ship-policy ruling scopes this to mount-time compiles only), so
268 /// there is no "caller didn't say" case to distinguish from "caller
269 /// said off." `false` (the `Default`) never turns the section on.
270 pub debug_info: bool,
271}
272
273// ── The effectful producer ───────────────────────────────────────────
274
275/// The producer namespace: `Project::load` turns a mount (a
276/// [`SourceTree`](brink_source_tree::SourceTree)) into an [`Environment`].
277///
278/// `Project` is where every ambient/effectful concern lives (filesystem
279/// walks, an `AssetReader` drain, an LSP store, and — later — dependency
280/// resolution), quarantined off the pure [`Environment`] it produces.
281pub struct Project;
282
283impl Project {
284 /// Walk `tree`, read + hash its sources, discover + parse `brink.toml`
285 /// over the same tree, apply override precedence, and freeze an
286 /// [`Environment`].
287 ///
288 /// The tree is treated as rooted at `.` with root-relative keys (the
289 /// #1312 `SourceTree` config-discovery convention): `entry` is a
290 /// root-relative key, and any `brink.toml` is looked up by a direct
291 /// `{ancestor}/brink.toml` probe walking up from `entry` (#1370 —
292 /// discovery no longer calls `list` at all, so a mount's `list` is not
293 /// required to surface `brink.toml`). The mount is responsible for
294 /// rooting its tree (the CLI drains its project root into an in-memory
295 /// tree; web / LSP push their own root-relative store).
296 ///
297 /// **Sync** — the only asynchrony a mount has (e.g. a bevy `AssetReader`)
298 /// is quarantined in *building the tree*, before this runs, matching the
299 /// `InkLoader` drain-then-compile pattern.
300 ///
301 /// Dispatches on `entry`'s extension: a `.brink` entry's universe is the
302 /// whole native source tree (enumerate every `.brink` key); a `.ink` entry
303 /// follows its `INCLUDE` graph from the entry (a BFS over the tree's
304 /// reads).
305 ///
306 /// ## Repeat compiles are deterministic
307 ///
308 /// Each call resolves `AnalysisOptions` from a brand-new
309 /// `AnalysisOptions::default()` (see `resolve_options` below) — never one
310 /// left over from a previous call — so two sequential `load` calls for
311 /// unrelated projects never leak `dialect`/`types` between them, even
312 /// though [`AnalysisOptions::apply_project_config`]'s "unset means
313 /// untouched" rule for those two fields would otherwise let a stale
314 /// value silently survive (see that method's own "must be fresh"
315 /// invariant doc). This matters for any caller that calls `load`
316 /// repeatedly against different mounts in the same process — notably
317 /// `bevy-brink`'s `InkLoader`, invoked once per `.ink` asset (re)load —
318 /// where a leaked `dialect` would make the *n*-th load's outcome depend
319 /// on what the (*n*-1)-th load happened to resolve. Pinned by
320 /// `repeat_compiles_do_not_leak_options_across_project_load_calls`
321 /// below.
322 pub fn load(
323 tree: &dyn SourceTree,
324 entry: &str,
325 overrides: &OptionOverrides,
326 ) -> Result<Environment, LoadError> {
327 let sources = collect_sources(tree, entry)?;
328
329 let mut manifest = BTreeMap::new();
330 let mut inline = BTreeMap::new();
331 for (key, text) in sources {
332 let hash = ContentHash::of(&text);
333 inline.insert(hash, text);
334 manifest.insert(key, hash);
335 }
336
337 mount_stdlib(&mut manifest, &mut inline);
338
339 let options = resolve_options(tree, entry, overrides)?;
340
341 Ok(Environment {
342 manifest,
343 content: ContentStore::Inline(inline),
344 entry: entry.to_string(),
345 options,
346 resolved_deps: Vec::new(),
347 })
348 }
349}
350
351// ── Standard library mount (#2080) ───────────────────────────────────
352
353/// The stdlib source set, embedded at compile time. #2080's 2026-08-03
354/// ruling: `Environment` (#1306) already generalizes
355/// `the-tree-is-the-universe` to `the-environment-is-the-universe`
356/// (`{ local module tree } + { resolved external module set }`), so the
357/// stdlib needs no bespoke resolution mechanism — it mounts into the same
358/// hash-addressed [`Environment::manifest`] every project source lives in,
359/// keyed by the same root-relative, forward-slash string-key convention
360/// (`std/conventions/screenplay.brink`). Native module identity then mints
361/// `std::conventions::screenplay` for it — the reserved `std/` leading
362/// segment mints as a top-level PEER of `story`, never nested under it
363/// (issue #2245, decision-log 2026-08-04 "peer roots" ruling); every other
364/// key still mints under `story` exactly as before, so no *caller* of
365/// `brink_db::modules::native_module_path` needs a std-specific rule of its
366/// own — the peer-root check lives once, inside that function.
367///
368/// `include_str!` (not a runtime filesystem read) because the wasm build
369/// (`@brink-lang/web`) has no filesystem — the ruling's explicit
370/// instruction: "Embed the source in the binary." Each new stdlib
371/// module/preset is added here as it ships; nothing downstream changes.
372///
373/// Scope (per the ruling): this is the *mount* only. Actually importing a
374/// mounted module (`use std::…`) additionally needs #1582's pub marker and
375/// #2167's closure-scoped confinement — neither shipped yet, so a mounted
376/// module's items are not yet reachable from a project's own `use`.
377const STDLIB_SOURCES: &[(&str, &str)] = &[(
378 "std/conventions/screenplay.brink",
379 include_str!(concat!(
380 env!("CARGO_MANIFEST_DIR"),
381 "/std/conventions/screenplay.brink"
382 )),
383)];
384
385/// The stdlib source set — `(root-relative key, source text)` pairs — for a
386/// caller that builds its own analysis universe *outside* [`Project::load`]
387/// (#2198): `brink-lsp` and `brink-cli`'s `ide` subcommand handlers construct
388/// their own `Driver`/`ProjectDb` directly rather than going through
389/// [`Environment`], so they cannot reach [`mount_stdlib`]'s private
390/// manifest/content-store merge. Rather than a second, parallel mount
391/// mechanism (the "second road" failure this issue exists to close), those
392/// callers pull the identical `(key, text)` pairs from here and fold them
393/// into whatever file-registration primitive their own loader already uses
394/// (`ProjectDb::set_file`), preserving the same "a project's own file at the
395/// same key wins" precedence [`mount_stdlib`] applies. This is the single
396/// source of truth both producers read — adding a stdlib module here is
397/// still the only place a new one is registered.
398#[must_use]
399pub fn stdlib_sources() -> &'static [(&'static str, &'static str)] {
400 STDLIB_SOURCES
401}
402
403/// Merge [`STDLIB_SOURCES`] into a manifest/content pair being assembled by
404/// [`Project::load`] — the whole mechanism the ruling describes: the
405/// producer adds stdlib entries, the same way it adds any other source key.
406/// A project source already present at the same key wins over the embedded
407/// copy rather than being silently clobbered (`std/` is a reserved-by-
408/// convention path, so a real collision is not expected, but "project data
409/// always wins" costs nothing and avoids a surprising override).
410fn mount_stdlib(
411 manifest: &mut BTreeMap<String, ContentHash>,
412 inline: &mut BTreeMap<ContentHash, String>,
413) {
414 for (key, text) in STDLIB_SOURCES {
415 if manifest.contains_key(*key) {
416 continue;
417 }
418 let hash = ContentHash::of(text);
419 inline.entry(hash).or_insert_with(|| (*text).to_string());
420 manifest.insert((*key).to_string(), hash);
421 }
422}
423
424/// A native `.brink` key with a `..` segment — `native_module_path` treats
425/// `..` literally, so letting one through would mint a bogus module. Mirrors
426/// `brink_driver`'s `discover_native` guard (issue #1288 review note (a)).
427fn is_dotdot_polluted(key: &str) -> bool {
428 key.split('/').any(|segment| segment == "..")
429}
430
431/// The `.brink` native source extension.
432const NATIVE_EXTENSION: &str = "brink";
433
434/// Collect the compilation universe for `entry` as a key→source map.
435///
436/// Native (`.brink`): the tree *is* the universe — every `.brink` key it
437/// enumerates. Ink (`.ink`): the `INCLUDE`-reachable set from `entry`,
438/// discovered by reusing the driver's BFS over the tree's reads (so the ink /
439/// native discovery duplication is not re-implemented here).
440fn collect_sources(
441 tree: &dyn SourceTree,
442 entry: &str,
443) -> Result<BTreeMap<String, String>, LoadError> {
444 if brink_driver::is_native(Path::new(entry)) {
445 let mut map = BTreeMap::new();
446 for key in tree.list()? {
447 if Path::new(&key)
448 .extension()
449 .is_none_or(|ext| ext != NATIVE_EXTENSION)
450 {
451 continue;
452 }
453 if is_dotdot_polluted(&key) {
454 return Err(LoadError::InvalidSourceKey(key));
455 }
456 let text = tree.read(&key)?;
457 map.insert(key, text);
458 }
459 Ok(map)
460 } else {
461 // Reuse the driver's `INCLUDE` BFS, reading through the tree.
462 let mut driver = Driver::new();
463 driver.discover(entry, |key| tree.read(key))?;
464 let db = driver.db();
465 let mut map = BTreeMap::new();
466 for id in db.file_ids() {
467 if let (Some(path), Some(source)) = (db.file_path(id), db.source(id)) {
468 map.insert(path.to_string(), source.to_string());
469 }
470 }
471 Ok(map)
472 }
473}
474
475/// Resolve the effective [`AnalysisOptions`] for `entry`: start from the
476/// default, apply a discovered `brink.toml` (honoring override precedence),
477/// then apply the explicit overrides — including `overrides.lints`/
478/// `overrides.deny_warnings` (issue #1373), applied last (via
479/// [`AnalysisOptions::apply_lint_overrides`]) so they win over the file
480/// regardless of whether a `brink.toml` was even discovered. The one
481/// resolution point every mount inherits (#1005 precedence:
482/// `CLI/API > file > default`).
483fn resolve_options(
484 tree: &dyn SourceTree,
485 entry: &str,
486 overrides: &OptionOverrides,
487) -> Result<AnalysisOptions, LoadError> {
488 // Fresh on every call (issue #1436) — never hoisted out of this
489 // function or reused across calls. `apply_project_config`'s
490 // `dialect`/`types` fields are "unset means untouched"; starting from
491 // `default()` every time is what stops one `Project::load` call's
492 // resolved dialect/types from silently surviving into the next,
493 // unrelated one. See `Project::load`'s doc comment and
494 // `AnalysisOptions::apply_project_config`'s "must be fresh" invariant.
495 let mut options = AnalysisOptions::default();
496
497 if let Some(config_key) = discover_from_entry_in_tree(tree, entry)? {
498 let text = tree
499 .read(&config_key)
500 .map_err(|source| LoadError::ConfigRead {
501 path: config_key.clone(),
502 source,
503 })?;
504 let (config, warnings) =
505 parse_str_at(config_key.clone(), &text).map_err(|source| LoadError::Config {
506 path: config_key.clone(),
507 source: Box::new(source),
508 })?;
509 for warning in &warnings {
510 // The producer is the effectful side; surfacing unknown-key
511 // warnings here (rather than dropping them) preserves the CLI's
512 // pre-#1306 "warn, never fail" behavior — a silent drop would be a
513 // bug (house rule).
514 tracing::warn!("[{config_key}] {warning}");
515 }
516 let config_warnings = options.apply_project_config(
517 &config,
518 overrides.dialect.is_some(),
519 overrides.types.is_some(),
520 );
521 for warning in &config_warnings {
522 // Same channel as the unknown-key warnings above: an unknown or
523 // non-overridable `[lints]` code, an unrecognized `[fix]` code
524 // (issue #3447), or an unrecognized `[project] elements` preset
525 // name (issue #1874), is never silently dropped (house rule).
526 tracing::warn!("[{config_key}] {warning}");
527 }
528 }
529
530 if let Some(dialect) = overrides.dialect {
531 options.dialect = dialect;
532 }
533 if let Some(types) = overrides.types {
534 options.types = Some(types);
535 }
536 // D6 (`docs/debugger-spec.md` §1.2): no `brink.toml` spelling to defer
537 // to (see `OptionOverrides::debug_info`'s doc) — a plain unconditional
538 // assignment, not an `if let Some(...)` like `dialect`/`types` above.
539 options.emit_debug_info = overrides.debug_info;
540
541 // The top of the `CLI/API > file > default` stack (#1373): applied last
542 // so an explicit `--deny`/`--warn`/`--allow`/`-D warnings` always wins
543 // over whatever the file (or nothing, if no `brink.toml` was found) set
544 // for the same code.
545 let lint_override_warnings =
546 options.apply_lint_overrides(&overrides.lints, overrides.deny_warnings);
547 for warning in &lint_override_warnings {
548 // Same "warn, never silently drop" channel as the file-sourced
549 // warnings above (house rule).
550 tracing::warn!("{warning}");
551 }
552
553 Ok(options)
554}
555
556// ── The pure compile over the input ──────────────────────────────────
557
558/// Compile an [`Environment`] — the **pure** function over the reified input.
559///
560/// Seeds a fresh salsa `ProjectDb` from the frozen value
561/// (`set_analysis_options` + `set_file` per source key, in the manifest's
562/// deterministic sorted order, so native `FileId`s/module identity mint
563/// exactly as native discovery would + `set_entry`) and pulls the memoized
564/// `story_data` query. No ambient reads, no walk-up, no I/O.
565pub fn compile(env: &Environment) -> Result<CompileOutput, CompileError> {
566 let mut driver = Driver::new();
567 driver.set_analysis_options(env.options.clone());
568
569 for key in env.source_keys() {
570 if let Some(text) = env.source_text(key) {
571 driver.db_mut().set_file(key, text.into_owned());
572 }
573 }
574
575 if driver.db_mut().set_entry(&env.entry).is_none() {
576 return Err(CompileError::Io(io::Error::new(
577 io::ErrorKind::NotFound,
578 format!("entry file not in environment: {}", env.entry),
579 )));
580 }
581
582 let product = driver.db().story_data().cloned().unwrap_or_default();
583
584 let Some(story) = product.story else {
585 let mut all = product.errors;
586 all.extend(product.warnings);
587 return Err(CompileError::Diagnostics(resolve_diagnostics(
588 driver.db(),
589 all,
590 )));
591 };
592
593 Ok(CompileOutput {
594 data: Arc::unwrap_or_clone(story),
595 warnings: resolve_diagnostics(driver.db(), product.warnings),
596 })
597}
598
599/// Resolve `FileId`-keyed diagnostics to path-carrying [`ResolvedDiagnostic`]s
600/// while the db is still alive (it owns the `FileId`→path map). Mirrors
601/// `brink-compiler`'s own resolution, using only the public `ProjectDb` API —
602/// including resolving `severity` through `brink_driver::effective_severity`
603/// against the db's own `AnalysisOptions`, same as the mirrored function
604/// (issue #1162), so the two never drift on which severity a diagnostic
605/// carries.
606fn resolve_diagnostics(
607 db: &brink_driver::ProjectDb,
608 diags: Vec<Diagnostic>,
609) -> Vec<ResolvedDiagnostic> {
610 let opts = db.analysis_options();
611 let types = opts.type_policy();
612 diags
613 .into_iter()
614 // Suppressed by `[lints] allow` — dropped rather than resolved
615 // (#3173).
616 .filter_map(|d| {
617 let severity = brink_driver::effective_severity(d.code, types, &opts.lints)?;
618 Some(ResolvedDiagnostic {
619 path: db.file_path(d.file).unwrap_or_default().to_string(),
620 file: d.file,
621 range: d.range,
622 message: d.message,
623 severity,
624 code: d.code,
625 })
626 })
627 .collect()
628}
629
630// ── Errors ───────────────────────────────────────────────────────────
631
632/// A failure producing an [`Environment`] from a mount.
633#[derive(Debug, thiserror::Error)]
634pub enum LoadError {
635 /// An I/O error reading or enumerating the tree.
636 #[error("I/O error: {0}")]
637 Io(#[from] io::Error),
638 /// `INCLUDE` discovery failed (missing include, circular include).
639 #[error("discovery error: {0}")]
640 Discover(#[from] brink_driver::DiscoverError),
641 /// A discovered `brink.toml` could not be parsed (malformed TOML, or a
642 /// recognized key with an out-of-range value). Unknown keys are warnings,
643 /// never this error. Carries the discovered `path` so the message names
644 /// which file failed — lost when this variant went through a bare
645 /// `#[from] ConfigError` in #1306 (#1369 restores it). `source` is
646 /// itself now path-carrying too (#1384: `parse_str_at` threads `path`
647 /// into every `ConfigError` it raises), so this field is kept for
648 /// structural/pattern-matching access (existing callers destructure
649 /// `LoadError::Config { path, .. }`) rather than duplicated into the
650 /// rendered message — `source`'s own `Display` already names the file.
651 /// `source` is boxed: `ConfigError` grew past `clippy::result_large_err`'s
652 /// threshold once #1384 threaded `path` into its own variants, and this
653 /// is the one `LoadError` variant that stacks a second `path` field on
654 /// top of it.
655 #[error("{source}")]
656 Config {
657 /// The root-relative key of the `brink.toml` that failed to parse.
658 path: String,
659 #[source]
660 source: Box<ConfigError>,
661 },
662 /// A discovered `brink.toml` could not be *read* (permission error,
663 /// non-UTF-8 bytes, or any other I/O failure) — the other half of
664 /// #1369's regression: `tree.read(&config_key)?` used to fall through
665 /// the bare `#[from] io::Error` on [`LoadError::Io`], which carries no
666 /// path (`RealFs::read` is `fs::read_to_string`, and std I/O errors
667 /// don't carry the path that failed). Carries the discovered `path` so
668 /// this half names the file too.
669 #[error("failed to read project config {path}: {source}")]
670 ConfigRead {
671 /// The root-relative key of the `brink.toml` that failed to read.
672 path: String,
673 #[source]
674 source: io::Error,
675 },
676 /// A native source key is not root-relative (contains a `..` segment) — a
677 /// save-key-identity guardrail against a `SourceTree` that violates the
678 /// contract.
679 #[error("invalid source key `{0}` (must be root-relative, no `..`)")]
680 InvalidSourceKey(String),
681}
682
683#[cfg(test)]
684mod tests {
685 use super::*;
686 use brink_driver::LintLevel;
687 use brink_source_tree::InMemory;
688
689 fn tree(files: &[(&str, &str)]) -> InMemory {
690 InMemory::new(
691 files
692 .iter()
693 .map(|(k, v)| ((*k).to_string(), (*v).to_string()))
694 .collect::<BTreeMap<_, _>>(),
695 )
696 }
697
698 // ── content addressing ───────────────────────────────────────────
699
700 #[test]
701 fn content_hash_is_deterministic_and_content_addressed() {
702 assert_eq!(ContentHash::of("hello"), ContentHash::of("hello"));
703 assert_ne!(ContentHash::of("hello"), ContentHash::of("world"));
704 }
705
706 #[test]
707 fn source_text_resolves_through_key_hash_store() {
708 let t = tree(&[("main.brink", "flow main() {}")]);
709 let env = Project::load(&t, "main.brink", &OptionOverrides::default()).expect("loads");
710
711 assert_eq!(
712 env.source_text("main.brink").as_deref(),
713 Some("flow main() {}")
714 );
715 assert_eq!(env.source_text("absent.brink"), None);
716 }
717
718 #[test]
719 fn identical_content_is_stored_once_but_keyed_twice() {
720 let t = tree(&[("a.brink", "flow a() {}"), ("b.brink", "flow a() {}")]);
721 let env = Project::load(&t, "a.brink", &OptionOverrides::default()).expect("loads");
722
723 let ContentStore::Inline(store) = &env.content;
724 // Two project manifest keys (deduplicated to one stored blob) plus
725 // the mounted stdlib entry (#2080) — three keys, two stored blobs.
726 assert_eq!(env.source_keys().count(), 3);
727 assert_eq!(store.len(), 2);
728 }
729
730 #[test]
731 fn source_keys_are_sorted() {
732 let t = tree(&[
733 ("z.brink", "flow z() {}"),
734 ("a.brink", "flow a() {}"),
735 ("m.brink", "flow m() {}"),
736 ]);
737 let env = Project::load(&t, "a.brink", &OptionOverrides::default()).expect("loads");
738 let keys: Vec<_> = env.source_keys().collect();
739 // The mounted stdlib key (#2080) sorts between "m.brink" and
740 // "z.brink".
741 assert_eq!(
742 keys,
743 vec![
744 "a.brink",
745 "m.brink",
746 "std/conventions/screenplay.brink",
747 "z.brink"
748 ]
749 );
750 }
751
752 // ── serialize / round-trip / hash ────────────────────────────────
753
754 #[test]
755 fn environment_round_trips_through_json_unchanged() {
756 let t = tree(&[("main.brink", "flow main() {}")]);
757 let env = Project::load(&t, "main.brink", &OptionOverrides::default()).expect("loads");
758
759 let json = serde_json::to_string(&env).expect("serializes");
760 let back: Environment = serde_json::from_str(&json).expect("deserializes");
761
762 assert_eq!(env, back);
763 assert_eq!(env.content_hash(), back.content_hash());
764 }
765
766 #[test]
767 fn content_hash_changes_when_a_source_changes() {
768 let a = Project::load(
769 &tree(&[("m.brink", "flow m() {}")]),
770 "m.brink",
771 &OptionOverrides::default(),
772 )
773 .expect("loads");
774 let b = Project::load(
775 &tree(&[("m.brink", "flow m() { Hi. }")]),
776 "m.brink",
777 &OptionOverrides::default(),
778 )
779 .expect("loads");
780 assert_ne!(a.content_hash(), b.content_hash());
781 }
782
783 #[test]
784 fn content_hash_changes_when_options_change() {
785 let base = tree(&[("m.brink", "flow m() {}")]);
786 let default = Project::load(&base, "m.brink", &OptionOverrides::default()).expect("loads");
787 let overridden = Project::load(
788 &base,
789 "m.brink",
790 &OptionOverrides {
791 dialect: Some(Dialect::Brink),
792 ..OptionOverrides::default()
793 },
794 )
795 .expect("loads");
796 assert_ne!(default.content_hash(), overridden.content_hash());
797 }
798
799 // ── config resolution / override precedence ──────────────────────
800
801 #[test]
802 fn brink_toml_dialect_is_discovered_over_the_tree() {
803 let t = tree(&[
804 ("brink.toml", "[project]\ndialect = \"brink\"\n"),
805 ("main.brink", "flow main() {}"),
806 ]);
807 let env = Project::load(&t, "main.brink", &OptionOverrides::default()).expect("loads");
808 assert_eq!(env.options.dialect, Dialect::Brink);
809 }
810
811 #[test]
812 fn brink_toml_is_discovered_by_walking_up_from_the_entry() {
813 let t = tree(&[
814 ("brink.toml", "[project]\ndialect = \"brink\"\n"),
815 ("chapters/main.brink", "flow main() {}"),
816 ]);
817 let env =
818 Project::load(&t, "chapters/main.brink", &OptionOverrides::default()).expect("loads");
819 assert_eq!(env.options.dialect, Dialect::Brink);
820 }
821
822 #[test]
823 fn explicit_override_wins_over_brink_toml() {
824 let t = tree(&[
825 ("brink.toml", "[project]\ndialect = \"brink\"\n"),
826 ("main.brink", "flow main() {}"),
827 ]);
828 let env = Project::load(
829 &t,
830 "main.brink",
831 &OptionOverrides {
832 dialect: Some(Dialect::StrictInk),
833 ..OptionOverrides::default()
834 },
835 )
836 .expect("loads");
837 assert_eq!(env.options.dialect, Dialect::StrictInk);
838 }
839
840 #[test]
841 fn no_brink_toml_yields_default_options() {
842 let t = tree(&[("main.brink", "flow main() {}")]);
843 let env = Project::load(&t, "main.brink", &OptionOverrides::default()).expect("loads");
844 assert_eq!(env.options, AnalysisOptions::default());
845 }
846
847 /// #1436: pins the "repeat compiles are deterministic" invariant
848 /// `Project::load`'s doc comment now names explicitly —
849 /// `resolve_options` must resolve every call from a fresh
850 /// `AnalysisOptions::default()`, never one mutated by a prior call.
851 ///
852 /// First compile resolves `dialect = Brink` from its own `brink.toml`.
853 /// The second, completely unrelated compile has no `brink.toml` at
854 /// all — if `resolve_options` ever stopped constructing `default()`
855 /// fresh (e.g. a future change hoisted/cached `AnalysisOptions` across
856 /// `load` calls "for efficiency"), `apply_project_config`'s "unset
857 /// means untouched" rule for `dialect`/`types` would let the first
858 /// call's `Brink` silently survive into the second call's result
859 /// instead of resolving to the dialect-less default — exactly the
860 /// leak `AnalysisOptions::apply_project_config`'s own "must be fresh"
861 /// doc warns every non-editor-session caller against.
862 #[test]
863 fn repeat_compiles_do_not_leak_options_across_project_load_calls() {
864 let brink_tree = tree(&[
865 ("brink.toml", "[project]\ndialect = \"brink\"\n"),
866 ("main.brink", "flow main() {}"),
867 ]);
868 let first =
869 Project::load(&brink_tree, "main.brink", &OptionOverrides::default()).expect("loads");
870 assert_eq!(first.options.dialect, Dialect::Brink);
871
872 let default_tree = tree(&[("main2.brink", "flow main() {}")]);
873 let second = Project::load(&default_tree, "main2.brink", &OptionOverrides::default())
874 .expect("loads");
875 assert_eq!(
876 second.options,
877 AnalysisOptions::default(),
878 "a later, unrelated Project::load call must never observe an \
879 earlier call's resolved AnalysisOptions -- each call must \
880 resolve from a fresh AnalysisOptions::default(), not a \
881 reused/mutated one; got {:?}",
882 second.options
883 );
884 }
885
886 #[test]
887 fn malformed_brink_toml_is_a_load_error() {
888 let t = tree(&[
889 ("brink.toml", "[project]\ndialect = \"sideways\"\n"),
890 ("main.brink", "flow main() {}"),
891 ]);
892 let err = Project::load(&t, "main.brink", &OptionOverrides::default())
893 .expect_err("invalid dialect value must fail load");
894 assert!(matches!(err, LoadError::Config { .. }));
895 }
896
897 /// #1369: the `Config` error must name the discovered `brink.toml`'s
898 /// path — lost since #1306 when the variant became a bare
899 /// `#[from] ConfigError` with no path carried alongside it.
900 #[test]
901 fn malformed_brink_toml_error_names_its_path() {
902 let t = tree(&[
903 ("brink.toml", "[project]\ndialect = \"sideways\"\n"),
904 ("main.brink", "flow main() {}"),
905 ]);
906 let err = Project::load(&t, "main.brink", &OptionOverrides::default())
907 .expect_err("invalid dialect value must fail load");
908 let LoadError::Config { path, .. } = &err else {
909 unreachable!("expected LoadError::Config, got {err:?}");
910 };
911 assert_eq!(path, "brink.toml");
912 assert!(
913 err.to_string().contains("brink.toml"),
914 "error message must name the malformed file, got: {err}"
915 );
916 }
917
918 /// Nested discovery (walking up from a subdirectory) must report the
919 /// `brink.toml`'s actual discovered *key* — a multi-segment root-relative
920 /// path — not just a bare filename. A `brink.toml` at the tree root
921 /// (the previous fixture) discovers as the bare `"brink.toml"` key
922 /// itself, which the preceding test already proves; this fixture nests
923 /// the `brink.toml` too, so `path` only passes if the discovered key is
924 /// actually threaded through rather than, say, a hardcoded filename.
925 #[test]
926 fn malformed_brink_toml_error_names_its_nested_path() {
927 let t = tree(&[
928 ("chapters/brink.toml", "[project]\ndialect = \"sideways\"\n"),
929 ("chapters/deep/main.brink", "flow main() {}"),
930 ]);
931 let err = Project::load(&t, "chapters/deep/main.brink", &OptionOverrides::default())
932 .expect_err("invalid dialect value must fail load");
933 let LoadError::Config { path, .. } = &err else {
934 unreachable!("expected LoadError::Config, got {err:?}");
935 };
936 assert_eq!(path, "chapters/brink.toml");
937 assert!(
938 err.to_string().contains("chapters/brink.toml"),
939 "error message must name the nested malformed file, got: {err}"
940 );
941 }
942
943 /// #1369's other half: a `brink.toml` that is *discovered* but fails to
944 /// *read* (non-UTF-8 bytes) must also name its path — the failure mode
945 /// that fell through the bare `#[from] io::Error` on `LoadError::Io`
946 /// pre-fix, since `RealFs::read` (`fs::read_to_string`) returns a std
947 /// I/O error with no path attached. `InMemory` can't represent invalid
948 /// UTF-8 (its map is `String`-keyed and -valued), so this exercises the
949 /// real filesystem via `RealFs` instead.
950 #[test]
951 fn unreadable_brink_toml_error_names_its_path() {
952 let dir = std::env::temp_dir().join(format!(
953 "brink-environment-unreadable-config-{}",
954 std::process::id()
955 ));
956 std::fs::create_dir_all(&dir).unwrap();
957 // Invalid UTF-8: a lone continuation byte can never start a valid
958 // UTF-8 sequence, so `fs::read_to_string` fails with `InvalidData`.
959 std::fs::write(dir.join("brink.toml"), [0x80_u8, 0x81, 0x82]).unwrap();
960 std::fs::write(dir.join("main.brink"), "flow main() {}").unwrap();
961
962 let t = brink_driver::RealFs::new(&dir);
963 let err = Project::load(&t, "main.brink", &OptionOverrides::default())
964 .expect_err("non-UTF-8 brink.toml must fail load");
965 let LoadError::ConfigRead { path, .. } = &err else {
966 unreachable!("expected LoadError::ConfigRead, got {err:?}");
967 };
968 assert_eq!(path, "brink.toml");
969 assert!(
970 err.to_string().contains("brink.toml"),
971 "error message must name the unreadable file, got: {err}"
972 );
973
974 std::fs::remove_dir_all(&dir).ok();
975 }
976
977 // ── [lints] resolution (issue #1160) ──────────────────────────────
978 //
979 // `Project::load` is the ONE point that folds a discovered `brink.toml`
980 // into the resolved `AnalysisOptions` (via
981 // `AnalysisOptions::apply_project_config`) — these tests exercise that
982 // exact seam, then prove the resolved policy is actually consulted by
983 // `compile`'s error gate (not just stored inertly on `Environment`).
984
985 /// A logic line with no effect (`~` alone) — `DiagnosticCode::E014`,
986 /// `Warning` by default (`brink_ir::hir::lower::tests::
987 /// logic_line_emits_diagnostic_on_malformed`).
988 const E014_SOURCE: &str = "Hello.\n~\n-> END\n";
989
990 #[test]
991 fn brink_toml_lints_table_resolves_into_options() {
992 let t = tree(&[
993 ("brink.toml", "[lints]\nE014 = \"deny\"\n"),
994 ("main.ink", E014_SOURCE),
995 ]);
996 let env = Project::load(&t, "main.ink", &OptionOverrides::default()).expect("loads");
997 assert_eq!(
998 env.options.lints.overrides.get("E014"),
999 Some(&LintLevel::Deny)
1000 );
1001 }
1002
1003 #[test]
1004 fn brink_toml_deny_warnings_resolves_into_options() {
1005 let t = tree(&[
1006 ("brink.toml", "[lints]\ndeny-warnings = true\n"),
1007 ("main.ink", E014_SOURCE),
1008 ]);
1009 let env = Project::load(&t, "main.ink", &OptionOverrides::default()).expect("loads");
1010 assert!(env.options.lints.deny_warnings);
1011 }
1012
1013 #[test]
1014 fn absent_lints_table_leaves_options_lints_at_default() {
1015 let t = tree(&[("main.ink", E014_SOURCE)]);
1016 let env = Project::load(&t, "main.ink", &OptionOverrides::default()).expect("loads");
1017 assert_eq!(env.options.lints, brink_driver::LintPolicy::default());
1018 }
1019
1020 #[test]
1021 fn e014_warning_compiles_cleanly_by_default() {
1022 // No `[lints]` table: E014 stays a Warning, never blocks compile —
1023 // "absent table = today's behavior" acceptance criterion.
1024 let t = tree(&[("main.ink", E014_SOURCE)]);
1025 let env = Project::load(&t, "main.ink", &OptionOverrides::default()).expect("loads");
1026 let out = compile(&env).expect("a Warning-only diagnostic must not block compilation");
1027 assert!(
1028 out.warnings
1029 .iter()
1030 .any(|d| d.code == brink_ir::DiagnosticCode::E014),
1031 "expected E014 among the warnings: {:?}",
1032 out.warnings
1033 );
1034 }
1035
1036 #[test]
1037 fn brink_toml_lints_deny_relevels_e014_and_blocks_compile() {
1038 // The same source as above, but `[lints] E014 = "deny"` re-levels
1039 // it to Error — this must now fail the same `has_errors`-style
1040 // gate `compile` reads through `Environment.options`.
1041 let t = tree(&[
1042 ("brink.toml", "[lints]\nE014 = \"deny\"\n"),
1043 ("main.ink", E014_SOURCE),
1044 ]);
1045 let env = Project::load(&t, "main.ink", &OptionOverrides::default()).expect("loads");
1046 let err = compile(&env).expect_err("a denied E014 must block compilation");
1047 let CompileError::Diagnostics(diags) = err else {
1048 unreachable!("expected CompileError::Diagnostics, got {err:?}");
1049 };
1050 assert!(
1051 diags
1052 .iter()
1053 .any(|d| d.code == brink_ir::DiagnosticCode::E014),
1054 "expected E014 among the surfaced diagnostics: {diags:?}"
1055 );
1056 }
1057
1058 #[test]
1059 fn brink_toml_deny_warnings_blocks_compile_on_an_unconfigured_warning() {
1060 // `deny-warnings = true` with no per-code override: E014 (an
1061 // ordinary, unconfigured Warning) is still promoted to Error.
1062 let t = tree(&[
1063 ("brink.toml", "[lints]\ndeny-warnings = true\n"),
1064 ("main.ink", E014_SOURCE),
1065 ]);
1066 let env = Project::load(&t, "main.ink", &OptionOverrides::default()).expect("loads");
1067 let err = compile(&env).expect_err("deny-warnings must promote E014 to a compile error");
1068 assert!(matches!(err, CompileError::Diagnostics(_)));
1069 }
1070
1071 // ── OptionOverrides.lints / .deny_warnings: CLI/API tier (#1373) ──
1072
1073 #[test]
1074 fn override_lints_resolves_into_options() {
1075 let t = tree(&[("main.ink", E014_SOURCE)]);
1076 let mut lints = BTreeMap::new();
1077 lints.insert("E014".to_owned(), LintLevel::Deny);
1078 let env = Project::load(
1079 &t,
1080 "main.ink",
1081 &OptionOverrides {
1082 lints,
1083 ..OptionOverrides::default()
1084 },
1085 )
1086 .expect("loads");
1087 assert_eq!(
1088 env.options.lints.overrides.get("E014"),
1089 Some(&LintLevel::Deny)
1090 );
1091 }
1092
1093 #[test]
1094 fn override_deny_e014_blocks_compile_with_no_brink_toml() {
1095 // No `brink.toml` at all — a CLI `--deny E014` alone must still
1096 // relevel E014 to Error and block compilation, exactly as a file's
1097 // `[lints] E014 = "deny"` already does.
1098 let t = tree(&[("main.ink", E014_SOURCE)]);
1099 let mut lints = BTreeMap::new();
1100 lints.insert("E014".to_owned(), LintLevel::Deny);
1101 let env = Project::load(
1102 &t,
1103 "main.ink",
1104 &OptionOverrides {
1105 lints,
1106 ..OptionOverrides::default()
1107 },
1108 )
1109 .expect("loads");
1110 let err = compile(&env).expect_err("CLI --deny E014 must block compilation");
1111 let CompileError::Diagnostics(diags) = err else {
1112 unreachable!("expected CompileError::Diagnostics, got {err:?}");
1113 };
1114 assert!(
1115 diags
1116 .iter()
1117 .any(|d| d.code == brink_ir::DiagnosticCode::E014),
1118 "expected E014 among the surfaced diagnostics: {diags:?}"
1119 );
1120 }
1121
1122 #[test]
1123 fn override_deny_warnings_blocks_compile_with_no_brink_toml() {
1124 let t = tree(&[("main.ink", E014_SOURCE)]);
1125 let env = Project::load(
1126 &t,
1127 "main.ink",
1128 &OptionOverrides {
1129 deny_warnings: Some(true),
1130 ..OptionOverrides::default()
1131 },
1132 )
1133 .expect("loads");
1134 let err = compile(&env).expect_err("CLI -D warnings must promote E014 to a compile error");
1135 assert!(matches!(err, CompileError::Diagnostics(_)));
1136 }
1137
1138 #[test]
1139 fn override_lints_wins_over_a_conflicting_brink_toml_entry() {
1140 // `brink.toml` denies E014; the CLI override allows it — the CLI
1141 // must win (#1005/#1373's `CLI/API > file > default` precedence),
1142 // so the same source now compiles cleanly.
1143 let t = tree(&[
1144 ("brink.toml", "[lints]\nE014 = \"deny\"\n"),
1145 ("main.ink", E014_SOURCE),
1146 ]);
1147 let mut lints = BTreeMap::new();
1148 lints.insert("E014".to_owned(), LintLevel::Allow);
1149 let env = Project::load(
1150 &t,
1151 "main.ink",
1152 &OptionOverrides {
1153 lints,
1154 ..OptionOverrides::default()
1155 },
1156 )
1157 .expect("loads");
1158 assert_eq!(
1159 env.options.lints.overrides.get("E014"),
1160 Some(&LintLevel::Allow),
1161 "the CLI override must replace the file's E014 = deny"
1162 );
1163 compile(&env).expect("CLI --allow E014 must win over brink.toml's E014 = deny");
1164 }
1165
1166 // ── native universe = whole tree; brink.toml never a source ──────
1167
1168 #[test]
1169 fn native_universe_is_the_whole_tree_excluding_config() {
1170 let t = tree(&[
1171 ("brink.toml", "[project]\n"),
1172 ("main.brink", "flow main() {}"),
1173 ("lib/util.brink", "flow util() {}"),
1174 ("README.md", "not source"),
1175 ]);
1176 let env = Project::load(&t, "main.brink", &OptionOverrides::default()).expect("loads");
1177 let keys: Vec<_> = env.source_keys().collect();
1178 // The mounted stdlib key (#2080) sorts between "main.brink" and
1179 // nothing else here, since 'm' < 's'.
1180 assert_eq!(
1181 keys,
1182 vec![
1183 "lib/util.brink",
1184 "main.brink",
1185 "std/conventions/screenplay.brink"
1186 ]
1187 );
1188 }
1189
1190 #[test]
1191 fn dotdot_native_key_is_rejected() {
1192 struct Hostile;
1193 impl SourceTree for Hostile {
1194 fn list(&self) -> io::Result<Vec<String>> {
1195 Ok(vec!["a.brink".to_string(), "../escape.brink".to_string()])
1196 }
1197 fn read(&self, key: &str) -> io::Result<String> {
1198 Ok(format!("-- {key} --"))
1199 }
1200 }
1201 let err = Project::load(&Hostile, "a.brink", &OptionOverrides::default())
1202 .expect_err("dotdot key must be rejected");
1203 assert!(matches!(err, LoadError::InvalidSourceKey(k) if k == "../escape.brink"));
1204 }
1205
1206 // ── ink INCLUDE discovery ────────────────────────────────────────
1207
1208 #[test]
1209 fn ink_universe_follows_the_include_graph() {
1210 let t = tree(&[
1211 ("main.ink", "INCLUDE lib.ink\nHello.\n-> END\n"),
1212 ("lib.ink", "== helper ==\n-> DONE\n"),
1213 ("unreferenced.ink", "== orphan ==\n-> DONE\n"),
1214 ]);
1215 let env = Project::load(&t, "main.ink", &OptionOverrides::default()).expect("loads");
1216 let keys: Vec<_> = env.source_keys().collect();
1217 // The orphan is not INCLUDE-reachable, so it is not in the universe.
1218 // The stdlib mount (#2080) is unconditional — it joins an ink
1219 // project's environment too, sorting after "main.ink".
1220 assert_eq!(
1221 keys,
1222 vec!["lib.ink", "main.ink", "std/conventions/screenplay.brink"]
1223 );
1224 }
1225
1226 // ── stdlib mount (#2080) ──────────────────────────────────────────
1227
1228 #[test]
1229 fn stdlib_screenplay_preset_is_mounted_into_every_environment() {
1230 let t = tree(&[("main.brink", "flow main() {}")]);
1231 let env = Project::load(&t, "main.brink", &OptionOverrides::default()).expect("loads");
1232
1233 let mounted = env
1234 .source_text("std/conventions/screenplay.brink")
1235 .expect("the built-in screenplay preset must be mounted into every Environment");
1236 assert!(
1237 mounted.contains("heading"),
1238 "mounted stdlib text looks wrong (embed path misconfigured?): {mounted}"
1239 );
1240 }
1241
1242 /// Reverting `mount_stdlib` (or not calling it from `Project::load`)
1243 /// makes this fail: `source_text` for the stdlib key returns `None`.
1244 #[test]
1245 fn stdlib_mount_is_present_for_a_native_and_an_ink_entry_alike() {
1246 let native = Project::load(
1247 &tree(&[("main.brink", "flow main() {}")]),
1248 "main.brink",
1249 &OptionOverrides::default(),
1250 )
1251 .expect("loads");
1252 let ink = Project::load(
1253 &tree(&[("main.ink", "Hello.\n-> END\n")]),
1254 "main.ink",
1255 &OptionOverrides::default(),
1256 )
1257 .expect("loads");
1258
1259 assert!(
1260 native
1261 .source_text("std/conventions/screenplay.brink")
1262 .is_some()
1263 );
1264 assert!(
1265 ink.source_text("std/conventions/screenplay.brink")
1266 .is_some()
1267 );
1268 }
1269
1270 #[test]
1271 fn a_project_source_at_the_stdlib_key_wins_over_the_embedded_copy() {
1272 let t = tree(&[
1273 ("main.brink", "flow main() {}"),
1274 (
1275 "std/conventions/screenplay.brink",
1276 "// project-authored override\nflow overridden() {}",
1277 ),
1278 ]);
1279 let env = Project::load(&t, "main.brink", &OptionOverrides::default()).expect("loads");
1280 assert_eq!(
1281 env.source_text("std/conventions/screenplay.brink")
1282 .as_deref(),
1283 Some("// project-authored override\nflow overridden() {}"),
1284 "a project's own file at the stdlib's key must win, not be silently clobbered \
1285 by the embedded copy"
1286 );
1287 }
1288
1289 #[test]
1290 fn mounted_stdlib_compiles_cleanly_alongside_an_ordinary_native_project() {
1291 // Proves the mount reaches the real, sole production compile path
1292 // (`brink_environment::compile`, per #2080's ruling) — not just
1293 // that the manifest holds the text. A plain native project must
1294 // still compile, and the mounted stdlib module must not itself
1295 // introduce any diagnostic.
1296 //
1297 // `warnings.is_empty()` alone is vacuous here (review finding on
1298 // #2080): a bare `main.brink` project compiles with zero warnings
1299 // whether or not `mount_stdlib` ran at all, so it cannot
1300 // distinguish "the mount reached the compile" from "the mount
1301 // didn't happen". A native entry's compilation universe is
1302 // "tree is universe" (every `.brink` key joins), so the mounted
1303 // screenplay preset's `heading` handler — which declares
1304 // `extern scene_entered(title, slug)` — must actually land in the
1305 // compiled `StoryData`'s externals table. Assert that too.
1306 let t = tree(&[("main.brink", "flow main() { Hello. }")]);
1307 let env = Project::load(&t, "main.brink", &OptionOverrides::default()).expect("loads");
1308 let out = compile(&env).expect(
1309 "a plain native project must compile cleanly with the stdlib mounted alongside it",
1310 );
1311 assert!(
1312 out.warnings.is_empty(),
1313 "the mounted stdlib module must not itself introduce diagnostics: {:?}",
1314 out.warnings
1315 );
1316 let has_scene_entered_extern = out.data.externals.iter().any(|ext| {
1317 out.data
1318 .name_table
1319 .get(ext.name.0 as usize)
1320 .is_some_and(|name| name == "scene_entered")
1321 });
1322 assert!(
1323 has_scene_entered_extern,
1324 "the mounted screenplay preset's `heading` handler declares \
1325 `extern scene_entered(title, slug)` — its absence from the \
1326 compiled externs means the mount never reached the compile at \
1327 all, got externs: {:?}",
1328 out.data.externals
1329 );
1330 }
1331
1332 #[test]
1333 fn stdlib_mount_is_manifest_only_for_an_ink_entry() {
1334 // Distinguishes native-entry reachability (asserted just above)
1335 // from ink-entry reachability, which review found is NOT the
1336 // same: `brink-db`'s `compilation_closure_files` walks an ink
1337 // entry's `INCLUDE` graph (`topological_order`), and the mounted
1338 // `.brink` key has no `INCLUDE` edge into it, so it is excluded
1339 // from an ink compile's closure entirely — present in the
1340 // `Environment`'s manifest, never lowered into LIR, contributing
1341 // nothing to the compiled story. The PR's original changeset and
1342 // reachability prose claimed the mount is "compiled as an
1343 // ordinary native module alongside the project's own files" /
1344 // "participates in native module resolution exactly like any
1345 // project file" for every compile — true for a native entry
1346 // (previous test), false for an ink one (this test), which is
1347 // `@brink-lang/web`'s ordinary case.
1348 let t = tree(&[("main.ink", "Hello.\n-> END\n")]);
1349 let env = Project::load(&t, "main.ink", &OptionOverrides::default()).expect("loads");
1350 let out = compile(&env).expect(
1351 "a plain ink project must compile cleanly with the stdlib mounted alongside it",
1352 );
1353 assert!(
1354 out.warnings.is_empty(),
1355 "the mounted stdlib module must not itself introduce diagnostics \
1356 for an ink entry either: {:?}",
1357 out.warnings
1358 );
1359 let has_scene_entered_extern = out.data.externals.iter().any(|ext| {
1360 out.data
1361 .name_table
1362 .get(ext.name.0 as usize)
1363 .is_some_and(|name| name == "scene_entered")
1364 });
1365 assert!(
1366 !has_scene_entered_extern,
1367 "an ink entry's compilation closure must NOT include the \
1368 mounted, manifest-only stdlib module (no INCLUDE edge reaches \
1369 it) — its presence here would mean the mount reaches ink \
1370 compiles too, contradicting the manifest-only scope fence: \
1371 {:?}",
1372 out.data.externals
1373 );
1374 }
1375
1376 #[test]
1377 fn mounted_stdlib_introduces_no_diagnostics_under_types_strict() {
1378 // Review finding on #2080: the mounted module now sits inside
1379 // every native project's compilation closure, but the only
1380 // existing compile test for it (`mounted_stdlib_compiles_cleanly_
1381 // alongside_an_ordinary_native_project`, above) runs under
1382 // `AnalysisOptions::default()`, which resolves `TypePolicy::
1383 // Gradual`. A real `.brink` project setting `dialect = brink` in
1384 // `brink.toml` resolves `TypePolicy::Strict` instead
1385 // (`tier1_native_strict.rs`'s own module doc), and any strict
1386 // diagnostic the mounted module produced would then fail every
1387 // strict build. It is clean today — `tier1_native_strict.rs`'s
1388 // baseline has zero rows for `conventions-screenplay-preset` —
1389 // which is exactly what makes this guard cheap to add now, before
1390 // the module grows and a strict finding sneaks in unnoticed.
1391 let t = tree(&[("main.brink", "flow main() { Hello. }")]);
1392 let overrides = OptionOverrides {
1393 types: Some(TypePolicy::Strict),
1394 ..OptionOverrides::default()
1395 };
1396 let env = Project::load(&t, "main.brink", &overrides).expect("loads");
1397 let out = compile(&env).expect(
1398 "a plain native project must compile cleanly under types = strict \
1399 with the stdlib mounted alongside it",
1400 );
1401 assert!(
1402 out.warnings.is_empty(),
1403 "the mounted stdlib module must not itself introduce diagnostics \
1404 under types = strict: {:?}",
1405 out.warnings
1406 );
1407 }
1408
1409 // ── issue #2240 review finding: E181 reachability ─────────────────
1410
1411 /// The `E181` reachability claim this PR originally shipped ("not
1412 /// reachable from any project compilable today") is false. A plain
1413 /// `Brink`-dialect ink project (dialect must be `Brink` for `STRUCT` to
1414 /// parse at all — under the default `StrictInk` it is `E051`, see
1415 /// `issue_460_shared_chunk_ctx.rs`'s own `brink_opts` doc) that declares
1416 /// its own `struct Cue` collides with the std-mounted `screenplay`
1417 /// preset's own `Cue` (#2080): `symbol_index_query` builds the shared
1418 /// index from every `set_file`-registered file regardless of the
1419 /// compilation closure (`compile`'s own loop registers the mounted
1420 /// stdlib key alongside the project's, per
1421 /// `stdlib_mount_is_manifest_only_for_an_ink_entry`'s own doc just
1422 /// above), so std's `Cue` sits in the index even though it never
1423 /// itself joins an ink entry's LIR closure. Neither struct declares a
1424 /// `#@module`, and this project is not all-native
1425 /// (`project_is_all_native`), so M-2d cross-declared-module coexistence
1426 /// never even applies (`manifest::is_cross_declared_module_collision`) —
1427 /// `insert_symbol` treats the pair as a true intra-module duplicate
1428 /// (`E023`).
1429 ///
1430 /// The entry is deliberately named `story.ink`, not `main.ink`:
1431 /// `FileId` mint order follows `set_file` call order, which follows
1432 /// `Environment::source_keys`'s sorted (`BTreeMap`) manifest order —
1433 /// `"story.ink"` sorts *after* `"std/conventions/screenplay.brink"`
1434 /// lexicographically (`'d' < 'o'` at the third byte), so std's `Cue`
1435 /// is inserted into the index first and it is the PROJECT's own later
1436 /// `Cue` that `insert_symbol` drops — the direction the review finding
1437 /// traced. (`main.ink` sorts *before* `"std/…"` and drops std's `Cue`
1438 /// instead, which raises no diagnostic at all since nothing else
1439 /// references it — verified while building this test.)
1440 ///
1441 /// Once the project's own `Cue` is dropped, `build_shape_table`'s
1442 /// self-lookup for it misses on both its exact-file arm (no entry left
1443 /// in `story.ink`) and the unscoped fallback (std's surviving `Cue` is
1444 /// excluded by the fallback's own std-visibility carve-out, issue
1445 /// #2197) — exactly the condition `E181`'s doc describes, and it fires
1446 /// here through the REAL analyzer drop, not a hand-built `SymbolIndex`.
1447 ///
1448 /// Per rule 20a: reverting the `E181` push in
1449 /// `structs::build_shape_table` (restoring the bare `continue`) makes
1450 /// this test fail — `compile` would then succeed instead of erroring,
1451 /// since the struct would go back to being silently dropped with no
1452 /// diagnostic at all.
1453 #[test]
1454 fn e181_is_reachable_from_an_ordinary_ink_project_colliding_with_a_std_preset_name() {
1455 let t = tree(&[(
1456 "story.ink",
1457 "STRUCT Cue = #{\n speaker: string,\n}\nHello.\n-> END\n",
1458 )]);
1459 let overrides = OptionOverrides {
1460 dialect: Some(Dialect::Brink),
1461 ..OptionOverrides::default()
1462 };
1463 let env = Project::load(&t, "story.ink", &overrides).expect("loads");
1464
1465 let err = compile(&env).expect_err(
1466 "a project struct colliding with a std-declared homonym must raise \
1467 E181 and fail compilation, not silently drop the struct",
1468 );
1469 let CompileError::Diagnostics(diags) = err else {
1470 unreachable!("expected a Diagnostics compile error, got: {err:?}");
1471 };
1472 assert!(
1473 diags
1474 .iter()
1475 .any(|d| d.code == brink_ir::DiagnosticCode::E181),
1476 "expected E181 among the blocking diagnostics: {diags:?}"
1477 );
1478 }
1479
1480 // ── issue #2262: the same silent-drop class recurs for `EXTERNAL` ──
1481
1482 /// `E181`'s reachability fix (above) covers `STRUCT` only —
1483 /// `lir::lower::decls::collect_externals`' own self-declaration lookup
1484 /// (`lookup_global(index, file_id, &ext.name.text,
1485 /// SymbolKind::External)`) has the textually identical no-`else`
1486 /// pattern and is reached the exact same way: an ordinary ink project
1487 /// (no `dialect` override needed — `EXTERNAL` is core ink, unlike
1488 /// `STRUCT`) that declares its own `EXTERNAL scene_entered(...)`
1489 /// collides with the std-mounted screenplay preset's own `extern
1490 /// scene_entered` (`std/conventions/screenplay.brink`). Neither
1491 /// declares a `#@module` and this project is not all-native, so M-2d
1492 /// cross-declared-module coexistence never applies — `insert_symbol`
1493 /// treats the pair as a true intra-module duplicate (`E023`) and drops
1494 /// the later one. `"story.ink"` sorts after `"std/conventions/
1495 /// screenplay.brink"` (same ordering argument as the `E181` test), so
1496 /// it is the *project's* `scene_entered` that is dropped.
1497 ///
1498 /// Before issue #2262's fix: this compiles CLEAN (`compile(&env)` is
1499 /// `Ok`) with the project's own `EXTERNAL scene_entered` silently
1500 /// absent from `out.data.externals` — no diagnostic at all, unlike
1501 /// `E181`'s struct case. That is the bug this test proves and pins the
1502 /// fix against.
1503 #[test]
1504 fn external_self_declaration_silently_drops_when_colliding_with_a_std_preset_name() {
1505 let t = tree(&[(
1506 "story.ink",
1507 "EXTERNAL scene_entered(title, slug)\nHello.\n-> END\n",
1508 )]);
1509 let env = Project::load(&t, "story.ink", &OptionOverrides::default()).expect("loads");
1510
1511 let err = compile(&env).expect_err(
1512 "a project EXTERNAL colliding with a std-declared homonym must raise \
1513 a diagnostic and fail compilation, not silently drop the external",
1514 );
1515 let CompileError::Diagnostics(diags) = err else {
1516 unreachable!("expected a Diagnostics compile error, got: {err:?}");
1517 };
1518 assert!(
1519 diags
1520 .iter()
1521 .any(|d| d.code == brink_ir::DiagnosticCode::E184),
1522 "expected E184 (the EXTERNAL/CONST/VAR twin of E181) among the \
1523 blocking diagnostics: {diags:?}"
1524 );
1525 }
1526
1527 // ── the pure compile over the input ──────────────────────────────
1528
1529 #[test]
1530 fn compile_over_environment_produces_story_data() {
1531 let t = tree(&[("main.ink", "Hello, world.\n-> END\n")]);
1532 let env = Project::load(&t, "main.ink", &OptionOverrides::default()).expect("loads");
1533 let out = compile(&env).expect("compiles");
1534 // A real story compiled: at least one container of instructions.
1535 assert!(
1536 !out.data.containers.is_empty(),
1537 "expected compiled containers"
1538 );
1539 }
1540
1541 #[test]
1542 fn compile_surfaces_diagnostics_as_a_compile_error() {
1543 // Extension syntax under the default (strict-ink) dialect is rejected.
1544 let t = tree(&[("main.ink", "VAR arr = 0\n~ { arr = #[1, 2, 3] }\n-> END\n")]);
1545 let env = Project::load(&t, "main.ink", &OptionOverrides::default()).expect("loads");
1546 let err = compile(&env).expect_err("strict-ink must reject extension syntax");
1547 assert!(matches!(err, CompileError::Diagnostics(_)));
1548 }
1549
1550 #[test]
1551 fn load_then_compile_matches_across_a_serialize_round_trip() {
1552 let t = tree(&[("main.ink", "Hello.\n-> END\n")]);
1553 let env = Project::load(&t, "main.ink", &OptionOverrides::default()).expect("loads");
1554 let json = serde_json::to_string(&env).expect("serializes");
1555 let back: Environment = serde_json::from_str(&json).expect("deserializes");
1556
1557 let a = compile(&env).expect("compiles");
1558 let b = compile(&back).expect("compiles from round-tripped env");
1559 // Same input value → same compiled bytes.
1560 let mut buf_a = String::new();
1561 let mut buf_b = String::new();
1562 brink_format::write_inkt(&a.data, &mut buf_a).expect("inkt a");
1563 brink_format::write_inkt(&b.data, &mut buf_b).expect("inkt b");
1564 assert_eq!(buf_a, buf_b);
1565 }
1566}