bevy_brink/source_loader.rs
1//! Asset loader for `.ink` source files (dev mode).
2//!
3//! Compiles ink source at asset-load time, walking the transitive
4//! `INCLUDE` graph asynchronously through Bevy's `AssetReader`. Hot-reload
5//! "just works" because every INCLUDE'd file is fetched via
6//! [`bevy_asset::LoadContext::read_asset_bytes`], which automatically
7//! registers the file as a dependency — when any of them change, Bevy
8//! re-runs the loader.
9//!
10//! Available only when the `dev` feature is enabled. Release builds
11//! should pre-compile to `.inkb` and avoid carrying the compiler.
12//!
13//! ## Async/sync seam (#1360, the `brink-environment` producer consumer)
14//!
15//! [`brink_environment::Project::load`] (and the `compile` it feeds) is
16//! synchronous — it reads through a [`brink_source_tree::SourceTree`], which
17//! has no `async fn`. Bevy's `AssetReader` is async, and on web targets it
18//! has to be — there's no blocking filesystem. We bridge the two by walking
19//! the INCLUDE graph ourselves first (using [`brink_syntax::extract_includes`]
20//! to discover INCLUDEs from cached source), pre-fetching every reachable
21//! file via Bevy's async reader into an in-memory map, then handing that map
22//! to the sync producer as a [`brink_source_tree::InMemory`] tree. The BFS
23//! itself stays here (the mount's async reality); `brink.toml` discovery,
24//! parsing, and override precedence (#1005/#1320) now live entirely in
25//! [`brink_environment::Project::load`] — not re-implemented in this loader.
26
27use std::collections::BTreeMap;
28
29use bevy_app::App;
30use bevy_asset::{AssetLoader, Assets, Handle, LoadContext, io::Reader};
31use bevy_ecs::resource::Resource;
32use bevy_reflect::TypePath;
33use brink_environment::{OptionOverrides, Project};
34use brink_project_config::ProjectConfig;
35use brink_source_tree::InMemory;
36
37use crate::asset::{
38 BrinkStoryAsset, LineTablesAsset, ProgramAsset, emit_story_assets, fresh_context,
39};
40
41/// The `with_config` override [`BrinkAssetsPlugin::build`](crate::BrinkAssetsPlugin)
42/// resolved [`InkLoader::override_config`](InkLoader) from, mirrored into a
43/// resource so [`compile_story_inline`] — a freestanding function with only
44/// `&mut App`, no `AssetLoader` instance to read a field off of — can see the
45/// same value (#1380).
46///
47/// Inserted once, unconditionally, whenever `BrinkAssetsPlugin` builds, from
48/// the exact `self.config` that also seeds `InkLoader` — so the two entry
49/// points read the identical override and can never diverge on it. Both
50/// entry points build their `OptionOverrides` through the one shared
51/// [`overrides_from_config`] mapping fed into `Project::load`'s existing
52/// seam, the same "reuse the resolution path, don't add a second one" fix
53/// applied here that wired `InkLoader` itself.
54/// `None` (the default, or no `BrinkAssetsPlugin` in the app at all) means
55/// [`overrides_from_config`] falls through to [`OptionOverrides::default()`],
56/// byte-identical to pre-#1380 behavior for a host that never calls
57/// `with_config`.
58#[derive(Resource, Debug, Clone, Default)]
59pub(crate) struct BrinkOverrideConfig(pub(crate) Option<ProjectConfig>);
60
61/// Build the [`OptionOverrides`] [`Project::load`] expects from a
62/// `with_config` override — the exact `ProjectConfig` -> `OptionOverrides`
63/// mapping both [`InkLoader::load`] and [`compile_story_inline`] need.
64/// Extracted so the two entry points share one mapping rather than each
65/// hand-rolling their own copy that could silently drift apart (#1380,
66/// following the resolution shape #1553/#1559/#1417 used for the
67/// IDE/LSP/wasm option-propagation gaps).
68///
69/// `config: None` (no override at all) yields
70/// [`OptionOverrides::default()`] — every field unset, so `Project::load`
71/// falls through entirely to the discovered `brink.toml` (or the built-in
72/// default).
73fn overrides_from_config(config: Option<&ProjectConfig>) -> OptionOverrides {
74 OptionOverrides {
75 dialect: config.and_then(|c| c.dialect),
76 types: config.and_then(|c| c.types),
77 lints: config.map(|c| c.lints.clone()).unwrap_or_default(),
78 deny_warnings: config.and_then(|c| c.deny_warnings),
79 // D6 (`docs/debugger-spec.md` §1.2): no engine-runtime path opts a
80 // `bevy-brink` compile into debug info today — that's D9's studio
81 // wiring, not this loader's.
82 debug_info: false,
83 }
84}
85
86/// Asset loader for `.ink` (source) files.
87///
88/// Reads the entry source, walks the `INCLUDE` graph asynchronously
89/// through Bevy's `AssetReader`, lands the drained sources in a
90/// [`brink_source_tree::InMemory`] tree, then goes through the
91/// [`brink_environment`] producer: [`Project::load`] resolves `brink.toml`
92/// and override precedence and freezes an `Environment`, and
93/// [`brink_environment::compile`] compiles it. Links the result via
94/// [`brink_runtime::link`] and emits labeled subassets (`#program`,
95/// `#line_tables`) just like [`InkbLoader`](crate::InkbLoader).
96///
97/// ## `brink.toml` discovery (#1029, #1360, #1406, #1439)
98///
99/// A `brink.toml` beside (or above) the entry asset supplies the
100/// [`ProjectConfig`] (`dialect`/`types`) that gates T1b brink-extension
101/// syntax — the same file the CLI discovers by walking the real
102/// filesystem (`brink-project-config::load_from_entry`). Bevy's
103/// `AssetReader` may be virtual or packed, so [`load`](AssetLoader::load)
104/// probes for it over the async reader itself: `brink.toml` beside the
105/// entry, then each ancestor directory in turn (nearest first), via
106/// [`LoadContext::read_asset_bytes`] — bounded at the asset source root
107/// (never above it) and naturally finite (the entry path has finitely many
108/// `/`-separated ancestors). As of #1439, [`probe_brink_toml`] stops at the
109/// first candidate it finds — the nearest one, since the walk is
110/// nearest-first — so only that candidate is ever read or registered as a
111/// load dependency; a farther, shadowed `brink.toml` is never fetched, so
112/// editing one in dev mode no longer triggers a hot-reload. The candidate's
113/// text is landed at its own key in the drained source map; parsing it and
114/// applying `CLI/API > file > default` precedence is [`Project::load`]'s job
115/// (`brink_project_config::discover_from_entry_in_tree` over the same map,
116/// walking the same bounded ancestor chain again over whatever the map
117/// contains) — this loader only supplies candidate bytes, it does not
118/// re-implement the resolution policy. Because the probe already stops at
119/// the nearest hit, `Project::load`'s walk finds at most one candidate in
120/// practice, but it is still the one place that applies the precedence
121/// rule; the probe is, deliberately, back to being a second "which
122/// candidate is nearest" decider (the duplication #1406 removed and #1439
123/// reintroduced on purpose, for the perf win — see [`probe_brink_toml`]'s
124/// doc). The two decisions agree by construction today because both walk
125/// [`ancestor_dirs`]-equivalent nearest-first order over the same bounded
126/// chain, but they are two independent implementations: a future change to
127/// `brink-project-config`'s bound/precedence shape would need to be mirrored
128/// here to stay honored on this path. A miss at every level leaves
129/// `AnalysisOptions` at its default — byte-identical to pre-#1029 behavior.
130///
131/// [`override_config`](Self::override_config), set via
132/// [`BrinkPlugin::with_config`](crate::BrinkPlugin::with_config) /
133/// [`BrinkAssetsPlugin::with_config`](crate::BrinkAssetsPlugin::with_config),
134/// is the programmatic escape hatch: when set, its fields win over
135/// whatever `brink.toml` supplies — passed through as
136/// [`OptionOverrides`], the same `explicit call always wins over the file`
137/// precedence [`Project::load`] applies for every mount (CLI included).
138#[derive(Default, TypePath)]
139pub struct InkLoader {
140 /// Out-of-band [`ProjectConfig`] override (#1029). `None` (the
141 /// default) means "the discovered `brink.toml` asset (if any) wins";
142 /// `Some` fields win over the asset's, unset fields still fall
143 /// through to whatever the asset (or the built-in default) supplies.
144 pub override_config: Option<ProjectConfig>,
145}
146
147/// Errors that can occur loading an `.ink` source file.
148#[derive(Debug, thiserror::Error)]
149pub enum InkLoaderError {
150 #[error("I/O error: {0}")]
151 Io(#[from] std::io::Error),
152 #[error("read asset: {0}")]
153 ReadAsset(#[from] bevy_asset::ReadAssetBytesError),
154 #[error("source not valid UTF-8: {0}")]
155 InvalidUtf8(#[from] std::string::FromUtf8Error),
156 #[error("entry path missing or non-UTF-8")]
157 BadEntryPath,
158 /// The `brink-environment` producer failed to resolve the drained
159 /// sources into an `Environment` — `INCLUDE` discovery (missing/circular
160 /// include) or a malformed discovered `brink.toml` (bad TOML syntax, or
161 /// a recognized key with a value outside its enum; unknown keys are
162 /// warnings, never this). See `brink_environment::LoadError`.
163 #[error("load environment: {0}")]
164 Load(#[from] brink_environment::LoadError),
165 #[error("compile: {0}")]
166 Compile(#[from] brink_compiler::CompileError),
167 #[error("link error: {0}")]
168 Link(#[from] brink_runtime::RuntimeError),
169}
170
171/// Errors from [`compile_story_inline`].
172#[derive(Debug, thiserror::Error)]
173pub enum CompileStoryInlineError {
174 /// The `brink-environment` producer failed to resolve the single-file
175 /// tree into an `Environment` — most commonly an unresolvable `INCLUDE`
176 /// (the tree has only `name`, so any `INCLUDE`'d path always misses),
177 /// but also reachable via a malformed discovered `brink.toml` or a
178 /// circular `INCLUDE`. See `brink_environment::LoadError`.
179 ///
180 /// The `#[error]` message carries the same authoring guidance the old
181 /// (pre-#1372) read closure used to surface directly, since this is
182 /// often the only diagnostic a caller sees — all three demo call sites
183 /// (`demos/compound/src/ink_{doors,cameras,alarm}.rs`) `.expect()` on
184 /// this function's result.
185 #[error(
186 "load environment: {0} (compile_story_inline compiles a single in-memory source; use InkLoader/AssetServer for multi-file stories)"
187 )]
188 Load(#[from] brink_environment::LoadError),
189 #[error("compile: {0}")]
190 Compile(#[from] brink_compiler::CompileError),
191 #[error("link error: {0}")]
192 Link(#[from] brink_runtime::RuntimeError),
193}
194
195/// Compile an in-memory ink source string straight into story assets,
196/// inserting them into `app`'s asset collections and returning the
197/// resulting `Handle<BrinkStoryAsset>` (G3, issue #1060).
198///
199/// Collapses the four-step dance tests/tools otherwise hand-roll —
200/// `brink_compiler::compile` → `brink_runtime::link` →
201/// `FlowInstance::new_at_root` (only to obtain the initial context) →
202/// hand-inserting `ProgramAsset` + `LineTablesAsset` + `BrinkStoryAsset`
203/// into three `Assets<T>` resources — into one call, wrapping the same
204/// [`emit_story_assets`]-adjacent logic [`InkLoader`] uses at asset-load
205/// time, but synchronously and without the async `AssetServer`.
206///
207/// ## Goes through the same producer as [`InkLoader`] (#1372)
208///
209/// `name`/`source` are landed as the sole entry in a
210/// [`brink_source_tree::InMemory`] tree, then handed to
211/// [`brink_environment::Project::load`] → [`brink_environment::compile`] —
212/// the exact same two-call seam [`InkLoader::load`] uses. This closes the
213/// `brink.toml`-discovery half of the divergence #1360 left open: before,
214/// this function called `brink_compiler::compile` directly, a second compile
215/// path with its own (in this case: no) `brink.toml`/precedence resolution,
216/// separate from `InkLoader`'s. Both entry points now run the identical
217/// `resolve_options` codepath over their respective trees, so a future
218/// change to *that* resolution logic can't silently diverge between them
219/// again.
220///
221/// ## Picks up `with_config` too (#1380)
222///
223/// [`BrinkAssetsPlugin::build`](crate::BrinkAssetsPlugin) mirrors whatever
224/// `with_config` override it resolved for [`InkLoader::override_config`]
225/// into a [`BrinkOverrideConfig`] resource. This function reads that
226/// resource back out of `app`'s `World` (absent — no `BrinkAssetsPlugin` in
227/// the app at all — is treated the same as `None`) and runs it through the
228/// same [`overrides_from_config`] mapping `InkLoader::load` uses, so an app
229/// built with `BrinkPlugin::with_config(dialect = Brink)` now compiles
230/// inline sources under `Brink` too, matching its `InkLoader`-loaded
231/// assets — closing the divergence #1372 narrowed down to "one missing
232/// override wire".
233///
234/// **Call this *after* `app.add_plugins(BrinkPlugin::<M>::with_config(...))`
235/// / `BrinkAssetsPlugin::with_config(...)`, not before.** `BrinkOverrideConfig`
236/// only exists in `app`'s `World` once `BrinkAssetsPlugin::build` has run,
237/// and Bevy runs `Plugin::build` at `add_plugins` time, not at struct
238/// construction. Calling `compile_story_inline` *before* that `add_plugins`
239/// call finds no `BrinkOverrideConfig` resource yet — the same "absent"
240/// case as no plugin at all — and silently compiles under
241/// `OptionOverrides::default()` with no diagnostic, even though a
242/// `with_config` override is sitting right there waiting to be installed.
243/// This is the exact kind of silent divergence #1380 set out to kill,
244/// relocated into a call-ordering footgun rather than eliminated; there is
245/// no runtime check for it (a freestanding `fn(&mut App, ...)` can't tell
246/// "no override was ever configured" apart from "the override exists but
247/// its plugin hasn't built yet"). See
248/// `compile_story_inline_before_plugin_add_silently_falls_back_to_default`
249/// for the regression pinning this fallback.
250///
251/// `name` is the compiler's synthetic entry file name (also its `INCLUDE`
252/// resolution root). Because the tree has exactly one key (`name`), `INCLUDE`
253/// can never resolve here: a directive is still discovered by the same BFS
254/// `InkLoader` uses, but always misses the single-key tree and surfaces as
255/// [`CompileStoryInlineError::Load`] rather than silently doing nothing; a
256/// story spanning multiple files needs [`InkLoader`]/`AssetServer::load`
257/// instead, which walks the graph asynchronously. A `brink.toml` is
258/// discovered the same way — never, for the same single-key reason — so
259/// `AnalysisOptions` always resolves to its default here.
260///
261/// `app` must already have `AssetPlugin` (or equivalent) installed, so
262/// `Assets<ProgramAsset>`, `Assets<LineTablesAsset>`, and
263/// `Assets<BrinkStoryAsset>` exist as resources — the same precondition
264/// `BrinkPlugin` itself has.
265pub fn compile_story_inline(
266 app: &mut App,
267 name: &str,
268 source: &str,
269) -> Result<Handle<BrinkStoryAsset>, CompileStoryInlineError> {
270 let mut sources = BTreeMap::new();
271 sources.insert(name.to_string(), source.to_string());
272 let tree = InMemory::new(sources);
273 let override_config = app
274 .world()
275 .get_resource::<BrinkOverrideConfig>()
276 .and_then(|r| r.0.as_ref());
277 let overrides = overrides_from_config(override_config);
278 let env = Project::load(&tree, name, &overrides)?;
279 let output = brink_environment::compile(&env)?;
280 let (program, tables) = brink_runtime::link(&output.data)?;
281 let initial_context = fresh_context(&program);
282
283 let world = app.world_mut();
284 let program_handle = world
285 .resource_mut::<Assets<ProgramAsset>>()
286 .add(ProgramAsset {
287 program,
288 initial_context,
289 effect_rows: output.data.effect_rows,
290 });
291 let line_tables_handle = world
292 .resource_mut::<Assets<LineTablesAsset>>()
293 .add(LineTablesAsset { tables });
294 Ok(world
295 .resource_mut::<Assets<BrinkStoryAsset>>()
296 .add(BrinkStoryAsset {
297 program: program_handle,
298 line_tables: line_tables_handle,
299 }))
300}
301
302/// Resolve an `INCLUDE` path relative to the including file's directory.
303///
304/// String-based (uses `/`) to match `brink-db`'s WASM-safe resolver and
305/// avoid platform separator issues. The joined path is normalized so `.`/`..`
306/// segments collapse to a clean key (matching `brink-db::resolve_include_path`
307/// system-wide; see docs/decision-log.md).
308fn resolve_include_path(from_file: &str, include_path: &str) -> String {
309 let joined = match from_file.rfind('/') {
310 Some(i) => format!("{}/{include_path}", &from_file[..i]),
311 None => include_path.to_string(),
312 };
313 let absolute = joined.starts_with('/');
314 let mut out: Vec<&str> = Vec::new();
315 for seg in joined.split('/') {
316 match seg {
317 "" | "." => {}
318 ".." if matches!(out.last(), Some(&s) if s != "..") => {
319 out.pop();
320 }
321 s => out.push(s),
322 }
323 }
324 let joined = out.join("/");
325 if absolute {
326 format!("/{joined}")
327 } else {
328 joined
329 }
330}
331
332/// Ancestor directories of `entry_path`'s containing directory, nearest
333/// first, ending at `""` (the asset source root). String-based (matches
334/// [`resolve_include_path`]'s `/`-only convention). E.g.
335/// `"stories/ch1/intro.ink"` yields `["stories/ch1", "stories", ""]`;
336/// `"intro.ink"` (no directory) yields `[""]`.
337///
338/// Finite by construction — each step strictly shortens the remaining
339/// prefix at a `/` boundary, so the walk-up this drives ([`probe_brink_toml`])
340/// is naturally bounded by the entry path's depth (guard against
341/// unbounded growth).
342fn ancestor_dirs(entry_path: &str) -> Vec<String> {
343 let mut dirs = Vec::new();
344 let mut current = match entry_path.rfind('/') {
345 Some(i) => &entry_path[..i],
346 None => "",
347 };
348 loop {
349 dirs.push(current.to_string());
350 if current.is_empty() {
351 break;
352 }
353 current = match current.rfind('/') {
354 Some(i) => ¤t[..i],
355 None => "",
356 };
357 }
358 dirs
359}
360
361/// Bounded ancestor walk-up (#1029): probe for `brink.toml` beside the
362/// entry asset, then each ancestor directory in turn (nearest first), up
363/// to the asset source root — mirroring the CLI's `brink.toml` walk-up
364/// (`brink_project_config::find_config`), but over the async `AssetReader`
365/// since a bevy source tree may be virtual or packed rather than a real
366/// filesystem.
367///
368/// Returns at most one candidate — the nearest `brink.toml` found, or
369/// `None` if no `brink.toml` exists at any ancestor level. The returned
370/// candidate is read via [`LoadContext::read_asset_bytes`], which
371/// registers it as a load dependency, so hot-reload "just works" exactly
372/// like an `INCLUDE`. Returns key + raw text, **not** parsed, and no
373/// precedence applied — this loader's job stops at supplying bytes; the
374/// caller lands it in the drained source map, and [`Project::load`]
375/// (over the resulting `SourceTree`) then parses it and applies the
376/// `CLI/API > file > default` precedence (#1360).
377///
378/// ## Single pass (#1439)
379///
380/// This probe stops at the first match found, collapsing the *async* side
381/// of the ancestor walk to a single pass. Before #1439, this probe fetched
382/// bytes for **every** candidate ancestor through the async `AssetReader`
383/// and landed all of them in the tree; `Project::load`'s own walk
384/// (`discover_from_entry_in_tree`) then re-walked the same bounded ancestor
385/// chain, synchronously, over that tree to pick the nearest one. Stopping
386/// here at the first match (guaranteed to be nearest, since
387/// [`ancestor_dirs`] returns nearest-first) means at most one candidate is
388/// ever landed, so `Project::load`'s subsequent sync walk finds it (or
389/// nothing) on its very first probe. That sync walk was already
390/// stopping at its first hit before this change (`find_config_in_tree`
391/// returns as soon as it finds a candidate), so its own probe count —
392/// the entry-to-nearest-config distance — is unchanged and still
393/// `O(depth)` when no config exists anywhere; what #1439 saves is strictly
394/// the number of async `read_asset_bytes` calls this probe makes (at most
395/// one hit instead of one per ancestor with a `brink.toml`), not the shape
396/// of either walk. Behavior is identical — only the nearest candidate was
397/// ever read as config before this change either.
398///
399/// A miss at every ancestor returns `Ok(None)` — not an error, matching
400/// `brink-project-config`'s "missing config changes nothing" contract.
401async fn probe_brink_toml(
402 load_context: &mut LoadContext<'_>,
403 entry_path: &str,
404) -> Result<Option<(String, String)>, InkLoaderError> {
405 for dir in ancestor_dirs(entry_path) {
406 let candidate = if dir.is_empty() {
407 brink_project_config::CONFIG_FILE_NAME.to_string()
408 } else {
409 format!("{dir}/{}", brink_project_config::CONFIG_FILE_NAME)
410 };
411 if let Ok(bytes) = load_context.read_asset_bytes(candidate.clone()).await {
412 match String::from_utf8(bytes) {
413 Ok(text) => {
414 // Found the nearest candidate; return immediately (single
415 // pass, no need to continue walking).
416 return Ok(Some((candidate, text)));
417 }
418 // An undecodable nearest candidate fails the load — this
419 // candidate governs, and we can't read it, so this is a
420 // genuine failure (not a case for `discover_from_entry_in_tree`
421 // to potentially shadow with a farther ancestor). Farther
422 // ancestors are never consulted.
423 Err(err) => return Err(err.into()),
424 }
425 }
426 }
427 // No `brink.toml` found at any ancestor level.
428 Ok(None)
429}
430
431impl AssetLoader for InkLoader {
432 type Asset = BrinkStoryAsset;
433 type Settings = ();
434 type Error = InkLoaderError;
435
436 async fn load(
437 &self,
438 reader: &mut dyn Reader,
439 _settings: &Self::Settings,
440 load_context: &mut LoadContext<'_>,
441 ) -> Result<Self::Asset, Self::Error> {
442 // Read the entry source. AssetPath includes optional source +
443 // label; for our purposes the underlying filesystem path is what
444 // INCLUDE resolution needs.
445 let entry_path = load_context
446 .path()
447 .path()
448 .to_str()
449 .ok_or(InkLoaderError::BadEntryPath)?
450 .to_string();
451
452 let mut entry_bytes = Vec::new();
453 reader.read_to_end(&mut entry_bytes).await?;
454 let entry_source = String::from_utf8(entry_bytes)?;
455
456 // BFS the INCLUDE graph, fetching every transitive dep through
457 // Bevy's async reader (which registers each as a dependency for
458 // automatic hot-reload). `BTreeMap` (not `HashMap`): it lands
459 // directly in an `InMemory` `SourceTree` below, whose contract is a
460 // deterministic key order.
461 let mut sources: BTreeMap<String, String> = BTreeMap::new();
462 let mut queue: Vec<String> = brink_syntax::extract_includes(&entry_source)
463 .into_iter()
464 .map(|inc| resolve_include_path(&entry_path, &inc))
465 .collect();
466 sources.insert(entry_path.clone(), entry_source);
467
468 while let Some(path) = queue.pop() {
469 if sources.contains_key(&path) {
470 continue;
471 }
472 let bytes = load_context.read_asset_bytes(path.clone()).await?;
473 let source = String::from_utf8(bytes)?;
474 for inc in brink_syntax::extract_includes(&source) {
475 let resolved = resolve_include_path(&path, &inc);
476 if !sources.contains_key(&resolved) {
477 queue.push(resolved);
478 }
479 }
480 sources.insert(path, source);
481 }
482
483 // #1029/#1360/#1439: bounded ancestor walk-up for `brink.toml`
484 // through the async AssetReader (see [`probe_brink_toml`]'s doc).
485 // Single-pass discovery (#1439): the probe stops at the first match
486 // (guaranteed nearest), so only one candidate (or none) is added to
487 // the tree — saving async `read_asset_bytes` calls for shadowed
488 // farther candidates. [`Project::load`] below still performs its own
489 // bounded sync walk over the tree to apply precedence; that walk's
490 // shape (and its O(depth) cost when nothing is found) is unchanged
491 // by this optimization.
492 if let Some((config_path, text)) = probe_brink_toml(load_context, &entry_path).await? {
493 sources.insert(config_path, text);
494 }
495
496 // The `CLI/API > file > default` precedence's explicit-override
497 // side (#1005): `override_config` — set via
498 // `BrinkPlugin::with_config` — wins over whatever `brink.toml` (just
499 // landed above) supplies. `brink.toml` itself, and applying that
500 // precedence, is entirely `Project::load`'s job. #1394's lint tier
501 // and #1380's `compile_story_inline` wiring both go through this
502 // same `overrides_from_config` mapping — no second implementation.
503 let overrides = overrides_from_config(self.override_config.as_ref());
504
505 // Land the drained map in a `SourceTree` and go through the
506 // producer: `Project::load` resolves `brink.toml` + precedence and
507 // freezes an `Environment`; `compile` is the pure function over it.
508 // Both are synchronous — the only asynchrony (the BFS above) is
509 // already behind us.
510 let tree = InMemory::new(sources);
511 let env = Project::load(&tree, &entry_path, &overrides)?;
512 let output = brink_environment::compile(&env)?;
513 let (program, tables) = brink_runtime::link(&output.data)?;
514 Ok(emit_story_assets(
515 load_context,
516 program,
517 tables,
518 output.data.effect_rows,
519 ))
520 }
521
522 fn extensions(&self) -> &[&str] {
523 &["ink"]
524 }
525}
526
527#[cfg(test)]
528mod tests {
529 use super::*;
530
531 #[test]
532 fn resolves_with_directory_prefix() {
533 assert_eq!(
534 resolve_include_path("src/main.ink", "utils.ink"),
535 "src/utils.ink"
536 );
537 }
538
539 #[test]
540 fn resolves_without_directory() {
541 assert_eq!(resolve_include_path("story.ink", "other.ink"), "other.ink");
542 }
543
544 #[test]
545 fn resolves_nested_directory() {
546 assert_eq!(resolve_include_path("a/b/c.ink", "d.ink"), "a/b/d.ink");
547 }
548
549 #[test]
550 fn normalizes_parent_traversal() {
551 assert_eq!(resolve_include_path("a/b/c.ink", "../d.ink"), "a/d.ink");
552 assert_eq!(resolve_include_path("a/b/c.ink", "../../d.ink"), "d.ink");
553 }
554
555 #[test]
556 fn compile_story_inline_inserts_assets_and_returns_handle() {
557 let mut app = crate::test_support::make_test_app();
558
559 let handle = compile_story_inline(&mut app, "inline.ink", "VAR mood = 3\n-> END\n")
560 .expect("inline source compiles and links");
561
562 let world = app.world();
563 let story = world
564 .resource::<Assets<BrinkStoryAsset>>()
565 .get(&handle)
566 .expect("story asset inserted");
567 let program_asset = world
568 .resource::<Assets<ProgramAsset>>()
569 .get(&story.program)
570 .expect("program asset inserted");
571 assert_eq!(program_asset.program.global_index("mood"), Some(0));
572 assert!(
573 world
574 .resource::<Assets<LineTablesAsset>>()
575 .get(&story.line_tables)
576 .is_some(),
577 "line tables asset inserted"
578 );
579 }
580
581 #[test]
582 fn compile_story_inline_surfaces_compile_error() {
583 let mut app = crate::test_support::make_test_app();
584
585 let err = compile_story_inline(&mut app, "broken.ink", "-> nowhere_knot\n")
586 .expect_err("a divert to an undeclared knot should not compile");
587 assert!(
588 matches!(err, CompileStoryInlineError::Compile(_)),
589 "got {err:?}"
590 );
591 }
592
593 /// #1372: `compile_story_inline` now goes through the same
594 /// `Project::load` → `compile` producer seam as [`InkLoader`], instead of
595 /// calling `brink_compiler::compile` directly. Pin the precedence
596 /// consequence both entry points now share: with no `brink.toml`
597 /// reachable (here, structurally — the tree has only the single inline
598 /// source), `AnalysisOptions` resolves to its default (`StrictInk`),
599 /// which rejects the brink-extension `#@private` form — the exact same
600 /// fixture and expectation as
601 /// `config_discovery_tests::missing_brink_toml_leaves_default_dialect_unchanged`
602 /// for `InkLoader`.
603 #[test]
604 fn compile_story_inline_has_no_brink_toml_and_uses_default_dialect() {
605 let mut app = crate::test_support::make_test_app();
606
607 let err = compile_story_inline(
608 &mut app,
609 "inline.ink",
610 "#@private\nVAR secret = 0\n-> END\n",
611 )
612 .expect_err("brink-extension syntax should be rejected under the default dialect");
613 assert!(
614 matches!(err, CompileStoryInlineError::Compile(_)),
615 "got {err:?}"
616 );
617 }
618
619 /// #1372: an `INCLUDE` in the inline source is followed through the same
620 /// `Project::load` discovery BFS `InkLoader` uses, but the single-key
621 /// tree can never satisfy it — surfaces as `CompileStoryInlineError::Load`
622 /// (a `brink_environment::LoadError`, not `brink_compiler::CompileError`
623 /// as it did before this function was rerouted through the producer).
624 ///
625 /// Also pins the authoring-guidance substring carried in `Load`'s
626 /// `#[error]` message (the old read closure's hint, since #1372 turned
627 /// this into the sole diagnostic every `.expect()`-ing demo call site
628 /// sees) so it cannot silently rot away in a future edit.
629 #[test]
630 fn compile_story_inline_surfaces_load_error_for_unresolvable_include() {
631 let mut app = crate::test_support::make_test_app();
632
633 let err = compile_story_inline(&mut app, "inline.ink", "INCLUDE missing.ink\n-> END\n")
634 .expect_err("an INCLUDE can never resolve in a single-key inline tree");
635 assert!(
636 matches!(err, CompileStoryInlineError::Load(_)),
637 "got {err:?}"
638 );
639 assert!(
640 err.to_string().contains(
641 "compile_story_inline compiles a single in-memory source; use InkLoader/AssetServer for multi-file stories"
642 ),
643 "error message should carry authoring guidance, got: {err}"
644 );
645 }
646
647 // ── `with_config` reaches `compile_story_inline` too (#1380) ───────
648 //
649 // `compile_story_inline_has_no_brink_toml_and_uses_default_dialect`
650 // above pins the no-override baseline: `#@private` is rejected under
651 // the default `StrictInk` dialect. These two tests prove a
652 // `BrinkPlugin::with_config` override actually reaches this entry
653 // point too (it didn't, pre-#1380 — see the removed "one override
654 // channel is still divergent" doc note this PR replaces), each picking
655 // a fixture whose outcome only the override — not the default policy
656 // — can flip (house rule 19q).
657
658 /// `dialect = Brink`, set via `BrinkPlugin::with_config`, must reach
659 /// `compile_story_inline`'s `Project::load` call the same way it
660 /// already reaches `InkLoader::load` — proven by compiling the same
661 /// `#@private` brink-extension source the no-override baseline test
662 /// rejects.
663 #[test]
664 fn compile_story_inline_reaches_plugin_with_config_dialect_override() {
665 use brink_project_config::Dialect;
666
667 let mut app = bevy_app::App::new();
668 app.add_plugins(bevy_asset::AssetPlugin::default());
669 app.add_plugins(
670 crate::BrinkPlugin::<()>::default().with_config(ProjectConfig {
671 dialect: Some(Dialect::Brink),
672 ..ProjectConfig::default()
673 }),
674 );
675
676 compile_story_inline(
677 &mut app,
678 "inline.ink",
679 "#@private\nVAR secret = 0\n-> END\n",
680 )
681 .expect(
682 "dialect = Brink override should reach compile_story_inline and permit \
683 brink-extension syntax the default dialect rejects",
684 );
685 }
686
687 /// The `[lints] deny-warnings` tier (#1394's addition to the same
688 /// `OptionOverrides` seam) reaches `compile_story_inline` too, not just
689 /// `dialect`/`types` — a logic line with no effect (`~` alone,
690 /// `DiagnosticCode::E014`, `Warning` by default) compiles cleanly with
691 /// no override, but a `deny_warnings = true` override relevels it to a
692 /// blocking `Error`.
693 #[test]
694 fn compile_story_inline_reaches_plugin_with_config_deny_warnings_override() {
695 let mut app = bevy_app::App::new();
696 app.add_plugins(bevy_asset::AssetPlugin::default());
697 app.add_plugins(
698 crate::BrinkPlugin::<()>::default().with_config(ProjectConfig {
699 deny_warnings: Some(true),
700 ..ProjectConfig::default()
701 }),
702 );
703
704 let err = compile_story_inline(&mut app, "inline.ink", "Hello.\n~\n-> END\n")
705 .expect_err("deny_warnings override should relevel E014 to a blocking error");
706 assert!(
707 matches!(err, CompileStoryInlineError::Compile(_)),
708 "got {err:?}"
709 );
710 }
711
712 /// Negative control for the two tests above (house rule 19q): the same
713 /// `deny_warnings`-sensitive source compiles cleanly with no
714 /// `with_config` override at all, so the override above is what
715 /// actually flips the outcome, not something else about the fixture.
716 #[test]
717 fn compile_story_inline_without_config_leaves_e014_a_warning() {
718 let mut app = crate::test_support::make_test_app();
719
720 compile_story_inline(&mut app, "inline.ink", "Hello.\n~\n-> END\n")
721 .expect("E014 is a Warning, never blocking, with no deny_warnings override");
722 }
723
724 /// An untyped function parameter, called with an argument — same fixture
725 /// (and same reasoning) as `config_discovery_tests`'
726 /// `UNTYPED_PARAM_SOURCE`: `dialect = Brink`'s own resolved-policy
727 /// default is `Strict`, which rejects it with 2 diagnostics; only an
728 /// explicit `types = Gradual` override compiles it.
729 const UNTYPED_PARAM_SOURCE: &str =
730 "=== function f(x) ===\n~ return x\n\n=== start ===\n{f(1)}\n-> END\n";
731
732 /// `types = Gradual`, set via `BrinkPlugin::with_config` alongside
733 /// `dialect = Brink`, must reach `compile_story_inline`'s `Project::load`
734 /// call the same way it already reaches `InkLoader::load`
735 /// (`plugin_with_config_types_reaches_ink_loader` in
736 /// `config_discovery_tests`) — mirrored here since #1380's fix covers
737 /// `compile_story_inline` too, not just `InkLoader` (house rule 19q: the
738 /// dialect-keyed default alone, `Strict`, still rejects
739 /// `UNTYPED_PARAM_SOURCE`, so only the `types` override can flip this
740 /// outcome).
741 #[test]
742 fn compile_story_inline_reaches_plugin_with_config_types_override() {
743 use brink_project_config::{Dialect, TypePolicy};
744
745 let mut app = bevy_app::App::new();
746 app.add_plugins(bevy_asset::AssetPlugin::default());
747 app.add_plugins(
748 crate::BrinkPlugin::<()>::default().with_config(ProjectConfig {
749 dialect: Some(Dialect::Brink),
750 types: Some(TypePolicy::Gradual),
751 ..ProjectConfig::default()
752 }),
753 );
754
755 compile_story_inline(&mut app, "inline.ink", UNTYPED_PARAM_SOURCE).expect(
756 "types = Gradual override should reach compile_story_inline and permit the \
757 untyped parameter the dialect-keyed Strict default rejects",
758 );
759 }
760
761 /// A per-code `[lints]` override (not just the blanket `deny_warnings`
762 /// knob already covered above) must reach `compile_story_inline` too —
763 /// mirrors `plugin_override_lints_wins_over_conflicting_asset` /
764 /// `plugin_with_config_dialect_reaches_ink_loader`'s `[lints]` coverage
765 /// for `InkLoader`, but through `compile_story_inline`'s
766 /// `BrinkOverrideConfig` channel.
767 #[test]
768 fn compile_story_inline_reaches_plugin_with_config_per_code_lint_override() {
769 let mut app = bevy_app::App::new();
770 app.add_plugins(bevy_asset::AssetPlugin::default());
771
772 let mut lints = std::collections::BTreeMap::new();
773 lints.insert("E014".to_owned(), brink_project_config::LintLevel::Deny);
774 app.add_plugins(
775 crate::BrinkPlugin::<()>::default().with_config(ProjectConfig {
776 lints,
777 ..ProjectConfig::default()
778 }),
779 );
780
781 let err = compile_story_inline(&mut app, "inline.ink", "Hello.\n~\n-> END\n").expect_err(
782 "a per-code `[lints] E014 = deny` override should relevel E014 \
783 to a blocking error",
784 );
785 assert!(
786 matches!(err, CompileStoryInlineError::Compile(_)),
787 "got {err:?}"
788 );
789 }
790
791 /// #1380 review finding: `compile_story_inline` reads
792 /// `BrinkOverrideConfig` out of `app.world()` at call time, but that
793 /// resource is only inserted once `BrinkAssetsPlugin::build` actually
794 /// runs — which happens at `app.add_plugins(...)` time, not at
795 /// `BrinkPlugin::with_config(...)` construction time. Calling
796 /// `compile_story_inline` *before* `add_plugins` finds no
797 /// `BrinkOverrideConfig` in the world at all — indistinguishable from
798 /// "no `BrinkAssetsPlugin` ever added" — and silently falls back to
799 /// `OptionOverrides::default()`, dropping the override with no
800 /// diagnostic. This pins that fallback so it can't drift into something
801 /// else (e.g. a panic) unnoticed; see `compile_story_inline`'s doc
802 /// comment for the full hazard.
803 #[test]
804 fn compile_story_inline_before_plugin_add_silently_falls_back_to_default() {
805 use brink_project_config::Dialect;
806
807 let mut app = bevy_app::App::new();
808 app.add_plugins(bevy_asset::AssetPlugin::default());
809
810 // Deliberately NOT added yet -- `compile_story_inline` runs first.
811 let pending_plugin = crate::BrinkPlugin::<()>::default().with_config(ProjectConfig {
812 dialect: Some(Dialect::Brink),
813 ..ProjectConfig::default()
814 });
815
816 let err = compile_story_inline(
817 &mut app,
818 "inline.ink",
819 "#@private\nVAR secret = 0\n-> END\n",
820 )
821 .expect_err(
822 "with no BrinkOverrideConfig resource yet inserted, the default StrictInk \
823 dialect must still reject brink-extension syntax -- the override cannot \
824 reach a call made before its plugin builds",
825 );
826 assert!(
827 matches!(err, CompileStoryInlineError::Compile(_)),
828 "got {err:?}"
829 );
830
831 // Adding the plugin after the fact doesn't retroactively help the
832 // already-failed call above -- but confirms the override itself is
833 // wired correctly (would reach a *subsequent* call), isolating the
834 // failure above to ordering, not a broken override.
835 app.add_plugins(pending_plugin);
836 compile_story_inline(
837 &mut app,
838 "inline.ink",
839 "#@private\nVAR secret = 0\n-> END\n",
840 )
841 .expect("once the plugin has built, the same override now reaches the same call");
842 }
843
844 // ── ancestor_dirs (#1029 bounded walk-up) ───────────────────────────
845
846 #[test]
847 fn ancestor_dirs_nested_entry_climbs_to_root() {
848 assert_eq!(
849 ancestor_dirs("stories/ch1/intro.ink"),
850 vec![
851 "stories/ch1".to_string(),
852 "stories".to_string(),
853 String::new()
854 ]
855 );
856 }
857
858 #[test]
859 fn ancestor_dirs_root_entry_is_just_root() {
860 assert_eq!(ancestor_dirs("intro.ink"), vec![String::new()]);
861 }
862
863 #[test]
864 fn ancestor_dirs_single_directory_level() {
865 assert_eq!(
866 ancestor_dirs("stories/intro.ink"),
867 vec!["stories".to_string(), String::new()]
868 );
869 }
870}
871
872/// Integration tests for #1029 (`brink.toml` discovery through the async
873/// `AssetReader`): the bounded ancestor walk-up, the plugin-override
874/// precedence, and hot-reload — all driven through a real `AssetServer`
875/// against an in-memory `AssetSource`
876/// ([`bevy_asset::io::memory::Dir`]/`MemoryAssetReader`), mirroring
877/// `bevy_asset`'s own `create_app` test pattern. In-memory (not a real
878/// temp directory) so these tests are hermetic and can freely rewrite file
879/// content for the hot-reload case without touching disk or a file
880/// watcher.
881#[cfg(test)]
882mod config_discovery_tests {
883 use std::path::Path;
884
885 use bevy_app::TaskPoolPlugin;
886 use bevy_asset::io::memory::{Dir, MemoryAssetReader};
887 use bevy_asset::io::{AssetSourceBuilder, AssetSourceId};
888 use bevy_asset::{AssetApp, AssetPlugin, AssetServer, LoadState};
889 use brink_project_config::{Dialect, TypePolicy};
890
891 use super::{InkLoader, ProjectConfig};
892 use crate::asset::{BrinkStoryAsset, LineTablesAsset, ProgramAsset};
893
894 /// A brink-extension form (`#@private`) that the default `StrictInk`
895 /// dialect rejects (E051-class dialect-gate diagnostic,
896 /// `brink-analyzer`'s `dialect_gate`) but `dialect = brink` compiles —
897 /// the reachability proof #1029 calls for: a bevy story with
898 /// `dialect = brink` in a sibling `brink.toml` compiles a
899 /// brink-extension form that fails under the default.
900 const BRINK_ONLY_SOURCE: &str = "#@private\nVAR secret = 0\n-> END\n";
901
902 /// A logic line with no effect (`~` alone) — `DiagnosticCode::E014`,
903 /// `Warning` by default (mirrors `brink_environment`'s own `E014_SOURCE`
904 /// fixture, `crates/internal/brink-environment/src/lib.rs`). Compiles
905 /// cleanly under the default policy; only relevels to a blocking `Error`
906 /// (and so a `Failed` load) once `[lints]` denies it or sets
907 /// `deny-warnings` (issue #1394).
908 const E014_SOURCE: &str = "Hello.\n~\n-> END\n";
909
910 /// An untyped function parameter (`f(x)`, no `: type` annotation) called
911 /// with an argument: `dialect = Brink`'s own resolved-policy default is
912 /// `Strict` (`AnalysisOptions::type_policy`), which rejects an untyped
913 /// param with 2 diagnostics — only an explicit `types = gradual`
914 /// override compiles it. Verified against this crate's own `brink`
915 /// CLI: `brink compile fnreturn.ink --dialect brink` exits 1 with 2
916 /// diagnostics; `--dialect brink --types gradual` exits 0.
917 ///
918 /// Replaces a prior `STRUCT NPC = ...; VAR npc = NPC#{...}` fixture that
919 /// was meant to hit `E075` (struct literals as declaration defaults) but
920 /// didn't — it compiled cleanly under `types = strict` too (verified the
921 /// same way), so the `types` override test built on it was vacuous
922 /// (house rule 19q; #1426 w52 review).
923 const UNTYPED_PARAM_SOURCE: &str =
924 "=== function f(x) ===\n~ return x\n\n=== start ===\n{f(1)}\n-> END\n";
925
926 /// Build an `App` with an in-memory `AssetSource` and just enough
927 /// registered (asset types + the dev-mode `InkLoader`) to drive a real
928 /// `AssetServer::load` through [`InkLoader::load`] end to end, without
929 /// pulling in all of `BrinkPlugin`'s systems.
930 fn make_memory_asset_app() -> (bevy_app::App, Dir) {
931 let mut app = bevy_app::App::new();
932 let dir = Dir::default();
933 let dir_clone = dir.clone();
934 app.register_asset_source(
935 AssetSourceId::Default,
936 AssetSourceBuilder::new(move || {
937 Box::new(MemoryAssetReader {
938 root: dir_clone.clone(),
939 })
940 }),
941 )
942 .add_plugins((
943 TaskPoolPlugin::default(),
944 AssetPlugin {
945 watch_for_changes_override: Some(false),
946 use_asset_processor_override: Some(false),
947 ..Default::default()
948 },
949 ));
950 app.init_asset::<BrinkStoryAsset>();
951 app.init_asset::<ProgramAsset>();
952 app.init_asset::<LineTablesAsset>();
953 app.register_asset_loader(InkLoader::default());
954 (app, dir)
955 }
956
957 /// Same as [`make_memory_asset_app`], except the `.ink` loader is wired
958 /// through the *real* [`crate::plugin::BrinkAssetsPlugin::with_config`]
959 /// plugin-build path instead of a hand-constructed `InkLoader {
960 /// override_config }`. Every other case in `config_discovery_tests`
961 /// constructs `InkLoader` directly, which only proves
962 /// `InkLoader::override_config`'s own behavior — not that
963 /// `BrinkPlugin::with_config` / `BrinkAssetsPlugin::with_config`
964 /// actually thread an override into it (`with_config` ->
965 /// `with_config_option` -> `BrinkAssetsPlugin::build` ->
966 /// `InkLoader { override_config }`). Use this builder for any test
967 /// whose claim is specifically about `with_config`'s reachability.
968 fn make_memory_asset_app_with_config(config: ProjectConfig) -> (bevy_app::App, Dir) {
969 let mut app = bevy_app::App::new();
970 let dir = Dir::default();
971 let dir_clone = dir.clone();
972 app.register_asset_source(
973 AssetSourceId::Default,
974 AssetSourceBuilder::new(move || {
975 Box::new(MemoryAssetReader {
976 root: dir_clone.clone(),
977 })
978 }),
979 )
980 .add_plugins((
981 TaskPoolPlugin::default(),
982 AssetPlugin {
983 watch_for_changes_override: Some(false),
984 use_asset_processor_override: Some(false),
985 ..Default::default()
986 },
987 crate::plugin::BrinkAssetsPlugin::default().with_config(config),
988 ));
989 (app, dir)
990 }
991
992 /// Same as [`make_memory_asset_app_with_config`], but wired through
993 /// [`crate::BrinkPlugin::with_config`] instead of
994 /// [`crate::plugin::BrinkAssetsPlugin::with_config`] directly — proves
995 /// the two-hop delegation (`BrinkPlugin::build` ->
996 /// `BrinkAssetsPlugin::with_config_option` -> `InkLoader {
997 /// override_config }`) a host adding the marker-parameterized plugin
998 /// actually goes through, not just `BrinkAssetsPlugin` in isolation.
999 fn make_memory_asset_app_via_brink_plugin_with_config(
1000 config: ProjectConfig,
1001 ) -> (bevy_app::App, Dir) {
1002 let mut app = bevy_app::App::new();
1003 let dir = Dir::default();
1004 let dir_clone = dir.clone();
1005 app.register_asset_source(
1006 AssetSourceId::Default,
1007 AssetSourceBuilder::new(move || {
1008 Box::new(MemoryAssetReader {
1009 root: dir_clone.clone(),
1010 })
1011 }),
1012 )
1013 .add_plugins((
1014 TaskPoolPlugin::default(),
1015 AssetPlugin {
1016 watch_for_changes_override: Some(false),
1017 use_asset_processor_override: Some(false),
1018 ..Default::default()
1019 },
1020 crate::BrinkPlugin::<()>::default().with_config(config),
1021 ));
1022 (app, dir)
1023 }
1024
1025 /// Poll `app.update()` until `predicate` returns `Some`, bounded so a
1026 /// stuck load fails the test instead of hanging the suite (guard
1027 /// against unbounded growth).
1028 fn run_until<T>(
1029 app: &mut bevy_app::App,
1030 mut predicate: impl FnMut(&mut bevy_app::App) -> Option<T>,
1031 ) -> Option<T> {
1032 for _ in 0..2000 {
1033 app.update();
1034 let hit = predicate(app);
1035 if hit.is_some() {
1036 return hit;
1037 }
1038 }
1039 None
1040 }
1041
1042 /// Poll until the handle reaches `Loaded`. Also stops early on `Failed`
1043 /// (so a genuine failure reports immediately instead of spinning out
1044 /// the whole bound) but never treats a *stale* `Loaded` as the answer
1045 /// to `wait_for_failed` below — each waiter polls for its own specific
1046 /// target state, which matters for the hot-reload test: right after
1047 /// `AssetServer::reload`, the handle briefly still reads its *previous*
1048 /// terminal state before the reload's spawned task lands.
1049 fn wait_for_loaded(app: &mut bevy_app::App, handle: &bevy_asset::Handle<BrinkStoryAsset>) {
1050 let state = run_until(app, |app| {
1051 match app.world().resource::<AssetServer>().load_state(handle) {
1052 LoadState::NotLoaded | LoadState::Loading => None,
1053 terminal => Some(terminal),
1054 }
1055 })
1056 .expect("asset load did not reach a terminal state within the bounded poll loop");
1057 assert!(
1058 matches!(state, LoadState::Loaded),
1059 "expected the load to succeed; got {state:?}"
1060 );
1061 }
1062
1063 /// Poll until the handle reaches `Failed`, ignoring any `Loaded` seen
1064 /// along the way (see [`wait_for_loaded`]'s doc for why that matters
1065 /// post-reload).
1066 fn wait_for_failed(app: &mut bevy_app::App, handle: &bevy_asset::Handle<BrinkStoryAsset>) {
1067 run_until(app, |app| {
1068 matches!(
1069 app.world().resource::<AssetServer>().load_state(handle),
1070 LoadState::Failed(_)
1071 )
1072 .then_some(())
1073 })
1074 .expect("asset load did not reach Failed within the bounded poll loop");
1075 }
1076
1077 #[test]
1078 fn missing_brink_toml_leaves_default_dialect_unchanged() {
1079 let (mut app, dir) = make_memory_asset_app();
1080 dir.insert_asset_text(Path::new("intro.ink"), BRINK_ONLY_SOURCE);
1081 // No brink.toml anywhere -- current (pre-#1029) behavior: default
1082 // AnalysisOptions (StrictInk), which rejects `#@private`.
1083
1084 let handle = app
1085 .world()
1086 .resource::<AssetServer>()
1087 .load::<BrinkStoryAsset>("intro.ink");
1088 wait_for_failed(&mut app, &handle);
1089 }
1090
1091 #[test]
1092 fn sibling_brink_toml_sets_brink_dialect() {
1093 let (mut app, dir) = make_memory_asset_app();
1094 dir.insert_asset_text(Path::new("intro.ink"), BRINK_ONLY_SOURCE);
1095 dir.insert_asset_text(Path::new("brink.toml"), "[project]\ndialect = \"brink\"\n");
1096
1097 let handle = app
1098 .world()
1099 .resource::<AssetServer>()
1100 .load::<BrinkStoryAsset>("intro.ink");
1101 wait_for_loaded(&mut app, &handle);
1102 }
1103
1104 #[test]
1105 fn ancestor_brink_toml_found_via_bounded_walkup() {
1106 let (mut app, dir) = make_memory_asset_app();
1107 dir.insert_asset_text(Path::new("stories/ch1/intro.ink"), BRINK_ONLY_SOURCE);
1108 // brink.toml sits two levels above the entry -- proves the walk-up
1109 // doesn't stop at the immediate sibling directory.
1110 dir.insert_asset_text(Path::new("brink.toml"), "[project]\ndialect = \"brink\"\n");
1111
1112 let handle = app
1113 .world()
1114 .resource::<AssetServer>()
1115 .load::<BrinkStoryAsset>("stories/ch1/intro.ink");
1116 wait_for_loaded(&mut app, &handle);
1117 }
1118
1119 /// #1406/#1439 regression: with a `brink.toml` at *two* ancestor levels
1120 /// (conflicting settings), the nearest one must still govern.
1121 /// [`super::probe_brink_toml`] stops at the first (nearest) hit as of
1122 /// #1439, so the farther, root-level `brink.toml` is never even fetched
1123 /// — it does not land in the drained tree at all, and `Project::load`'s
1124 /// own walk sees only the nearer candidate. The farther file sets
1125 /// `dialect = "strict-ink"`, which alone would reject `BRINK_ONLY_SOURCE`
1126 /// (`#@private`) — only the nearer `stories/ch1/brink.toml`'s
1127 /// `dialect = "brink"` makes it compile, so a `Loaded` outcome here
1128 /// pins that the nearer candidate is the one actually used, both by the
1129 /// probe (which never reads the farther file) and by `Project::load`'s
1130 /// precedence resolution.
1131 ///
1132 /// `single_pass_discovery_finds_nearest_config_for_nested_entry` below
1133 /// covers a distinct case: here, the nearest config sits *beside* the
1134 /// entry's own directory (`stories/ch1/`); there, it sits one level
1135 /// *above* the entry's own directory, pinning that the walk-up still
1136 /// finds a nearest config that isn't a same-directory sibling.
1137 #[test]
1138 fn nearest_ancestor_brink_toml_shadows_a_farther_conflicting_one() {
1139 let (mut app, dir) = make_memory_asset_app();
1140 dir.insert_asset_text(Path::new("stories/ch1/intro.ink"), BRINK_ONLY_SOURCE);
1141 dir.insert_asset_text(
1142 Path::new("stories/ch1/brink.toml"),
1143 "[project]\ndialect = \"brink\"\n",
1144 );
1145 dir.insert_asset_text(
1146 Path::new("brink.toml"),
1147 "[project]\ndialect = \"strict-ink\"\n",
1148 );
1149
1150 let handle = app
1151 .world()
1152 .resource::<AssetServer>()
1153 .load::<BrinkStoryAsset>("stories/ch1/intro.ink");
1154 wait_for_loaded(&mut app, &handle);
1155 }
1156
1157 /// #1439 regression: verify the discovered config path for a nested
1158 /// project is the nearest one, not a farther sibling. The single-pass
1159 /// optimization (_this_ PR) stops at the first match found, which is
1160 /// guaranteed to be nearest since [`ancestor_dirs`] returns nearest
1161 /// first; this test pins that discovered path stays correct after the
1162 /// optimization.
1163 #[test]
1164 fn single_pass_discovery_finds_nearest_config_for_nested_entry() {
1165 let (mut app, dir) = make_memory_asset_app();
1166 // Entry deep in a nested tree.
1167 dir.insert_asset_text(
1168 Path::new("project/nested/deep/story.ink"),
1169 BRINK_ONLY_SOURCE,
1170 );
1171 // Nearer config (should win).
1172 dir.insert_asset_text(
1173 Path::new("project/nested/brink.toml"),
1174 "[project]\ndialect = \"brink\"\n",
1175 );
1176 // Farther config (should be ignored).
1177 dir.insert_asset_text(
1178 Path::new("project/brink.toml"),
1179 "[project]\ndialect = \"strict-ink\"\n",
1180 );
1181
1182 let handle = app
1183 .world()
1184 .resource::<AssetServer>()
1185 .load::<BrinkStoryAsset>("project/nested/deep/story.ink");
1186 wait_for_loaded(&mut app, &handle);
1187 }
1188
1189 /// w-review regression, retained post-#1439: an *undecodable* farther
1190 /// ancestor `brink.toml` must not fail a load whose nearest, decodable
1191 /// candidate is the one that actually governs. As of #1439,
1192 /// `probe_brink_toml` stops at the first (nearest) candidate it finds,
1193 /// so the farther, non-UTF-8 root-level `brink.toml` here is never even
1194 /// fetched — the load succeeds simply because that file is never read,
1195 /// not because of a decode-error-skipping branch (that guard was
1196 /// removed by #1439; see `probe_brink_toml`'s doc). This test is kept
1197 /// as a regression guard for the outcome — a farther, shadowed,
1198 /// undecodable candidate must never be able to fail a load — even
1199 /// though the mechanism that guarantees it changed.
1200 #[test]
1201 fn undecodable_farther_ancestor_brink_toml_does_not_fail_the_load() {
1202 let (mut app, dir) = make_memory_asset_app();
1203 dir.insert_asset_text(Path::new("stories/ch1/intro.ink"), BRINK_ONLY_SOURCE);
1204 dir.insert_asset_text(
1205 Path::new("stories/ch1/brink.toml"),
1206 "[project]\ndialect = \"brink\"\n",
1207 );
1208 // Invalid UTF-8 (a lone continuation byte) -- must never be decoded
1209 // as config text, since the nearer candidate above already governs.
1210 dir.insert_asset(Path::new("brink.toml"), vec![0x80_u8]);
1211
1212 let handle = app
1213 .world()
1214 .resource::<AssetServer>()
1215 .load::<BrinkStoryAsset>("stories/ch1/intro.ink");
1216 wait_for_loaded(&mut app, &handle);
1217 }
1218
1219 /// w-review regression: the mirror of
1220 /// `undecodable_farther_ancestor_brink_toml_does_not_fail_the_load`
1221 /// above — an *undecodable nearest* `brink.toml` must fail the load,
1222 /// since it is the candidate that governs and `probe_brink_toml`
1223 /// propagates its `String::from_utf8` error rather than silently
1224 /// treating it as "no config" or falling through to a farther ancestor.
1225 #[test]
1226 fn undecodable_nearest_brink_toml_fails_the_load() {
1227 let (mut app, dir) = make_memory_asset_app();
1228 dir.insert_asset_text(Path::new("stories/ch1/intro.ink"), BRINK_ONLY_SOURCE);
1229 // Invalid UTF-8 (a lone continuation byte) at the *nearest* level --
1230 // this candidate governs, so an undecodable read here must fail the
1231 // load rather than being skipped.
1232 dir.insert_asset(Path::new("stories/ch1/brink.toml"), vec![0x80_u8]);
1233
1234 let handle = app
1235 .world()
1236 .resource::<AssetServer>()
1237 .load::<BrinkStoryAsset>("stories/ch1/intro.ink");
1238 wait_for_failed(&mut app, &handle);
1239 }
1240
1241 #[test]
1242 fn plugin_override_wins_over_conflicting_asset() {
1243 let (mut app, dir) = make_memory_asset_app();
1244 dir.insert_asset_text(Path::new("intro.ink"), BRINK_ONLY_SOURCE);
1245 // The asset explicitly sets strict-ink, which alone would reject
1246 // `#@private`.
1247 dir.insert_asset_text(
1248 Path::new("brink.toml"),
1249 "[project]\ndialect = \"strict-ink\"\n",
1250 );
1251
1252 // Re-register the loader with a programmatic override that
1253 // disagrees with the discovered asset -- override must win
1254 // (#1029: override > asset > default).
1255 app.register_asset_loader(InkLoader {
1256 override_config: Some(ProjectConfig {
1257 dialect: Some(Dialect::Brink),
1258 types: None,
1259 ..ProjectConfig::default()
1260 }),
1261 });
1262
1263 let handle = app
1264 .world()
1265 .resource::<AssetServer>()
1266 .load::<BrinkStoryAsset>("intro.ink");
1267 wait_for_loaded(&mut app, &handle);
1268 }
1269
1270 // ── `[lints]` re-level, observable through the bevy load path (#1394) ──
1271 //
1272 // A served `brink.toml`'s `[lints]`/`deny-warnings` table was already
1273 // reaching `Project::load` before this issue — `resolve_options` there
1274 // applies the discovered file's config unconditionally, regardless of
1275 // `OptionOverrides` (see `sibling_brink_toml_lints_deny_relevels_...`
1276 // below, both regression guards for that pre-existing file tier, not
1277 // new coverage). What `InkLoader` actually dropped was narrower: a
1278 // `BrinkPlugin::with_config` override's `lints`/`deny_warnings` never
1279 // reached `OptionOverrides`, so it could never win over (or supply, with
1280 // no `brink.toml` present) the file. `plugin_override_lints_wins_over_conflicting_asset`
1281 // and `plugin_override_deny_warnings_relevels_warning_to_failed_load`
1282 // below are the tests that actually exercise the fixed seam. These
1283 // mirror `brink_environment`'s own `[lints]` tests (`E014_SOURCE`, a
1284 // Warning by default that only blocks compilation once denied), but
1285 // drive them through the real `AssetServer`/`InkLoader` seam this crate
1286 // owns.
1287
1288 #[test]
1289 fn e014_source_loads_by_default_with_no_lints_table() {
1290 // Baseline: E014 is a Warning, never blocking, with no `[lints]`
1291 // anywhere -- the contrast case for the two `Failed` tests below.
1292 let (mut app, dir) = make_memory_asset_app();
1293 dir.insert_asset_text(Path::new("intro.ink"), E014_SOURCE);
1294
1295 let handle = app
1296 .world()
1297 .resource::<AssetServer>()
1298 .load::<BrinkStoryAsset>("intro.ink");
1299 wait_for_loaded(&mut app, &handle);
1300 }
1301
1302 #[test]
1303 fn sibling_brink_toml_lints_deny_relevels_warning_to_failed_load() {
1304 // A served brink.toml's `[lints] E014 = "deny"` re-levels the
1305 // Warning to a blocking Error -- observable as a `Failed` load
1306 // through the bevy path, exactly as it already blocks the CLI.
1307 let (mut app, dir) = make_memory_asset_app();
1308 dir.insert_asset_text(Path::new("intro.ink"), E014_SOURCE);
1309 dir.insert_asset_text(Path::new("brink.toml"), "[lints]\nE014 = \"deny\"\n");
1310
1311 let handle = app
1312 .world()
1313 .resource::<AssetServer>()
1314 .load::<BrinkStoryAsset>("intro.ink");
1315 wait_for_failed(&mut app, &handle);
1316 }
1317
1318 #[test]
1319 fn sibling_brink_toml_deny_warnings_relevels_warning_to_failed_load() {
1320 // Same re-level, via the `deny-warnings = true` blanket knob rather
1321 // than a per-code entry.
1322 let (mut app, dir) = make_memory_asset_app();
1323 dir.insert_asset_text(Path::new("intro.ink"), E014_SOURCE);
1324 dir.insert_asset_text(Path::new("brink.toml"), "[lints]\ndeny-warnings = true\n");
1325
1326 let handle = app
1327 .world()
1328 .resource::<AssetServer>()
1329 .load::<BrinkStoryAsset>("intro.ink");
1330 wait_for_failed(&mut app, &handle);
1331 }
1332
1333 #[test]
1334 fn plugin_override_lints_wins_over_conflicting_asset() {
1335 // The asset explicitly allows E014 (so, alone, the load would
1336 // succeed); the plugin override denies it -- override must win,
1337 // same `override > file > default` precedence as dialect/types.
1338 let (mut app, dir) = make_memory_asset_app();
1339 dir.insert_asset_text(Path::new("intro.ink"), E014_SOURCE);
1340 dir.insert_asset_text(Path::new("brink.toml"), "[lints]\nE014 = \"allow\"\n");
1341
1342 let mut lints = std::collections::BTreeMap::new();
1343 lints.insert("E014".to_owned(), brink_project_config::LintLevel::Deny);
1344 app.register_asset_loader(InkLoader {
1345 override_config: Some(ProjectConfig {
1346 lints,
1347 ..ProjectConfig::default()
1348 }),
1349 });
1350
1351 let handle = app
1352 .world()
1353 .resource::<AssetServer>()
1354 .load::<BrinkStoryAsset>("intro.ink");
1355 wait_for_failed(&mut app, &handle);
1356 }
1357
1358 #[test]
1359 fn plugin_override_deny_warnings_relevels_warning_to_failed_load() {
1360 // No `brink.toml` at all -- alone, E014 stays a non-blocking
1361 // Warning (see `e014_source_loads_by_default_with_no_lints_table`).
1362 // The plugin override's blanket `deny_warnings` knob must still
1363 // relevel it to a blocking Error on its own, the same way a served
1364 // `brink.toml`'s `deny-warnings = true` does
1365 // (`sibling_brink_toml_deny_warnings_relevels_warning_to_failed_load`),
1366 // proving the `.deny_warnings` field (not just `.lints`) actually
1367 // reaches `OptionOverrides` through `InkLoader::load`.
1368 let (mut app, dir) = make_memory_asset_app();
1369 dir.insert_asset_text(Path::new("intro.ink"), E014_SOURCE);
1370
1371 app.register_asset_loader(InkLoader {
1372 override_config: Some(ProjectConfig {
1373 deny_warnings: Some(true),
1374 ..ProjectConfig::default()
1375 }),
1376 });
1377
1378 let handle = app
1379 .world()
1380 .resource::<AssetServer>()
1381 .load::<BrinkStoryAsset>("intro.ink");
1382 wait_for_failed(&mut app, &handle);
1383 }
1384
1385 // ── unknown/non-overridable `[lints]` codes warn, not drop (#1416) ──
1386 //
1387 // `AnalysisOptions::apply_lint_overrides` (`brink-analyzer`) already
1388 // rejects a code that isn't a real `DiagnosticCode`, or names one whose
1389 // *base* severity isn't `Warning`, returning a `ConfigWarning` instead of
1390 // silently merging it — `resolve_options` (`brink-environment`) loops
1391 // those through `tracing::warn!` unconditionally, regardless of whether
1392 // a `brink.toml` was even discovered (see its own doc comment). Since
1393 // `bevy_log`'s macros are `tracing`'s own, re-exported verbatim, and
1394 // `LogPlugin` installs a process-wide `tracing` subscriber, that
1395 // `tracing::warn!` call already reaches a bevy author's console in any
1396 // real app -- the CLI/`brink.toml` mounts rely on the exact same
1397 // ambient-dispatch mechanism, just with `tracing_subscriber::fmt`
1398 // instead of `LogPlugin` as the installed subscriber. What was actually
1399 // untested (house rule 9) is that `BrinkPlugin::with_config`'s
1400 // `override_config.lints` -- forwarded into `OptionOverrides` by #1394 --
1401 // reaches that channel too, and that an invalid entry doesn't take a
1402 // valid sibling entry down with it. `CapturingSubscriber` below installs
1403 // itself as the *global* `tracing` default (not a thread-local one,
1404 // which `InkLoader::load` -- driven off the asset IO task pool thread --
1405 // would never see) so the warnings loop above is observed no matter
1406 // which pool thread runs the compile.
1407 struct CapturingSubscriber {
1408 messages: std::sync::Arc<std::sync::Mutex<Vec<String>>>,
1409 }
1410
1411 impl tracing::field::Visit for CapturedMessage {
1412 fn record_debug(&mut self, field: &tracing::field::Field, value: &dyn std::fmt::Debug) {
1413 if field.name() == "message" {
1414 self.0 = format!("{value:?}");
1415 }
1416 }
1417 }
1418
1419 struct CapturedMessage(String);
1420
1421 impl tracing::Subscriber for CapturingSubscriber {
1422 // Global (process-wide, whole-test-binary-lifetime) subscriber, so
1423 // this must not accept every `trace!`/`debug!`/`info!` from every
1424 // crate (bevy_asset, bevy_ecs, brink-*) in every other test running
1425 // concurrently -- that would tax unrelated tests and let their
1426 // events contaminate this test's substring assertions. Only the
1427 // warnings this test actually cares about need capturing.
1428 fn enabled(&self, metadata: &tracing::Metadata<'_>) -> bool {
1429 *metadata.level() <= tracing::Level::WARN
1430 }
1431
1432 fn max_level_hint(&self) -> Option<tracing::level_filters::LevelFilter> {
1433 Some(tracing::level_filters::LevelFilter::WARN)
1434 }
1435
1436 fn new_span(&self, _span: &tracing::span::Attributes<'_>) -> tracing::span::Id {
1437 tracing::span::Id::from_u64(1)
1438 }
1439
1440 fn record(&self, _span: &tracing::span::Id, _values: &tracing::span::Record<'_>) {}
1441
1442 fn record_follows_from(&self, _span: &tracing::span::Id, _follows: &tracing::span::Id) {}
1443
1444 fn event(&self, event: &tracing::Event<'_>) {
1445 let mut captured = CapturedMessage(String::new());
1446 event.record(&mut captured);
1447 self.messages.lock().unwrap().push(captured.0);
1448 }
1449
1450 fn enter(&self, _span: &tracing::span::Id) {}
1451
1452 fn exit(&self, _span: &tracing::span::Id) {}
1453 }
1454
1455 /// Install (once per test binary) a process-wide capturing subscriber
1456 /// and return the shared buffer every event's formatted message lands
1457 /// in. A single global install is required -- `tracing` only allows
1458 /// setting the global default once -- so every test that calls this
1459 /// shares one growing buffer; that's fine here because each test only
1460 /// asserts its own uniquely-spelled codes appear somewhere in it, never
1461 /// that the buffer is otherwise empty.
1462 fn captured_warnings() -> std::sync::Arc<std::sync::Mutex<Vec<String>>> {
1463 static MESSAGES: std::sync::OnceLock<std::sync::Arc<std::sync::Mutex<Vec<String>>>> =
1464 std::sync::OnceLock::new();
1465 let messages = MESSAGES.get_or_init(|| {
1466 let messages = std::sync::Arc::new(std::sync::Mutex::new(Vec::new()));
1467 let subscriber = CapturingSubscriber {
1468 messages: std::sync::Arc::clone(&messages),
1469 };
1470 // Ignore a "someone already set it" error: nothing else in this
1471 // crate's test binary installs a global subscriber, so in
1472 // practice this always wins on first call.
1473 let _ = tracing::subscriber::set_global_default(subscriber);
1474 messages
1475 });
1476 std::sync::Arc::clone(messages)
1477 }
1478
1479 #[test]
1480 fn plugin_override_unknown_and_non_overridable_lint_codes_warn_but_valid_entry_still_applies() {
1481 let warnings = captured_warnings();
1482
1483 let mut lints = std::collections::BTreeMap::new();
1484 // Not a real `DiagnosticCode` -- never parses.
1485 lints.insert(
1486 "E9999_TYPO".to_owned(),
1487 brink_project_config::LintLevel::Deny,
1488 );
1489 // A real code, but its base severity is `Error`, not `Warning` --
1490 // never overridable (mirrors `brink-analyzer`'s own
1491 // `apply_lint_overrides_rejects_non_overridable_code` unit test).
1492 lints.insert("E001".to_owned(), brink_project_config::LintLevel::Deny);
1493 // The valid entry: must still apply and relevel E014 to a blocking
1494 // Error, proving the two invalid siblings above don't take it down
1495 // with them.
1496 lints.insert("E014".to_owned(), brink_project_config::LintLevel::Deny);
1497
1498 // Routed through `BrinkAssetsPlugin::with_config` (not a
1499 // hand-registered `InkLoader`) so this test actually proves the
1500 // `BrinkPlugin::with_config` wiring path, not just
1501 // `InkLoader::override_config` in isolation.
1502 let (mut app, dir) = make_memory_asset_app_with_config(ProjectConfig {
1503 lints,
1504 ..ProjectConfig::default()
1505 });
1506 dir.insert_asset_text(Path::new("intro.ink"), E014_SOURCE);
1507
1508 let handle = app
1509 .world()
1510 .resource::<AssetServer>()
1511 .load::<BrinkStoryAsset>("intro.ink");
1512 wait_for_failed(&mut app, &handle);
1513
1514 let joined = warnings.lock().unwrap().join("\n");
1515 // Assert the exact message text `validate_lint_code`
1516 // (`brink-analyzer/src/lib.rs`) emits for each rejection class, not
1517 // just the code substring -- a plain `contains("E001")` can't tell
1518 // "not a recognized diagnostic code" apart from "not overridable",
1519 // so it would pass identically even if the two rejection paths were
1520 // swapped.
1521 assert!(
1522 joined.contains("[lints] `E9999_TYPO` is not a recognized diagnostic code"),
1523 "an unknown lint code must warn with the 'not a recognized diagnostic code' \
1524 message (not silently drop); captured: {joined}"
1525 );
1526 assert!(
1527 joined.contains("[lints] `E001` is not overridable"),
1528 "a non-overridable lint code must warn with the 'not overridable' message \
1529 (not silently drop); captured: {joined}"
1530 );
1531 }
1532
1533 // ── plugin-level `with_config` coverage across all four knobs (#1426) ──
1534 //
1535 // The tests above prove `[lints]` reaches through `with_config` at the
1536 // plugin level. `dialect`/`types`/`deny_warnings` previously only had
1537 // *loader*-level coverage (`InkLoader { override_config }` constructed
1538 // by hand, e.g. `plugin_override_wins_over_conflicting_asset` above) —
1539 // never proven to actually reach `InkLoader` through
1540 // `BrinkAssetsPlugin::with_config` / `BrinkPlugin::with_config`
1541 // themselves (`with_config` -> `with_config_option` ->
1542 // `BrinkAssetsPlugin::build` -> `InkLoader { override_config }`). Each
1543 // test below picks a fixture whose default-policy outcome the override
1544 // must actually flip (house rule: a value the default would already
1545 // produce proves nothing).
1546
1547 #[test]
1548 fn plugin_with_config_dialect_reaches_ink_loader() {
1549 // Default dialect (StrictInk) rejects `#@private`
1550 // (`missing_brink_toml_leaves_default_dialect_unchanged` above) --
1551 // only an explicit `dialect = Brink` override flips that.
1552 let (mut app, dir) = make_memory_asset_app_with_config(ProjectConfig {
1553 dialect: Some(Dialect::Brink),
1554 ..ProjectConfig::default()
1555 });
1556 dir.insert_asset_text(Path::new("intro.ink"), BRINK_ONLY_SOURCE);
1557
1558 let handle = app
1559 .world()
1560 .resource::<AssetServer>()
1561 .load::<BrinkStoryAsset>("intro.ink");
1562 wait_for_loaded(&mut app, &handle);
1563 }
1564
1565 #[test]
1566 fn plugin_with_config_types_reaches_ink_loader() {
1567 // `dialect = Brink` alone resolves `types` to its dialect-keyed
1568 // default, `Strict` (`AnalysisOptions::type_policy`), which rejects
1569 // `UNTYPED_PARAM_SOURCE`'s untyped function parameter -- only an
1570 // explicit `types = Gradual` override (on top of the same
1571 // `dialect = Brink`) makes it compile. See
1572 // `plugin_with_config_types_absent_leaves_strict_default_rejecting`
1573 // for the negative control proving the default (no `types`
1574 // override) still fails the same source.
1575 let (mut app, dir) = make_memory_asset_app_with_config(ProjectConfig {
1576 dialect: Some(Dialect::Brink),
1577 types: Some(TypePolicy::Gradual),
1578 ..ProjectConfig::default()
1579 });
1580 dir.insert_asset_text(Path::new("intro.ink"), UNTYPED_PARAM_SOURCE);
1581
1582 let handle = app
1583 .world()
1584 .resource::<AssetServer>()
1585 .load::<BrinkStoryAsset>("intro.ink");
1586 wait_for_loaded(&mut app, &handle);
1587 }
1588
1589 #[test]
1590 fn plugin_with_config_types_absent_leaves_strict_default_rejecting() {
1591 // Negative control for `plugin_with_config_types_reaches_ink_loader`
1592 // (house rule 19q): same `UNTYPED_PARAM_SOURCE`, same `dialect =
1593 // Brink`, but no `types` override -- the dialect-keyed default
1594 // (`Strict`) still rejects it, so the override in the sibling test
1595 // is what actually flips the outcome, not something else about the
1596 // fixture or the dialect setting.
1597 let (mut app, dir) = make_memory_asset_app_with_config(ProjectConfig {
1598 dialect: Some(Dialect::Brink),
1599 types: None,
1600 ..ProjectConfig::default()
1601 });
1602 dir.insert_asset_text(Path::new("intro.ink"), UNTYPED_PARAM_SOURCE);
1603
1604 let handle = app
1605 .world()
1606 .resource::<AssetServer>()
1607 .load::<BrinkStoryAsset>("intro.ink");
1608 wait_for_failed(&mut app, &handle);
1609 }
1610
1611 #[test]
1612 fn plugin_with_config_deny_warnings_reaches_ink_loader() {
1613 // `E014_SOURCE` loads cleanly under the default policy
1614 // (`e014_source_loads_by_default_with_no_lints_table` above) --
1615 // only an explicit `deny_warnings = true` override relevels its
1616 // Warning to a blocking Error.
1617 let (mut app, dir) = make_memory_asset_app_with_config(ProjectConfig {
1618 deny_warnings: Some(true),
1619 ..ProjectConfig::default()
1620 });
1621 dir.insert_asset_text(Path::new("intro.ink"), E014_SOURCE);
1622
1623 let handle = app
1624 .world()
1625 .resource::<AssetServer>()
1626 .load::<BrinkStoryAsset>("intro.ink");
1627 wait_for_failed(&mut app, &handle);
1628 }
1629
1630 #[test]
1631 fn brink_plugin_with_config_delegates_to_ink_loader() {
1632 // The two-hop path a real app actually uses (`BrinkPlugin<M>`, not
1633 // `BrinkAssetsPlugin` standalone) -- proves `BrinkPlugin::build`'s
1634 // `with_config_option` delegation actually carries the override
1635 // through, using the same dialect fixture as
1636 // `plugin_with_config_dialect_reaches_ink_loader`.
1637 let (mut app, dir) = make_memory_asset_app_via_brink_plugin_with_config(ProjectConfig {
1638 dialect: Some(Dialect::Brink),
1639 ..ProjectConfig::default()
1640 });
1641 dir.insert_asset_text(Path::new("intro.ink"), BRINK_ONLY_SOURCE);
1642
1643 let handle = app
1644 .world()
1645 .resource::<AssetServer>()
1646 .load::<BrinkStoryAsset>("intro.ink");
1647 wait_for_loaded(&mut app, &handle);
1648 }
1649
1650 /// [`BrinkConfigWarnings`](crate::BrinkConfigWarnings)'s plugin-level
1651 /// wiring (#1426): `BrinkAssetsPlugin::build` inserts it eagerly, so a
1652 /// host never needs a real story load (or a `tracing` subscriber) to
1653 /// read a rejected `with_config` lint code.
1654 #[test]
1655 fn plugin_with_config_invalid_lint_code_reaches_brink_config_warnings_resource() {
1656 let mut lints = std::collections::BTreeMap::new();
1657 lints.insert(
1658 "E9999_TYPO".to_owned(),
1659 brink_project_config::LintLevel::Deny,
1660 );
1661 let (app, _dir) = make_memory_asset_app_with_config(ProjectConfig {
1662 lints,
1663 ..ProjectConfig::default()
1664 });
1665
1666 let warnings = app.world().resource::<crate::BrinkConfigWarnings>();
1667 assert_eq!(warnings.0.len(), 1);
1668 assert!(
1669 warnings.0[0].contains("E9999_TYPO") && warnings.0[0].contains("not a recognized"),
1670 "unexpected resource contents: {warnings:?}"
1671 );
1672 }
1673
1674 /// Additional coverage found while investigating #1436 — not one of the
1675 /// two gaps #1430's review thread actually named (a *served*
1676 /// `brink.toml`'s `[lints]` rejections and unknown-key warnings on the
1677 /// bevy loader path; see #1625, filed as the follow-up, for those).
1678 /// This closes a different, narrower gap: the sibling of
1679 /// [`plugin_with_config_invalid_lint_code_reaches_brink_config_warnings_resource`]
1680 /// above only ever exercised the *unknown-code* rejection class
1681 /// (`E9999_TYPO`, "not a recognized diagnostic code") against
1682 /// [`BrinkConfigWarnings`](crate::BrinkConfigWarnings) — the
1683 /// *non-overridable-code* class ("not overridable", e.g. `E001`, a
1684 /// real code whose default severity isn't `Warning`) had this exact
1685 /// resource-channel reachability covered only by
1686 /// `config_warnings::tests::non_overridable_code_surfaces_a_message_naming_it`,
1687 /// which calls `BrinkConfigWarnings::from_config` directly and so
1688 /// proves nothing about `with_config`'s own plugin-build wiring — the
1689 /// same gap class `plugin_with_config_invalid_lint_code_reaches_brink_config_warnings_resource`
1690 /// closed for the unknown-code class. `validate_lint_code`
1691 /// (`brink-analyzer`) returns a structurally different `ConfigWarning`
1692 /// per rejection class, so covering one does not prove the other is
1693 /// wired the same way.
1694 #[test]
1695 fn plugin_with_config_non_overridable_lint_code_reaches_brink_config_warnings_resource() {
1696 let mut lints = std::collections::BTreeMap::new();
1697 // A real `DiagnosticCode`, but its base severity is `Error`, not
1698 // `Warning` -- never overridable (mirrors
1699 // `apply_lint_overrides_rejects_non_overridable_code` in
1700 // `brink-analyzer`, and this file's own
1701 // `plugin_override_unknown_and_non_overridable_lint_codes_warn_but_valid_entry_still_applies`,
1702 // which proves the same code on the `tracing::warn!` channel, not
1703 // this resource).
1704 lints.insert("E001".to_owned(), brink_project_config::LintLevel::Deny);
1705 let (app, _dir) = make_memory_asset_app_with_config(ProjectConfig {
1706 lints,
1707 ..ProjectConfig::default()
1708 });
1709
1710 let warnings = app.world().resource::<crate::BrinkConfigWarnings>();
1711 assert_eq!(warnings.0.len(), 1);
1712 assert!(
1713 warnings.0[0].contains("E001") && warnings.0[0].contains("not overridable"),
1714 "unexpected resource contents: {warnings:?}"
1715 );
1716 }
1717
1718 /// Additional coverage found while investigating #1436 — not one of the
1719 /// two gaps #1430's review thread actually named (see the doc comment
1720 /// on `plugin_with_config_non_overridable_lint_code_reaches_brink_config_warnings_resource`
1721 /// above for those, and #1625, filed as the follow-up). This closes a
1722 /// different gap: `compile_story_inline` shares the exact same
1723 /// `Project::load` seam `InkLoader::load` uses (#1380's own doc
1724 /// comment: "the exact same two-call seam `InkLoader::load` uses") —
1725 /// so a `with_config` lint-code rejection must warn through
1726 /// `compile_story_inline`'s call too, not just the asset-loader path
1727 /// `plugin_override_unknown_and_non_overridable_lint_codes_warn_but_valid_entry_still_applies`
1728 /// already covers. That test (and the `BrinkConfigWarnings`-resource
1729 /// tests above) only ever drove the rejection through
1730 /// `AssetServer::load` -> `InkLoader::load` -> `Project::load`;
1731 /// `compile_story_inline`'s own `Project::load` call
1732 /// (`source_loader.rs`, `compile_story_inline`) had no coverage proving
1733 /// it reaches the same `tracing::warn!` channel.
1734 #[test]
1735 fn compile_story_inline_invalid_lint_code_warns_via_tracing() {
1736 let captured = captured_warnings();
1737
1738 let mut lints = std::collections::BTreeMap::new();
1739 lints.insert(
1740 "E9998_INLINE_TYPO".to_owned(),
1741 brink_project_config::LintLevel::Deny,
1742 );
1743 let (mut app, _dir) = make_memory_asset_app_with_config(ProjectConfig {
1744 lints,
1745 ..ProjectConfig::default()
1746 });
1747
1748 // The rejected code is never applied (it's invalid, not merged),
1749 // so this trivial, diagnostic-free source still compiles cleanly --
1750 // the point is to observe the warning, not a failed compile.
1751 crate::compile_story_inline(&mut app, "intro.ink", "-> END\n")
1752 .expect("E9998_INLINE_TYPO is rejected, not applied, so nothing blocks the compile");
1753
1754 let joined = captured.lock().unwrap().join("\n");
1755 assert!(
1756 joined.contains("[lints] `E9998_INLINE_TYPO` is not a recognized diagnostic code"),
1757 "an invalid with_config lint code must warn through \
1758 compile_story_inline's own Project::load call too, not just \
1759 InkLoader's; captured: {joined}"
1760 );
1761 }
1762
1763 /// Issue #1382 sweep finding: a *second* `BrinkPlugin<M>` registration's
1764 /// `with_config` override used to vanish with no trace at all once
1765 /// `BrinkAssetsPlugin` already existed (added by an earlier marker's
1766 /// plugin) — `BrinkPlugin::with_config`'s own doc comment already
1767 /// documented the precedence rule ("only the plugin that ends up adding
1768 /// `BrinkAssetsPlugin` applies its config"), but neither the
1769 /// `tracing::warn!` channel nor [`BrinkConfigWarnings`] ever recorded
1770 /// that a *later* marker's whole `ProjectConfig` was the one that lost —
1771 /// exactly the silent-drop pattern this issue swept for, just under a
1772 /// different name than `resolve_*_options`. Two distinct marker types
1773 /// reproduce a real multi-story app rather than a synthetic double
1774 /// registration: `MarkerA`'s plugin (no override) is the one that adds
1775 /// `BrinkAssetsPlugin`, so `MarkerB`'s `with_config` has nothing left to
1776 /// land in.
1777 #[test]
1778 fn second_marker_with_config_drop_is_diagnosed_not_silent() {
1779 struct MarkerA;
1780 struct MarkerB;
1781
1782 let captured = captured_warnings();
1783
1784 let mut app = bevy_app::App::new();
1785 let dir = Dir::default();
1786 let dir_clone = dir.clone();
1787 app.register_asset_source(
1788 AssetSourceId::Default,
1789 AssetSourceBuilder::new(move || {
1790 Box::new(MemoryAssetReader {
1791 root: dir_clone.clone(),
1792 })
1793 }),
1794 )
1795 .add_plugins((
1796 TaskPoolPlugin::default(),
1797 AssetPlugin {
1798 watch_for_changes_override: Some(false),
1799 use_asset_processor_override: Some(false),
1800 ..Default::default()
1801 },
1802 // `MarkerA`'s plugin has no override and is the one that ends up
1803 // adding `BrinkAssetsPlugin` (first in registration order).
1804 crate::BrinkPlugin::<MarkerA>::default(),
1805 // `MarkerB`'s override arrives after `BrinkAssetsPlugin` already
1806 // exists, so it must be diagnosed rather than silently dropped.
1807 crate::BrinkPlugin::<MarkerB>::default().with_config(ProjectConfig {
1808 dialect: Some(Dialect::Brink),
1809 ..ProjectConfig::default()
1810 }),
1811 ));
1812
1813 let warnings = app.world().resource::<crate::BrinkConfigWarnings>();
1814 assert!(
1815 warnings
1816 .0
1817 .iter()
1818 .any(|w| w.contains("MarkerB") && w.contains("ignored")),
1819 "a second marker's dropped `with_config` override must be recorded \
1820 in `BrinkConfigWarnings`, not silently discarded; got: {warnings:?}"
1821 );
1822
1823 let joined = captured.lock().unwrap().join("\n");
1824 assert!(
1825 joined.contains("MarkerB") && joined.contains("ignored"),
1826 "the same drop must also reach the tracing::warn! channel (the \
1827 'warn, never silently drop' rule every other mount's config \
1828 resolution already follows); captured: {joined}"
1829 );
1830
1831 // Prove the drop, not just its announcement (house rule 19t): the
1832 // shared `InkLoader` this app ends up with must still be running
1833 // under the strict-ink default, not `MarkerB`'s `dialect = brink`
1834 // override -- if a future change ever let a later marker's config
1835 // reach `InkLoader` after all, this would start failing (green,
1836 // while the warning above kept firing as a lie) unless it's pinned
1837 // down here too.
1838 dir.insert_asset_text(Path::new("intro.ink"), BRINK_ONLY_SOURCE);
1839 let handle = app
1840 .world()
1841 .resource::<AssetServer>()
1842 .load::<BrinkStoryAsset>("intro.ink");
1843 wait_for_failed(&mut app, &handle);
1844 }
1845
1846 #[test]
1847 fn hot_reload_picks_up_edited_brink_toml() {
1848 let (mut app, dir) = make_memory_asset_app();
1849 dir.insert_asset_text(Path::new("intro.ink"), BRINK_ONLY_SOURCE);
1850 dir.insert_asset_text(Path::new("brink.toml"), "[project]\ndialect = \"brink\"\n");
1851
1852 let handle = app
1853 .world()
1854 .resource::<AssetServer>()
1855 .load::<BrinkStoryAsset>("intro.ink");
1856 wait_for_loaded(&mut app, &handle);
1857
1858 // Edit brink.toml back to strict-ink and force a reload -- this is
1859 // what the dev-mode file watcher does automatically when a
1860 // registered load dependency changes on disk; `reload` here drives
1861 // the same path deterministically without a real watcher.
1862 dir.insert_asset_text(
1863 Path::new("brink.toml"),
1864 "[project]\ndialect = \"strict-ink\"\n",
1865 );
1866 app.world().resource::<AssetServer>().reload("intro.ink");
1867
1868 wait_for_failed(&mut app, &handle);
1869 }
1870
1871 /// #1360 regression: the migrated loader still walks a multi-file
1872 /// `INCLUDE` graph correctly through the real `AssetServer` /
1873 /// `MemoryAssetReader`, including a parent-traversing (`../`) include
1874 /// that exercises [`super::resolve_include_path`]'s `..`-segment
1875 /// normalization, alongside a same-directory include. Before #1360 this
1876 /// path went through `brink_compiler::compile_with_options`'s read
1877 /// closure; it now goes through `brink_source_tree::InMemory` ->
1878 /// `brink_environment::Driver::discover`, so a producer-side change to
1879 /// include-graph keying could silently break multi-file loads without
1880 /// this test catching it.
1881 #[test]
1882 fn multi_file_include_graph_loads_through_asset_server() {
1883 let (mut app, dir) = make_memory_asset_app();
1884 dir.insert_asset_text(
1885 Path::new("stories/ch1/intro.ink"),
1886 "INCLUDE ../shared/util.ink\nINCLUDE local.ink\n-> END\n",
1887 );
1888 // Parent traversal: "../shared/util.ink" from "stories/ch1/" must
1889 // resolve to "stories/shared/util.ink", not "stories/ch1/../shared/util.ink".
1890 dir.insert_asset_text(Path::new("stories/shared/util.ink"), "VAR shared_var = 1\n");
1891 // Same-directory include, resolved relative to the entry's own dir.
1892 dir.insert_asset_text(Path::new("stories/ch1/local.ink"), "VAR local_var = 2\n");
1893
1894 let handle = app
1895 .world()
1896 .resource::<AssetServer>()
1897 .load::<BrinkStoryAsset>("stories/ch1/intro.ink");
1898 wait_for_loaded(&mut app, &handle);
1899 }
1900
1901 // ── served `brink.toml`'s own warnings reach the loader path (#1625) ──
1902 //
1903 // #1430's review thread named two gaps neither #1436's tests
1904 // (`plugin_with_config_non_overridable_lint_code_reaches_brink_config_warnings_resource`,
1905 // `compile_story_inline_invalid_lint_code_warns_via_tracing`, both above)
1906 // nor any other existing test actually closed: a *served* `brink.toml`
1907 // -- one discovered next to the story asset through the real
1908 // `AssetServer::load` -> `InkLoader::load` -> `Project::load` ->
1909 // `resolve_options` seam, not a `with_config`/`InkLoader::override_config`
1910 // override -- whose `[lints]` table rejects an entry, or whose top-level
1911 // keys include an unknown one, must still warn via `tracing::warn!`.
1912 // `resolve_options` (`brink-environment/src/lib.rs`) already does this
1913 // unconditionally for a discovered file (`tracing::warn!("[{config_key}]
1914 // {warning}")`, both for `parse_str_at`'s own unknown-key warnings and
1915 // for `apply_project_config`'s rejected-`[lints]`-entry warnings) -- the
1916 // two tests below are the first to actually observe that through the
1917 // bevy loader path a real host uses, asserting the full `resolve_options`
1918 // -produced message text (not just a code substring) so the two
1919 // rejection classes stay distinguishable from each other, per the issue.
1920 #[test]
1921 fn served_brink_toml_invalid_lint_code_warns_via_tracing() {
1922 let captured = captured_warnings();
1923
1924 let (mut app, dir) = make_memory_asset_app();
1925 dir.insert_asset_text(Path::new("intro.ink"), E014_SOURCE);
1926 // Uniquely-spelled code (the capture buffer is process-global across
1927 // this file's tests) -- a real `DiagnosticCode` whose base severity
1928 // isn't `Warning`, so it's rejected as "not overridable" rather than
1929 // "not a recognized diagnostic code". `E001` is deliberately avoided
1930 // here: it's also used by
1931 // `plugin_override_unknown_and_non_overridable_lint_codes_warn_but_valid_entry_still_applies`'s
1932 // `with_config` case, and that test's `joined.contains("[lints]
1933 // `E001` is not overridable")` assertion would be satisfied by this
1934 // served-file warning too (the capture buffer is shared process-wide)
1935 // whenever this test runs first, making that other test's own
1936 // `with_config` path go unverified without either test failing.
1937 // `E002` is a real, non-overridable `DiagnosticCode`
1938 // (`DiagnosticCode::severity`, `brink-ir/src/hir/types.rs`) used
1939 // nowhere else in this file, so it stays independent of both.
1940 //
1941 // A second entry, `E8888_SERVED_TYPO`, covers the sibling rejection
1942 // class ("not a recognized diagnostic code") on this same served-file
1943 // path -- the precedent test above deliberately covers both classes
1944 // because a bare code substring can't distinguish them, so this test
1945 // does too.
1946 dir.insert_asset_text(
1947 Path::new("brink.toml"),
1948 "[lints]\nE002 = \"deny\"\nE8888_SERVED_TYPO = \"deny\"\n",
1949 );
1950
1951 let handle = app
1952 .world()
1953 .resource::<AssetServer>()
1954 .load::<BrinkStoryAsset>("intro.ink");
1955 // The rejected entries are never applied, so E014 stays a
1956 // non-blocking Warning and the load still succeeds -- the point is
1957 // to observe the warnings, not a failed compile.
1958 wait_for_loaded(&mut app, &handle);
1959
1960 let joined = captured.lock().unwrap().join("\n");
1961 assert!(
1962 joined.contains("[brink.toml] [lints] `E002` is not overridable"),
1963 "a served brink.toml's rejected [lints] entry must warn with the \
1964 full resolve_options-produced message (config-key-prefixed, not \
1965 just the code substring) through the real AssetServer -> \
1966 InkLoader -> Project::load path; captured: {joined}"
1967 );
1968 assert!(
1969 joined.contains(
1970 "[brink.toml] [lints] `E8888_SERVED_TYPO` is not a recognized diagnostic code"
1971 ),
1972 "a served brink.toml's unrecognized [lints] code must also warn, \
1973 distinguishable from the not-overridable class above; \
1974 captured: {joined}"
1975 );
1976 }
1977
1978 #[test]
1979 fn served_brink_toml_unknown_top_level_key_warns_via_tracing() {
1980 let captured = captured_warnings();
1981
1982 let (mut app, dir) = make_memory_asset_app();
1983 dir.insert_asset_text(Path::new("intro.ink"), BRINK_ONLY_SOURCE);
1984 // `[project] dialect = "brink"` so the load still succeeds
1985 // (BRINK_ONLY_SOURCE needs it) -- the unknown top-level key alongside
1986 // it (not nested under `[project]`, which would instead warn as
1987 // "unknown key `project.suprise_typo_key`") is what this test is
1988 // actually about, distinguished from the `[lints]`-rejection class
1989 // above by asserting the "unknown top-level key" wording
1990 // specifically. The bare key must precede the `[project]` table
1991 // header -- TOML requires top-level key/value pairs before the
1992 // first table.
1993 dir.insert_asset_text(
1994 Path::new("brink.toml"),
1995 "suprise_typo_key = true\n\n[project]\ndialect = \"brink\"\n",
1996 );
1997
1998 let handle = app
1999 .world()
2000 .resource::<AssetServer>()
2001 .load::<BrinkStoryAsset>("intro.ink");
2002 wait_for_loaded(&mut app, &handle);
2003
2004 let joined = captured.lock().unwrap().join("\n");
2005 assert!(
2006 joined.contains(
2007 "[brink.toml] unknown top-level key `suprise_typo_key` in brink.toml (ignored)"
2008 ),
2009 "a served brink.toml's unknown top-level key must warn with the \
2010 full resolve_options-produced message through the real \
2011 AssetServer -> InkLoader -> Project::load path, distinguishable \
2012 from the [lints]-rejection class; captured: {joined}"
2013 );
2014 }
2015}