use std::collections::BTreeMap;
use bevy_app::App;
use bevy_asset::{AssetLoader, Assets, Handle, LoadContext, io::Reader};
use bevy_ecs::resource::Resource;
use bevy_reflect::TypePath;
use brink_environment::{OptionOverrides, Project};
use brink_project_config::ProjectConfig;
use brink_source_tree::InMemory;
use crate::asset::{
BrinkStoryAsset, LineTablesAsset, ProgramAsset, emit_story_assets, fresh_context,
};
#[derive(Resource, Debug, Clone, Default)]
pub(crate) struct BrinkOverrideConfig(pub(crate) Option<ProjectConfig>);
fn overrides_from_config(config: Option<&ProjectConfig>) -> OptionOverrides {
OptionOverrides {
dialect: config.and_then(|c| c.dialect),
types: config.and_then(|c| c.types),
lints: config.map(|c| c.lints.clone()).unwrap_or_default(),
deny_warnings: config.and_then(|c| c.deny_warnings),
debug_info: false,
}
}
#[derive(Default, TypePath)]
pub struct InkLoader {
pub override_config: Option<ProjectConfig>,
}
#[derive(Debug, thiserror::Error)]
pub enum InkLoaderError {
#[error("I/O error: {0}")]
Io(#[from] std::io::Error),
#[error("read asset: {0}")]
ReadAsset(#[from] bevy_asset::ReadAssetBytesError),
#[error("source not valid UTF-8: {0}")]
InvalidUtf8(#[from] std::string::FromUtf8Error),
#[error("entry path missing or non-UTF-8")]
BadEntryPath,
#[error("load environment: {0}")]
Load(#[from] brink_environment::LoadError),
#[error("compile: {0}")]
Compile(#[from] brink_compiler::CompileError),
#[error("link error: {0}")]
Link(#[from] brink_runtime::RuntimeError),
}
#[derive(Debug, thiserror::Error)]
pub enum CompileStoryInlineError {
#[error(
"load environment: {0} (compile_story_inline compiles a single in-memory source; use InkLoader/AssetServer for multi-file stories)"
)]
Load(#[from] brink_environment::LoadError),
#[error("compile: {0}")]
Compile(#[from] brink_compiler::CompileError),
#[error("link error: {0}")]
Link(#[from] brink_runtime::RuntimeError),
}
pub fn compile_story_inline(
app: &mut App,
name: &str,
source: &str,
) -> Result<Handle<BrinkStoryAsset>, CompileStoryInlineError> {
let mut sources = BTreeMap::new();
sources.insert(name.to_string(), source.to_string());
let tree = InMemory::new(sources);
let override_config = app
.world()
.get_resource::<BrinkOverrideConfig>()
.and_then(|r| r.0.as_ref());
let overrides = overrides_from_config(override_config);
let env = Project::load(&tree, name, &overrides)?;
let output = brink_environment::compile(&env)?;
let (program, tables) = brink_runtime::link(&output.data)?;
let initial_context = fresh_context(&program);
let world = app.world_mut();
let program_handle = world
.resource_mut::<Assets<ProgramAsset>>()
.add(ProgramAsset {
program,
initial_context,
effect_rows: output.data.effect_rows,
});
let line_tables_handle = world
.resource_mut::<Assets<LineTablesAsset>>()
.add(LineTablesAsset { tables });
Ok(world
.resource_mut::<Assets<BrinkStoryAsset>>()
.add(BrinkStoryAsset {
program: program_handle,
line_tables: line_tables_handle,
}))
}
fn resolve_include_path(from_file: &str, include_path: &str) -> String {
let joined = match from_file.rfind('/') {
Some(i) => format!("{}/{include_path}", &from_file[..i]),
None => include_path.to_string(),
};
let absolute = joined.starts_with('/');
let mut out: Vec<&str> = Vec::new();
for seg in joined.split('/') {
match seg {
"" | "." => {}
".." if matches!(out.last(), Some(&s) if s != "..") => {
out.pop();
}
s => out.push(s),
}
}
let joined = out.join("/");
if absolute {
format!("/{joined}")
} else {
joined
}
}
fn ancestor_dirs(entry_path: &str) -> Vec<String> {
let mut dirs = Vec::new();
let mut current = match entry_path.rfind('/') {
Some(i) => &entry_path[..i],
None => "",
};
loop {
dirs.push(current.to_string());
if current.is_empty() {
break;
}
current = match current.rfind('/') {
Some(i) => ¤t[..i],
None => "",
};
}
dirs
}
async fn probe_brink_toml(
load_context: &mut LoadContext<'_>,
entry_path: &str,
) -> Result<Option<(String, String)>, InkLoaderError> {
for dir in ancestor_dirs(entry_path) {
let candidate = if dir.is_empty() {
brink_project_config::CONFIG_FILE_NAME.to_string()
} else {
format!("{dir}/{}", brink_project_config::CONFIG_FILE_NAME)
};
if let Ok(bytes) = load_context.read_asset_bytes(candidate.clone()).await {
match String::from_utf8(bytes) {
Ok(text) => {
return Ok(Some((candidate, text)));
}
Err(err) => return Err(err.into()),
}
}
}
Ok(None)
}
impl AssetLoader for InkLoader {
type Asset = BrinkStoryAsset;
type Settings = ();
type Error = InkLoaderError;
async fn load(
&self,
reader: &mut dyn Reader,
_settings: &Self::Settings,
load_context: &mut LoadContext<'_>,
) -> Result<Self::Asset, Self::Error> {
let entry_path = load_context
.path()
.path()
.to_str()
.ok_or(InkLoaderError::BadEntryPath)?
.to_string();
let mut entry_bytes = Vec::new();
reader.read_to_end(&mut entry_bytes).await?;
let entry_source = String::from_utf8(entry_bytes)?;
let mut sources: BTreeMap<String, String> = BTreeMap::new();
let mut queue: Vec<String> = brink_syntax::extract_includes(&entry_source)
.into_iter()
.map(|inc| resolve_include_path(&entry_path, &inc))
.collect();
sources.insert(entry_path.clone(), entry_source);
while let Some(path) = queue.pop() {
if sources.contains_key(&path) {
continue;
}
let bytes = load_context.read_asset_bytes(path.clone()).await?;
let source = String::from_utf8(bytes)?;
for inc in brink_syntax::extract_includes(&source) {
let resolved = resolve_include_path(&path, &inc);
if !sources.contains_key(&resolved) {
queue.push(resolved);
}
}
sources.insert(path, source);
}
if let Some((config_path, text)) = probe_brink_toml(load_context, &entry_path).await? {
sources.insert(config_path, text);
}
let overrides = overrides_from_config(self.override_config.as_ref());
let tree = InMemory::new(sources);
let env = Project::load(&tree, &entry_path, &overrides)?;
let output = brink_environment::compile(&env)?;
let (program, tables) = brink_runtime::link(&output.data)?;
Ok(emit_story_assets(
load_context,
program,
tables,
output.data.effect_rows,
))
}
fn extensions(&self) -> &[&str] {
&["ink"]
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn resolves_with_directory_prefix() {
assert_eq!(
resolve_include_path("src/main.ink", "utils.ink"),
"src/utils.ink"
);
}
#[test]
fn resolves_without_directory() {
assert_eq!(resolve_include_path("story.ink", "other.ink"), "other.ink");
}
#[test]
fn resolves_nested_directory() {
assert_eq!(resolve_include_path("a/b/c.ink", "d.ink"), "a/b/d.ink");
}
#[test]
fn normalizes_parent_traversal() {
assert_eq!(resolve_include_path("a/b/c.ink", "../d.ink"), "a/d.ink");
assert_eq!(resolve_include_path("a/b/c.ink", "../../d.ink"), "d.ink");
}
#[test]
fn compile_story_inline_inserts_assets_and_returns_handle() {
let mut app = crate::test_support::make_test_app();
let handle = compile_story_inline(&mut app, "inline.ink", "VAR mood = 3\n-> END\n")
.expect("inline source compiles and links");
let world = app.world();
let story = world
.resource::<Assets<BrinkStoryAsset>>()
.get(&handle)
.expect("story asset inserted");
let program_asset = world
.resource::<Assets<ProgramAsset>>()
.get(&story.program)
.expect("program asset inserted");
assert_eq!(program_asset.program.global_index("mood"), Some(0));
assert!(
world
.resource::<Assets<LineTablesAsset>>()
.get(&story.line_tables)
.is_some(),
"line tables asset inserted"
);
}
#[test]
fn compile_story_inline_surfaces_compile_error() {
let mut app = crate::test_support::make_test_app();
let err = compile_story_inline(&mut app, "broken.ink", "-> nowhere_knot\n")
.expect_err("a divert to an undeclared knot should not compile");
assert!(
matches!(err, CompileStoryInlineError::Compile(_)),
"got {err:?}"
);
}
#[test]
fn compile_story_inline_has_no_brink_toml_and_uses_default_dialect() {
let mut app = crate::test_support::make_test_app();
let err = compile_story_inline(
&mut app,
"inline.ink",
"#@private\nVAR secret = 0\n-> END\n",
)
.expect_err("brink-extension syntax should be rejected under the default dialect");
assert!(
matches!(err, CompileStoryInlineError::Compile(_)),
"got {err:?}"
);
}
#[test]
fn compile_story_inline_surfaces_load_error_for_unresolvable_include() {
let mut app = crate::test_support::make_test_app();
let err = compile_story_inline(&mut app, "inline.ink", "INCLUDE missing.ink\n-> END\n")
.expect_err("an INCLUDE can never resolve in a single-key inline tree");
assert!(
matches!(err, CompileStoryInlineError::Load(_)),
"got {err:?}"
);
assert!(
err.to_string().contains(
"compile_story_inline compiles a single in-memory source; use InkLoader/AssetServer for multi-file stories"
),
"error message should carry authoring guidance, got: {err}"
);
}
#[test]
fn compile_story_inline_reaches_plugin_with_config_dialect_override() {
use brink_project_config::Dialect;
let mut app = bevy_app::App::new();
app.add_plugins(bevy_asset::AssetPlugin::default());
app.add_plugins(
crate::BrinkPlugin::<()>::default().with_config(ProjectConfig {
dialect: Some(Dialect::Brink),
..ProjectConfig::default()
}),
);
compile_story_inline(
&mut app,
"inline.ink",
"#@private\nVAR secret = 0\n-> END\n",
)
.expect(
"dialect = Brink override should reach compile_story_inline and permit \
brink-extension syntax the default dialect rejects",
);
}
#[test]
fn compile_story_inline_reaches_plugin_with_config_deny_warnings_override() {
let mut app = bevy_app::App::new();
app.add_plugins(bevy_asset::AssetPlugin::default());
app.add_plugins(
crate::BrinkPlugin::<()>::default().with_config(ProjectConfig {
deny_warnings: Some(true),
..ProjectConfig::default()
}),
);
let err = compile_story_inline(&mut app, "inline.ink", "Hello.\n~\n-> END\n")
.expect_err("deny_warnings override should relevel E014 to a blocking error");
assert!(
matches!(err, CompileStoryInlineError::Compile(_)),
"got {err:?}"
);
}
#[test]
fn compile_story_inline_without_config_leaves_e014_a_warning() {
let mut app = crate::test_support::make_test_app();
compile_story_inline(&mut app, "inline.ink", "Hello.\n~\n-> END\n")
.expect("E014 is a Warning, never blocking, with no deny_warnings override");
}
const UNTYPED_PARAM_SOURCE: &str =
"=== function f(x) ===\n~ return x\n\n=== start ===\n{f(1)}\n-> END\n";
#[test]
fn compile_story_inline_reaches_plugin_with_config_types_override() {
use brink_project_config::{Dialect, TypePolicy};
let mut app = bevy_app::App::new();
app.add_plugins(bevy_asset::AssetPlugin::default());
app.add_plugins(
crate::BrinkPlugin::<()>::default().with_config(ProjectConfig {
dialect: Some(Dialect::Brink),
types: Some(TypePolicy::Gradual),
..ProjectConfig::default()
}),
);
compile_story_inline(&mut app, "inline.ink", UNTYPED_PARAM_SOURCE).expect(
"types = Gradual override should reach compile_story_inline and permit the \
untyped parameter the dialect-keyed Strict default rejects",
);
}
#[test]
fn compile_story_inline_reaches_plugin_with_config_per_code_lint_override() {
let mut app = bevy_app::App::new();
app.add_plugins(bevy_asset::AssetPlugin::default());
let mut lints = std::collections::BTreeMap::new();
lints.insert("E014".to_owned(), brink_project_config::LintLevel::Deny);
app.add_plugins(
crate::BrinkPlugin::<()>::default().with_config(ProjectConfig {
lints,
..ProjectConfig::default()
}),
);
let err = compile_story_inline(&mut app, "inline.ink", "Hello.\n~\n-> END\n").expect_err(
"a per-code `[lints] E014 = deny` override should relevel E014 \
to a blocking error",
);
assert!(
matches!(err, CompileStoryInlineError::Compile(_)),
"got {err:?}"
);
}
#[test]
fn compile_story_inline_before_plugin_add_silently_falls_back_to_default() {
use brink_project_config::Dialect;
let mut app = bevy_app::App::new();
app.add_plugins(bevy_asset::AssetPlugin::default());
let pending_plugin = crate::BrinkPlugin::<()>::default().with_config(ProjectConfig {
dialect: Some(Dialect::Brink),
..ProjectConfig::default()
});
let err = compile_story_inline(
&mut app,
"inline.ink",
"#@private\nVAR secret = 0\n-> END\n",
)
.expect_err(
"with no BrinkOverrideConfig resource yet inserted, the default StrictInk \
dialect must still reject brink-extension syntax -- the override cannot \
reach a call made before its plugin builds",
);
assert!(
matches!(err, CompileStoryInlineError::Compile(_)),
"got {err:?}"
);
app.add_plugins(pending_plugin);
compile_story_inline(
&mut app,
"inline.ink",
"#@private\nVAR secret = 0\n-> END\n",
)
.expect("once the plugin has built, the same override now reaches the same call");
}
#[test]
fn ancestor_dirs_nested_entry_climbs_to_root() {
assert_eq!(
ancestor_dirs("stories/ch1/intro.ink"),
vec![
"stories/ch1".to_string(),
"stories".to_string(),
String::new()
]
);
}
#[test]
fn ancestor_dirs_root_entry_is_just_root() {
assert_eq!(ancestor_dirs("intro.ink"), vec![String::new()]);
}
#[test]
fn ancestor_dirs_single_directory_level() {
assert_eq!(
ancestor_dirs("stories/intro.ink"),
vec!["stories".to_string(), String::new()]
);
}
}
#[cfg(test)]
mod config_discovery_tests {
use std::path::Path;
use bevy_app::TaskPoolPlugin;
use bevy_asset::io::memory::{Dir, MemoryAssetReader};
use bevy_asset::io::{AssetSourceBuilder, AssetSourceId};
use bevy_asset::{AssetApp, AssetPlugin, AssetServer, LoadState};
use brink_project_config::{Dialect, TypePolicy};
use super::{InkLoader, ProjectConfig};
use crate::asset::{BrinkStoryAsset, LineTablesAsset, ProgramAsset};
const BRINK_ONLY_SOURCE: &str = "#@private\nVAR secret = 0\n-> END\n";
const E014_SOURCE: &str = "Hello.\n~\n-> END\n";
const UNTYPED_PARAM_SOURCE: &str =
"=== function f(x) ===\n~ return x\n\n=== start ===\n{f(1)}\n-> END\n";
fn make_memory_asset_app() -> (bevy_app::App, Dir) {
let mut app = bevy_app::App::new();
let dir = Dir::default();
let dir_clone = dir.clone();
app.register_asset_source(
AssetSourceId::Default,
AssetSourceBuilder::new(move || {
Box::new(MemoryAssetReader {
root: dir_clone.clone(),
})
}),
)
.add_plugins((
TaskPoolPlugin::default(),
AssetPlugin {
watch_for_changes_override: Some(false),
use_asset_processor_override: Some(false),
..Default::default()
},
));
app.init_asset::<BrinkStoryAsset>();
app.init_asset::<ProgramAsset>();
app.init_asset::<LineTablesAsset>();
app.register_asset_loader(InkLoader::default());
(app, dir)
}
fn make_memory_asset_app_with_config(config: ProjectConfig) -> (bevy_app::App, Dir) {
let mut app = bevy_app::App::new();
let dir = Dir::default();
let dir_clone = dir.clone();
app.register_asset_source(
AssetSourceId::Default,
AssetSourceBuilder::new(move || {
Box::new(MemoryAssetReader {
root: dir_clone.clone(),
})
}),
)
.add_plugins((
TaskPoolPlugin::default(),
AssetPlugin {
watch_for_changes_override: Some(false),
use_asset_processor_override: Some(false),
..Default::default()
},
crate::plugin::BrinkAssetsPlugin::default().with_config(config),
));
(app, dir)
}
fn make_memory_asset_app_via_brink_plugin_with_config(
config: ProjectConfig,
) -> (bevy_app::App, Dir) {
let mut app = bevy_app::App::new();
let dir = Dir::default();
let dir_clone = dir.clone();
app.register_asset_source(
AssetSourceId::Default,
AssetSourceBuilder::new(move || {
Box::new(MemoryAssetReader {
root: dir_clone.clone(),
})
}),
)
.add_plugins((
TaskPoolPlugin::default(),
AssetPlugin {
watch_for_changes_override: Some(false),
use_asset_processor_override: Some(false),
..Default::default()
},
crate::BrinkPlugin::<()>::default().with_config(config),
));
(app, dir)
}
fn run_until<T>(
app: &mut bevy_app::App,
mut predicate: impl FnMut(&mut bevy_app::App) -> Option<T>,
) -> Option<T> {
for _ in 0..2000 {
app.update();
let hit = predicate(app);
if hit.is_some() {
return hit;
}
}
None
}
fn wait_for_loaded(app: &mut bevy_app::App, handle: &bevy_asset::Handle<BrinkStoryAsset>) {
let state = run_until(app, |app| {
match app.world().resource::<AssetServer>().load_state(handle) {
LoadState::NotLoaded | LoadState::Loading => None,
terminal => Some(terminal),
}
})
.expect("asset load did not reach a terminal state within the bounded poll loop");
assert!(
matches!(state, LoadState::Loaded),
"expected the load to succeed; got {state:?}"
);
}
fn wait_for_failed(app: &mut bevy_app::App, handle: &bevy_asset::Handle<BrinkStoryAsset>) {
run_until(app, |app| {
matches!(
app.world().resource::<AssetServer>().load_state(handle),
LoadState::Failed(_)
)
.then_some(())
})
.expect("asset load did not reach Failed within the bounded poll loop");
}
#[test]
fn missing_brink_toml_leaves_default_dialect_unchanged() {
let (mut app, dir) = make_memory_asset_app();
dir.insert_asset_text(Path::new("intro.ink"), BRINK_ONLY_SOURCE);
let handle = app
.world()
.resource::<AssetServer>()
.load::<BrinkStoryAsset>("intro.ink");
wait_for_failed(&mut app, &handle);
}
#[test]
fn sibling_brink_toml_sets_brink_dialect() {
let (mut app, dir) = make_memory_asset_app();
dir.insert_asset_text(Path::new("intro.ink"), BRINK_ONLY_SOURCE);
dir.insert_asset_text(Path::new("brink.toml"), "[project]\ndialect = \"brink\"\n");
let handle = app
.world()
.resource::<AssetServer>()
.load::<BrinkStoryAsset>("intro.ink");
wait_for_loaded(&mut app, &handle);
}
#[test]
fn ancestor_brink_toml_found_via_bounded_walkup() {
let (mut app, dir) = make_memory_asset_app();
dir.insert_asset_text(Path::new("stories/ch1/intro.ink"), BRINK_ONLY_SOURCE);
dir.insert_asset_text(Path::new("brink.toml"), "[project]\ndialect = \"brink\"\n");
let handle = app
.world()
.resource::<AssetServer>()
.load::<BrinkStoryAsset>("stories/ch1/intro.ink");
wait_for_loaded(&mut app, &handle);
}
#[test]
fn nearest_ancestor_brink_toml_shadows_a_farther_conflicting_one() {
let (mut app, dir) = make_memory_asset_app();
dir.insert_asset_text(Path::new("stories/ch1/intro.ink"), BRINK_ONLY_SOURCE);
dir.insert_asset_text(
Path::new("stories/ch1/brink.toml"),
"[project]\ndialect = \"brink\"\n",
);
dir.insert_asset_text(
Path::new("brink.toml"),
"[project]\ndialect = \"strict-ink\"\n",
);
let handle = app
.world()
.resource::<AssetServer>()
.load::<BrinkStoryAsset>("stories/ch1/intro.ink");
wait_for_loaded(&mut app, &handle);
}
#[test]
fn single_pass_discovery_finds_nearest_config_for_nested_entry() {
let (mut app, dir) = make_memory_asset_app();
dir.insert_asset_text(
Path::new("project/nested/deep/story.ink"),
BRINK_ONLY_SOURCE,
);
dir.insert_asset_text(
Path::new("project/nested/brink.toml"),
"[project]\ndialect = \"brink\"\n",
);
dir.insert_asset_text(
Path::new("project/brink.toml"),
"[project]\ndialect = \"strict-ink\"\n",
);
let handle = app
.world()
.resource::<AssetServer>()
.load::<BrinkStoryAsset>("project/nested/deep/story.ink");
wait_for_loaded(&mut app, &handle);
}
#[test]
fn undecodable_farther_ancestor_brink_toml_does_not_fail_the_load() {
let (mut app, dir) = make_memory_asset_app();
dir.insert_asset_text(Path::new("stories/ch1/intro.ink"), BRINK_ONLY_SOURCE);
dir.insert_asset_text(
Path::new("stories/ch1/brink.toml"),
"[project]\ndialect = \"brink\"\n",
);
dir.insert_asset(Path::new("brink.toml"), vec![0x80_u8]);
let handle = app
.world()
.resource::<AssetServer>()
.load::<BrinkStoryAsset>("stories/ch1/intro.ink");
wait_for_loaded(&mut app, &handle);
}
#[test]
fn undecodable_nearest_brink_toml_fails_the_load() {
let (mut app, dir) = make_memory_asset_app();
dir.insert_asset_text(Path::new("stories/ch1/intro.ink"), BRINK_ONLY_SOURCE);
dir.insert_asset(Path::new("stories/ch1/brink.toml"), vec![0x80_u8]);
let handle = app
.world()
.resource::<AssetServer>()
.load::<BrinkStoryAsset>("stories/ch1/intro.ink");
wait_for_failed(&mut app, &handle);
}
#[test]
fn plugin_override_wins_over_conflicting_asset() {
let (mut app, dir) = make_memory_asset_app();
dir.insert_asset_text(Path::new("intro.ink"), BRINK_ONLY_SOURCE);
dir.insert_asset_text(
Path::new("brink.toml"),
"[project]\ndialect = \"strict-ink\"\n",
);
app.register_asset_loader(InkLoader {
override_config: Some(ProjectConfig {
dialect: Some(Dialect::Brink),
types: None,
..ProjectConfig::default()
}),
});
let handle = app
.world()
.resource::<AssetServer>()
.load::<BrinkStoryAsset>("intro.ink");
wait_for_loaded(&mut app, &handle);
}
#[test]
fn e014_source_loads_by_default_with_no_lints_table() {
let (mut app, dir) = make_memory_asset_app();
dir.insert_asset_text(Path::new("intro.ink"), E014_SOURCE);
let handle = app
.world()
.resource::<AssetServer>()
.load::<BrinkStoryAsset>("intro.ink");
wait_for_loaded(&mut app, &handle);
}
#[test]
fn sibling_brink_toml_lints_deny_relevels_warning_to_failed_load() {
let (mut app, dir) = make_memory_asset_app();
dir.insert_asset_text(Path::new("intro.ink"), E014_SOURCE);
dir.insert_asset_text(Path::new("brink.toml"), "[lints]\nE014 = \"deny\"\n");
let handle = app
.world()
.resource::<AssetServer>()
.load::<BrinkStoryAsset>("intro.ink");
wait_for_failed(&mut app, &handle);
}
#[test]
fn sibling_brink_toml_deny_warnings_relevels_warning_to_failed_load() {
let (mut app, dir) = make_memory_asset_app();
dir.insert_asset_text(Path::new("intro.ink"), E014_SOURCE);
dir.insert_asset_text(Path::new("brink.toml"), "[lints]\ndeny-warnings = true\n");
let handle = app
.world()
.resource::<AssetServer>()
.load::<BrinkStoryAsset>("intro.ink");
wait_for_failed(&mut app, &handle);
}
#[test]
fn plugin_override_lints_wins_over_conflicting_asset() {
let (mut app, dir) = make_memory_asset_app();
dir.insert_asset_text(Path::new("intro.ink"), E014_SOURCE);
dir.insert_asset_text(Path::new("brink.toml"), "[lints]\nE014 = \"allow\"\n");
let mut lints = std::collections::BTreeMap::new();
lints.insert("E014".to_owned(), brink_project_config::LintLevel::Deny);
app.register_asset_loader(InkLoader {
override_config: Some(ProjectConfig {
lints,
..ProjectConfig::default()
}),
});
let handle = app
.world()
.resource::<AssetServer>()
.load::<BrinkStoryAsset>("intro.ink");
wait_for_failed(&mut app, &handle);
}
#[test]
fn plugin_override_deny_warnings_relevels_warning_to_failed_load() {
let (mut app, dir) = make_memory_asset_app();
dir.insert_asset_text(Path::new("intro.ink"), E014_SOURCE);
app.register_asset_loader(InkLoader {
override_config: Some(ProjectConfig {
deny_warnings: Some(true),
..ProjectConfig::default()
}),
});
let handle = app
.world()
.resource::<AssetServer>()
.load::<BrinkStoryAsset>("intro.ink");
wait_for_failed(&mut app, &handle);
}
struct CapturingSubscriber {
messages: std::sync::Arc<std::sync::Mutex<Vec<String>>>,
}
impl tracing::field::Visit for CapturedMessage {
fn record_debug(&mut self, field: &tracing::field::Field, value: &dyn std::fmt::Debug) {
if field.name() == "message" {
self.0 = format!("{value:?}");
}
}
}
struct CapturedMessage(String);
impl tracing::Subscriber for CapturingSubscriber {
fn enabled(&self, metadata: &tracing::Metadata<'_>) -> bool {
*metadata.level() <= tracing::Level::WARN
}
fn max_level_hint(&self) -> Option<tracing::level_filters::LevelFilter> {
Some(tracing::level_filters::LevelFilter::WARN)
}
fn new_span(&self, _span: &tracing::span::Attributes<'_>) -> tracing::span::Id {
tracing::span::Id::from_u64(1)
}
fn record(&self, _span: &tracing::span::Id, _values: &tracing::span::Record<'_>) {}
fn record_follows_from(&self, _span: &tracing::span::Id, _follows: &tracing::span::Id) {}
fn event(&self, event: &tracing::Event<'_>) {
let mut captured = CapturedMessage(String::new());
event.record(&mut captured);
self.messages.lock().unwrap().push(captured.0);
}
fn enter(&self, _span: &tracing::span::Id) {}
fn exit(&self, _span: &tracing::span::Id) {}
}
fn captured_warnings() -> std::sync::Arc<std::sync::Mutex<Vec<String>>> {
static MESSAGES: std::sync::OnceLock<std::sync::Arc<std::sync::Mutex<Vec<String>>>> =
std::sync::OnceLock::new();
let messages = MESSAGES.get_or_init(|| {
let messages = std::sync::Arc::new(std::sync::Mutex::new(Vec::new()));
let subscriber = CapturingSubscriber {
messages: std::sync::Arc::clone(&messages),
};
let _ = tracing::subscriber::set_global_default(subscriber);
messages
});
std::sync::Arc::clone(messages)
}
#[test]
fn plugin_override_unknown_and_non_overridable_lint_codes_warn_but_valid_entry_still_applies() {
let warnings = captured_warnings();
let mut lints = std::collections::BTreeMap::new();
lints.insert(
"E9999_TYPO".to_owned(),
brink_project_config::LintLevel::Deny,
);
lints.insert("E001".to_owned(), brink_project_config::LintLevel::Deny);
lints.insert("E014".to_owned(), brink_project_config::LintLevel::Deny);
let (mut app, dir) = make_memory_asset_app_with_config(ProjectConfig {
lints,
..ProjectConfig::default()
});
dir.insert_asset_text(Path::new("intro.ink"), E014_SOURCE);
let handle = app
.world()
.resource::<AssetServer>()
.load::<BrinkStoryAsset>("intro.ink");
wait_for_failed(&mut app, &handle);
let joined = warnings.lock().unwrap().join("\n");
assert!(
joined.contains("[lints] `E9999_TYPO` is not a recognized diagnostic code"),
"an unknown lint code must warn with the 'not a recognized diagnostic code' \
message (not silently drop); captured: {joined}"
);
assert!(
joined.contains("[lints] `E001` is not overridable"),
"a non-overridable lint code must warn with the 'not overridable' message \
(not silently drop); captured: {joined}"
);
}
#[test]
fn plugin_with_config_dialect_reaches_ink_loader() {
let (mut app, dir) = make_memory_asset_app_with_config(ProjectConfig {
dialect: Some(Dialect::Brink),
..ProjectConfig::default()
});
dir.insert_asset_text(Path::new("intro.ink"), BRINK_ONLY_SOURCE);
let handle = app
.world()
.resource::<AssetServer>()
.load::<BrinkStoryAsset>("intro.ink");
wait_for_loaded(&mut app, &handle);
}
#[test]
fn plugin_with_config_types_reaches_ink_loader() {
let (mut app, dir) = make_memory_asset_app_with_config(ProjectConfig {
dialect: Some(Dialect::Brink),
types: Some(TypePolicy::Gradual),
..ProjectConfig::default()
});
dir.insert_asset_text(Path::new("intro.ink"), UNTYPED_PARAM_SOURCE);
let handle = app
.world()
.resource::<AssetServer>()
.load::<BrinkStoryAsset>("intro.ink");
wait_for_loaded(&mut app, &handle);
}
#[test]
fn plugin_with_config_types_absent_leaves_strict_default_rejecting() {
let (mut app, dir) = make_memory_asset_app_with_config(ProjectConfig {
dialect: Some(Dialect::Brink),
types: None,
..ProjectConfig::default()
});
dir.insert_asset_text(Path::new("intro.ink"), UNTYPED_PARAM_SOURCE);
let handle = app
.world()
.resource::<AssetServer>()
.load::<BrinkStoryAsset>("intro.ink");
wait_for_failed(&mut app, &handle);
}
#[test]
fn plugin_with_config_deny_warnings_reaches_ink_loader() {
let (mut app, dir) = make_memory_asset_app_with_config(ProjectConfig {
deny_warnings: Some(true),
..ProjectConfig::default()
});
dir.insert_asset_text(Path::new("intro.ink"), E014_SOURCE);
let handle = app
.world()
.resource::<AssetServer>()
.load::<BrinkStoryAsset>("intro.ink");
wait_for_failed(&mut app, &handle);
}
#[test]
fn brink_plugin_with_config_delegates_to_ink_loader() {
let (mut app, dir) = make_memory_asset_app_via_brink_plugin_with_config(ProjectConfig {
dialect: Some(Dialect::Brink),
..ProjectConfig::default()
});
dir.insert_asset_text(Path::new("intro.ink"), BRINK_ONLY_SOURCE);
let handle = app
.world()
.resource::<AssetServer>()
.load::<BrinkStoryAsset>("intro.ink");
wait_for_loaded(&mut app, &handle);
}
#[test]
fn plugin_with_config_invalid_lint_code_reaches_brink_config_warnings_resource() {
let mut lints = std::collections::BTreeMap::new();
lints.insert(
"E9999_TYPO".to_owned(),
brink_project_config::LintLevel::Deny,
);
let (app, _dir) = make_memory_asset_app_with_config(ProjectConfig {
lints,
..ProjectConfig::default()
});
let warnings = app.world().resource::<crate::BrinkConfigWarnings>();
assert_eq!(warnings.0.len(), 1);
assert!(
warnings.0[0].contains("E9999_TYPO") && warnings.0[0].contains("not a recognized"),
"unexpected resource contents: {warnings:?}"
);
}
#[test]
fn plugin_with_config_non_overridable_lint_code_reaches_brink_config_warnings_resource() {
let mut lints = std::collections::BTreeMap::new();
lints.insert("E001".to_owned(), brink_project_config::LintLevel::Deny);
let (app, _dir) = make_memory_asset_app_with_config(ProjectConfig {
lints,
..ProjectConfig::default()
});
let warnings = app.world().resource::<crate::BrinkConfigWarnings>();
assert_eq!(warnings.0.len(), 1);
assert!(
warnings.0[0].contains("E001") && warnings.0[0].contains("not overridable"),
"unexpected resource contents: {warnings:?}"
);
}
#[test]
fn compile_story_inline_invalid_lint_code_warns_via_tracing() {
let captured = captured_warnings();
let mut lints = std::collections::BTreeMap::new();
lints.insert(
"E9998_INLINE_TYPO".to_owned(),
brink_project_config::LintLevel::Deny,
);
let (mut app, _dir) = make_memory_asset_app_with_config(ProjectConfig {
lints,
..ProjectConfig::default()
});
crate::compile_story_inline(&mut app, "intro.ink", "-> END\n")
.expect("E9998_INLINE_TYPO is rejected, not applied, so nothing blocks the compile");
let joined = captured.lock().unwrap().join("\n");
assert!(
joined.contains("[lints] `E9998_INLINE_TYPO` is not a recognized diagnostic code"),
"an invalid with_config lint code must warn through \
compile_story_inline's own Project::load call too, not just \
InkLoader's; captured: {joined}"
);
}
#[test]
fn second_marker_with_config_drop_is_diagnosed_not_silent() {
struct MarkerA;
struct MarkerB;
let captured = captured_warnings();
let mut app = bevy_app::App::new();
let dir = Dir::default();
let dir_clone = dir.clone();
app.register_asset_source(
AssetSourceId::Default,
AssetSourceBuilder::new(move || {
Box::new(MemoryAssetReader {
root: dir_clone.clone(),
})
}),
)
.add_plugins((
TaskPoolPlugin::default(),
AssetPlugin {
watch_for_changes_override: Some(false),
use_asset_processor_override: Some(false),
..Default::default()
},
crate::BrinkPlugin::<MarkerA>::default(),
crate::BrinkPlugin::<MarkerB>::default().with_config(ProjectConfig {
dialect: Some(Dialect::Brink),
..ProjectConfig::default()
}),
));
let warnings = app.world().resource::<crate::BrinkConfigWarnings>();
assert!(
warnings
.0
.iter()
.any(|w| w.contains("MarkerB") && w.contains("ignored")),
"a second marker's dropped `with_config` override must be recorded \
in `BrinkConfigWarnings`, not silently discarded; got: {warnings:?}"
);
let joined = captured.lock().unwrap().join("\n");
assert!(
joined.contains("MarkerB") && joined.contains("ignored"),
"the same drop must also reach the tracing::warn! channel (the \
'warn, never silently drop' rule every other mount's config \
resolution already follows); captured: {joined}"
);
dir.insert_asset_text(Path::new("intro.ink"), BRINK_ONLY_SOURCE);
let handle = app
.world()
.resource::<AssetServer>()
.load::<BrinkStoryAsset>("intro.ink");
wait_for_failed(&mut app, &handle);
}
#[test]
fn hot_reload_picks_up_edited_brink_toml() {
let (mut app, dir) = make_memory_asset_app();
dir.insert_asset_text(Path::new("intro.ink"), BRINK_ONLY_SOURCE);
dir.insert_asset_text(Path::new("brink.toml"), "[project]\ndialect = \"brink\"\n");
let handle = app
.world()
.resource::<AssetServer>()
.load::<BrinkStoryAsset>("intro.ink");
wait_for_loaded(&mut app, &handle);
dir.insert_asset_text(
Path::new("brink.toml"),
"[project]\ndialect = \"strict-ink\"\n",
);
app.world().resource::<AssetServer>().reload("intro.ink");
wait_for_failed(&mut app, &handle);
}
#[test]
fn multi_file_include_graph_loads_through_asset_server() {
let (mut app, dir) = make_memory_asset_app();
dir.insert_asset_text(
Path::new("stories/ch1/intro.ink"),
"INCLUDE ../shared/util.ink\nINCLUDE local.ink\n-> END\n",
);
dir.insert_asset_text(Path::new("stories/shared/util.ink"), "VAR shared_var = 1\n");
dir.insert_asset_text(Path::new("stories/ch1/local.ink"), "VAR local_var = 2\n");
let handle = app
.world()
.resource::<AssetServer>()
.load::<BrinkStoryAsset>("stories/ch1/intro.ink");
wait_for_loaded(&mut app, &handle);
}
#[test]
fn served_brink_toml_invalid_lint_code_warns_via_tracing() {
let captured = captured_warnings();
let (mut app, dir) = make_memory_asset_app();
dir.insert_asset_text(Path::new("intro.ink"), E014_SOURCE);
dir.insert_asset_text(
Path::new("brink.toml"),
"[lints]\nE002 = \"deny\"\nE8888_SERVED_TYPO = \"deny\"\n",
);
let handle = app
.world()
.resource::<AssetServer>()
.load::<BrinkStoryAsset>("intro.ink");
wait_for_loaded(&mut app, &handle);
let joined = captured.lock().unwrap().join("\n");
assert!(
joined.contains("[brink.toml] [lints] `E002` is not overridable"),
"a served brink.toml's rejected [lints] entry must warn with the \
full resolve_options-produced message (config-key-prefixed, not \
just the code substring) through the real AssetServer -> \
InkLoader -> Project::load path; captured: {joined}"
);
assert!(
joined.contains(
"[brink.toml] [lints] `E8888_SERVED_TYPO` is not a recognized diagnostic code"
),
"a served brink.toml's unrecognized [lints] code must also warn, \
distinguishable from the not-overridable class above; \
captured: {joined}"
);
}
#[test]
fn served_brink_toml_unknown_top_level_key_warns_via_tracing() {
let captured = captured_warnings();
let (mut app, dir) = make_memory_asset_app();
dir.insert_asset_text(Path::new("intro.ink"), BRINK_ONLY_SOURCE);
dir.insert_asset_text(
Path::new("brink.toml"),
"suprise_typo_key = true\n\n[project]\ndialect = \"brink\"\n",
);
let handle = app
.world()
.resource::<AssetServer>()
.load::<BrinkStoryAsset>("intro.ink");
wait_for_loaded(&mut app, &handle);
let joined = captured.lock().unwrap().join("\n");
assert!(
joined.contains(
"[brink.toml] unknown top-level key `suprise_typo_key` in brink.toml (ignored)"
),
"a served brink.toml's unknown top-level key must warn with the \
full resolve_options-produced message through the real \
AssetServer -> InkLoader -> Project::load path, distinguishable \
from the [lints]-rejection class; captured: {joined}"
);
}
}