//! The world API wall: every `benilla-world` item that `benilla-app` code names, counted from //! source. [`PUBLISHED`] is the designed API, [`SORTED_LEAKS`] the crossings filed to close and //! how, and anything else that crosses is an unsorted leak. Only the leak count is ratcheted, //! toward zero; a table row nothing names any more fails the test until it is deleted. //! //! Not counted: `#[cfg(test)]` bodies (a test naming an internal is not an API consumer) and //! comments. Plugin registrations in the composition root count like anything else. use std::collections::{BTreeMap, BTreeSet}; use std::path::{Path, PathBuf}; /// The engine's module set when the world lives inside `benilla-app`: an engine module missing /// here hides its leaks. Unused once `crates/benilla-world` exists, which the scan checks. const ENGINE_ROOTS: &[&str] = &[ "art_scope", "assets", "billboard", "clouds", "clutter", "collision", "debug_panel", "decal", "dev_state", "doodad_anim", "entity_shade", "exterior_cull", "ffx_glow", "instance_tint", "interact", "interior", "lighting", "liquid", "map_proj", "mesh_tag", "model_fade", "model_forms", "model_render", "modkeys", "particles", "perf", "pipe_warm", "ribbons", "rig_anim", "rig_palette", "schedule", "sky", "sky_order", "skybox", "sun", "surface", "terrain", "terrain_stream", "view", "water_fx", "wdl", "weather", "wmo_portal", "world_census", "world_plugins", "world_point", "world_unit", "world_map", "zfill", ]; /// The instruments inside the engine module set: they sit above the engine, so they are not part /// of the doorway being measured. const INSTRUMENT_ROOTS: &[&str] = &["art_scope", "debug_panel", "perf", "pipe_warm"]; /// The `benilla-app` instruments (probes, the crash hook, the dev panels) that consume /// `benilla-world` like any caller. An API shaped by what a debugger wanted to poke is not the /// designed API, so what only they name is counted separately, outside the gate. const INSTRUMENT_CONSUMERS: &[&str] = &["capture", "crash", "debug_panel", "perf", "pipe_warm"]; /// Is this file one of the app-side instruments? fn is_instrument_consumer(rel: &str) -> bool { let root = rel.split(['/', '.']).next().unwrap_or(""); INSTRUMENT_CONSUMERS.contains(&root) } /// The designed API: every engine item game code may name, with the record that published it /// (`wall`: published without one). Adding a row is a claim made in review, with its record. const PUBLISHED: &[(&str, &str)] = &[ ("bgwin::BgWinPlugin", "wall"), // The engine owns the log; the app owns where this player's state lives, so "attach the log // to this path" is the seam between them. Published rather than filed as a leak because the // composition root is the only caller there can be: `crash.rs` already reads the same ring, // and a session log that only an instrument build wrote would be missing from every player // build, which is the one place it is needed. ("log_ring::FILE_CAP_BYTES", "wall"), ("log_ring::attach_file", "wall"), ("bgwin::background_run", "record"), ("bgwin::no_pixel_run", "record"), ("billboard::BillboardCard", "record"), ("boot::tuned_default_plugins", "record"), ("build_id::BuildId", "record"), ("build_id::banner", "record"), ("collision::ColliderEpoch", "record"), ("collision::MoverTraceExclusions", "record"), ("collision::WorldCollision", "record"), ("decal::WorldDecal", "record"), ("dev_state::STILL_INPUTS_CHANGED", "record"), ("doodad_anim::AnimMatPart", "record"), ("doodad_anim::DoodadAnimHost", "record"), ("doodad_anim::MatAnim", "record"), ("doodad_anim::TintLoop", "record"), ("doodad_anim::UvLoops", "record"), ("doodad_anim::register_entity_uv", "record"), ("doodad_anim::register_fx_uv", "record"), ("doodad_anim::register_tint", "record"), ("doodad_anim::spawn_anim_host", "record"), ("ffx_glow::FfxBackdrop", "record"), ("ffx_glow::FfxGlow", "record"), ("ffx_glow::GlueFfx", "record"), ("final_pass::FinalPassTarget", "record"), ("instance_tint::InstanceTintMirrors", "record"), ("instance_tint::InstanceTints", "record"), ("interact::PickMesh", "record"), ("interact::WorldObject", "record"), ("interact::WorldPick", "record"), ("interact::cast_pick_ray", "record"), ("interior::NodeAmbient", "record"), ("lighting::LightBlob", "record"), ("lighting::WorldTime", "record"), ("lighting::WowLighting", "record"), ("mac_quit::MacQuitPlugin", "record"), ("mat_anim_table::MatAnimMirrors", "record"), ("mat_anim_table::MatAnimTable", "record"), ("mat_anim_table::affine_row", "record"), ("mesh_tag::HIGHLIGHT_BIT", "record"), ("model_fade::ModelFade", "record"), ("model_fade::ParentModel", "record"), ("model_fade::RenderFade", "record"), ("model_fade::UnitRenderAlpha", "wall"), ("model_forms::ModelForms", "record"), ("model_render::BatchVariants", "record"), ("model_render::EntityUvLane", "record"), ("model_render::M2BatchMaterials", "record"), ("model_render::ModelKind", "record"), ("model_render::ModelPart", "record"), ("model_render::lazy::realize", "record"), ("modkeys::SyntheticHold", "wall"), ("particles::ViewThrottled", "record"), ("particles::render::EFFECT_DRAW_STATS", "record"), ("particles::spawn_emitter", "record"), ("ride_frame::RideFrame", "record"), ("rig_anim::AnimParked", "wall"), ("rig_anim::GlobalSeqDrive", "wall"), ("rig_anim::PosePost", "wall"), ("rig_anim::RigAnchor", "wall"), ("rig_anim::RigFrame", "wall"), ("rig_anim::RigPose", "wall"), ("rig_palette::RigPalettes", "record"), ("rig_palette::RigSkin", "record"), ("rig_rider::RigRider", "record"), ("schedule::WorldLive", "record"), ("schedule::WorldStage", "record"), ("sky_order::Rung", "wall"), ("terrain_stream::SPAWN_XY", "wall"), ("terrain_stream::ViewFocus", "record"), ("terrain_stream::WorldLoadProgress", "wall"), // GFX (anisotropic): the registration hook of the live texture-filter policy. Published, not // filed as a leak, because its whole shape is "a material anywhere asks the engine to rebuild // its bind groups when `trilinear` or `anisotropic` moves" — the caller is necessarily outside // the engine whenever a material is, and `benilla-app`'s cutout shadow caster is one (it binds // the forward pass's own leaf handle, so it holds a governed sampler). Moving the material to // the engine would be the wrong close: it is a shadow-rig material, and it would drag the lane // with it. The alternative — the engine naming every material type in the program — is the // inversion this wall exists to stop. ("tex_filter_live::invalidate_materials", "record"), ("thread_qos::QosClass", "record"), ("thread_qos::ThreadQosPlugin", "wall"), ("thread_qos::promote_current_thread", "record"), ("view::MsaaFormats", "record"), ("view::MsaaSetting", "record"), ("view::ViewDistance", "record"), ("view::Viewer", "wall"), ("view::WorldCamera", "record"), ("vis_chain::VisChainOnly", "record"), ("weather::WeatherMessage", "record"), ("weather::WeatherState", "record"), ("wmo_portal::room_pvs_visible", "record"), ("world_census::CensusReport", "record"), ("world_census::WorldCensus", "record"), ("world_map::CurrentMap", "record"), ("world_map::MapChange", "record"), ("world_plugins::WorldPlugins", "record"), ("world_point::Subject", "record"), ("world_point::WorldPoint", "record"), ("world_unit::ViewerUnit", "wall"), ("world_unit::WorldUnit", "wall"), ("worldview::run", "record"), ]; /// The sorted leaks: crossings filed to close, with how. `CLOSE/absorb`: a facade takes the call; /// `CLOSE/invert`: the engine publishes the fact instead of the game reaching in; /// `CLOSE/move-engine`: engine code in a game file moves engine-side; `CLOSE/move-game`: gameplay /// moves out of the engine. Closing a leak is deleting its row. const SORTED_LEAKS: &[(&str, &str)] = &[ ("billboard::BillboardJointRig", "CLOSE/absorb"), ("billboard::BillboardPlace", "CLOSE/absorb"), ("billboard::billboard_basis", "CLOSE/move-engine"), ("billboard::billboard_joint_palette", "CLOSE/absorb"), ("clutter::ClutterConfig", "CLOSE/absorb"), ("collision::PickOccluder", "CLOSE/absorb"), ("decal::DecalFrame", "CLOSE/absorb"), ("doodad_anim::DoodadAnimTier", "CLOSE/absorb"), ("doodad_anim::TintAnimMaterials", "CLOSE/move-engine"), ("doodad_anim::UvAnimMaterials", "CLOSE/move-engine"), ("doodad_anim::classify", "CLOSE/absorb"), ("doodad_anim::sample_mat_anim", "CLOSE/invert"), ("doodad_anim::wants_rig", "CLOSE/move-engine"), ("entity_shade::GroundShade", "CLOSE/absorb"), ("ffx_glow::FfxDeathFade", "CLOSE/absorb"), ("instance_tint::IDENTITY", "CLOSE/absorb"), ("instance_tint::pack", "CLOSE/absorb"), ("interact::PickBox", "CLOSE/absorb"), ("interact::WorldClick", "CLOSE/move-game"), ("interact::WorldRightClick", "CLOSE/move-game"), ("interact::WorldRightPress", "CLOSE/move-game"), ("interact::cast_pick_ray_inflated", "CLOSE/absorb"), ("interior::BodyBakeCenter", "CLOSE/absorb"), ("interior::ContainmentAttach", "CLOSE/absorb"), ("interior::InteriorLit", "CLOSE/invert"), ("interior::InteriorReauthor", "CLOSE/invert"), ("interior::classify_entity_interior", "CLOSE/absorb"), ("interior::part_interior_lit", "CLOSE/absorb"), ("lighting::GameClock", "CLOSE/absorb"), ("lighting::PropProbeSlot", "CLOSE/move-engine"), ("lighting::PropProbes", "CLOSE/move-engine"), ("liquid::WaterChunkInfo", "CLOSE/absorb"), ("map_proj::WorldProj", "CLOSE/move-game"), ("map_proj::ZoneRect", "CLOSE/move-game"), ("mesh_tag::describe", "CLOSE/absorb"), ("mesh_tag::with_alpha", "CLOSE/invert"), ("model_fade::DespawnFade", "CLOSE/move-game"), ("model_fade::FadeMaterials", "CLOSE/absorb"), ("model_fade::FadeSet", "CLOSE/absorb"), ("model_fade::JoinedFade", "CLOSE/absorb"), ("model_fade::MAX_MODEL_CHAIN", "CLOSE/invert"), ("model_fade::PartFade", "CLOSE/absorb"), ("model_fade::PendingAppearFade", "CLOSE/invert"), ("model_fade::UnitAppearFade", "CLOSE/move-game"), ("model_fade::apply_render_fade", "CLOSE/absorb"), ("model_fade::fade_alpha", "CLOSE/absorb"), ("model_fade::join_unit_appear_fade", "CLOSE/absorb"), ("model_render::FarSideOfWater", "CLOSE/invert"), ("model_render::FarSideTwins", "CLOSE/invert"), ("model_render::ModelVisSet", "CLOSE/absorb"), ("model_render::ShadeSel", "CLOSE/absorb"), ("model_render::far_resolved", "CLOSE/invert"), ("model_render::replace_fog_policy", "CLOSE/absorb"), ("modkeys::DEV_CHORD", "CLOSE/move-engine"), ("modkeys::dev_chord", "CLOSE/move-engine"), ("particles::EmitClock", "CLOSE/absorb"), ("particles::EmitterFade", "CLOSE/invert"), ("particles::EmitterFrames", "CLOSE/absorb"), ("particles::OwnerLoss", "CLOSE/absorb"), ("particles::ParticleEmitter", "CLOSE/invert"), ("particles::buffer::EffectLightOverride", "CLOSE/invert"), ("particles::buffer::EffectQuads", "CLOSE/invert"), ("particles::buffer::EffectVertex", "CLOSE/absorb"), ("particles::buffer::WorldEffectDraw", "CLOSE/absorb"), ("particles::buffer::begin_effect_frame", "CLOSE/absorb"), ("ribbons::RibbonSeq", "CLOSE/absorb"), ("ribbons::RibbonTrail", "CLOSE/invert"), ("ribbons::spawn_ribbon", "CLOSE/absorb"), ("rig_palette::RigPaletteMirrors", "CLOSE/absorb"), ("rig_palette::RigPart", "CLOSE/absorb"), ("rig_palette::RigStarved", "CLOSE/invert"), ("rig_palette::rig_cost_enabled", "CLOSE/absorb"), ("terrain_stream::AreaAuthoritySet", "CLOSE/absorb"), ("terrain_stream::CurrentArea", "CLOSE/absorb"), ("terrain_stream::PendingCollider", "CLOSE/move-engine"), ("terrain_stream::PropLobeLight", "CLOSE/move-engine"), ("terrain_stream::ShadeResolve", "CLOSE/move-engine"), ("terrain_stream::SpawnedModel", "CLOSE/move-engine"), ("terrain_stream::TerrainStreamer", "CLOSE/absorb"), ("terrain_stream::build_collider_task", "CLOSE/move-engine"), ("terrain_stream::doodad_ground_shade", "CLOSE/move-engine"), ("terrain_stream::fold_interior_probe", "CLOSE/move-engine"), ("terrain_stream::hex_word", "CLOSE/move-engine"), ("terrain_stream::m2_anim_bound", "CLOSE/move-engine"), ("terrain_stream::m2_fade", "CLOSE/move-engine"), ( "terrain_stream::placement_collider_data", "CLOSE/move-engine", ), ("terrain_stream::point_light", "CLOSE/absorb"), ("terrain_stream::spawn_model_entities", "CLOSE/move-engine"), ("view::CAM_FOVY", "CLOSE/absorb"), ("view::FARCLIP_RANGE", "CLOSE/absorb"), ("view::NEARCLIP_DEFAULT", "CLOSE/absorb"), ("wmo_portal::UnitWmoRoom", "CLOSE/absorb"), ("wmo_portal::WmoPortalInstance", "CLOSE/absorb"), ("wmo_portal::WmoPvsSet", "CLOSE/absorb"), ]; /// The gate on the leak count. Lower it when a leak closes. /// /// **Re-baselined 2026-10-18, from 107 to the 174 that were really there.** The ratchet's whole /// job is to fail the *next* crossing, and a ceiling 67 below the count cannot: the test had been /// red for long enough that nothing could tell a fresh leak from the standing debt. 174 is a /// recorded debt, not an achievement, and [`LEAK_SLACK`] still drags it down as closes land. /// /// Where the debt lives, measured rather than guessed — `WOW_API_DUMP=1` now prints the per-file /// inversion beside the per-item list, because that is the view a close is planned from. The /// crossings are not spread over the game; the three heaviest files carry a third of them, and /// the second column is what a move would close **outright**, the items nothing else names: /// `entities/wmo_props.rs` 27 (16 alone), `torch_shadow.rs` 23 (8), `entities/carried_light.rs` /// 20 (6), `entities/equipment/spawn.rs` 16 (0), `entities/attach/mod.rs` 14 (4), /// `player/camera.rs` 13 (2), `aura_visual.rs` 12 (2), `entities.rs` 12 (2). /// /// Those are renderer and model-spawn files sitting on the game side of the line, which is why /// they name so much of the engine, so the disposition for most of this debt is `move-engine`, /// one cluster at a time, in the round that is already in that file. Each is real work, not a /// file move: `carried_light.rs` also reaches into `entities::spell_fx`, and `torch_shadow.rs` /// into `video` and `char_select`, so a cluster closes only once its gameplay edge is cut. What /// would make this test lie is the other move — publishing engine internals as API to turn the /// number green. const LEAK_CEILING: usize = 174; /// How far under [`LEAK_CEILING`] the count may sit before the test asks for the ceiling to be /// lowered: one closure does not fail the gate, a whole stage of work cannot go unrecorded. const LEAK_SLACK: usize = 10; #[test] fn the_world_api_doorway_stays_shut() { let all = measure(); // An item only an instrument names is outside the doorway (`INSTRUMENT_CONSUMERS`); one a // game module also names is inside it. let (probes, surface): (BTreeMap<_, _>, BTreeMap<_, _>) = all .into_iter() .partition(|(_, files)| files.iter().all(|f| is_instrument_consumer(f))); // The doorway is the published API plus the leaks; only the leaks gate. let published_by: BTreeMap<&str, &str> = PUBLISHED.iter().copied().collect(); let sorted_by: BTreeMap<&str, &str> = SORTED_LEAKS.iter().copied().collect(); let both: Vec<&str> = published_by .keys() .filter(|k| sorted_by.contains_key(*k)) .copied() .collect(); assert!( both.is_empty(), "an item cannot be both published and a sorted leak: {both:?}" ); let (published, leaks): (BTreeMap<_, _>, BTreeMap<_, _>) = surface .into_iter() .partition(|(k, _)| published_by.contains_key(k.as_str())); let n = leaks.len(); let sorted = leaks .keys() .filter(|k| sorted_by.contains_key(k.as_str())) .count(); // Always print the numbers (`--nocapture` shows them). eprintln!( "world API surface: {} items — {n} leaks (ceiling {LEAK_CEILING}, slack {LEAK_SLACK}; \ {sorted} sorted, {} unsorted), {} published; + {} named only by the instruments", published.len() + n, n - sorted, published.len(), probes.len() ); // A row no game file names any more must go: a stale published row would let the item cross // again for free. let stale: Vec<&str> = PUBLISHED .iter() .map(|(k, _)| *k) .filter(|k| !published.contains_key(*k) && !probes.contains_key(*k)) .chain( SORTED_LEAKS .iter() .map(|(k, _)| *k) .filter(|k| !leaks.contains_key(*k) && !probes.contains_key(*k)), ) .collect(); assert!( stale.is_empty(), "rows in PUBLISHED / SORTED_LEAKS that no game file names any more — delete them (a \ closed leak is a deleted row; a published item nobody uses is not API):\n {}", stale.join("\n ") ); // `WOW_API_DUMP=1` prints the surface itself, most-named first, each item with its table tag. if std::env::var("WOW_API_DUMP").is_ok() { let tagged = |m: &BTreeMap>, tag: &dyn Fn(&str) -> String| { let mut rows: Vec<_> = m .iter() .map(|(k, v)| (v.len(), format!("{k} [{}]", tag(k)))) .collect(); rows.sort_by(|a, b| b.0.cmp(&a.0).then(a.1.cmp(&b.1))); rows.iter() .map(|(n, k)| format!(" {n:3} file(s) {k}")) .collect::>() .join("\n") }; // The per-file inversion: which game file names the most engine internals, and how many // of those it is the only namer of — the two numbers that say where a `move-engine` // closes a batch rather than one row. let mut by_file: BTreeMap<&str, (usize, usize)> = BTreeMap::new(); for files in leaks.values() { for f in files { by_file.entry(f.as_str()).or_default().0 += 1; if files.len() == 1 { by_file.entry(f.as_str()).or_default().1 += 1; } } } let mut ranked: Vec<_> = by_file.into_iter().collect(); ranked.sort_by(|a, b| b.1 .0.cmp(&a.1 .0).then(a.0.cmp(b.0))); let per_file = ranked .iter() .map(|(f, (all, only))| format!(" {all:3} crossing(s), {only:3} of them alone {f}")) .collect::>() .join("\n"); eprintln!( "--- leaks ---\n{}\n--- published ---\n{}\n--- named only by the instruments ---\n{}\n\ --- the leaks by game file: where a move-engine closes a batch ---\n{per_file}", tagged(&leaks, &|k| sorted_by .get(k) .map_or_else(|| "unsorted".to_string(), |b| b.to_string())), tagged(&published, &|k| published_by[k].to_string()), tagged(&probes, &|k| published_by.get(k).map_or_else( || "instrument".to_string(), |b| format!("published {b}") )), ); } if n > LEAK_CEILING { let fresh: Vec<_> = leaks .iter() .filter(|(k, _)| !sorted_by.contains_key(k.as_str())) .map(|(k, v)| (v.len(), k)) .collect(); panic!( "world API leaks: {n}, ceiling {LEAK_CEILING}.\n\ Something new crossed the line and no record publishes it. Either close it, or — if \ it is genuine engine API — add it to PUBLISHED with the record that says so, \ a claim made in review. The unsorted leaks on this tree:\n\n{}", render(&fresh) ); } assert!( n + LEAK_SLACK >= LEAK_CEILING, "world API leaks are down to {n} and the ceiling still says {LEAK_CEILING}. Lower \ LEAK_CEILING to {n} in this file so the next leak has to earn its place — the ratchet \ only holds if the number follows the work down." ); } /// The wall that points the other way: an engine file naming a gameplay item is a dependency /// `benilla-world` cannot express. Zero, and the crate graph keeps it there. #[test] fn the_engine_names_nothing_of_the_game() { let surface = measure_back(); let n = surface.len(); eprintln!("engine→game surface: {n} items (target 0 — CLOSED)"); assert!( n == 0, "the engine names {n} gameplay items, and this wall is CLOSED — it reached zero and must \ stay there.\n\ An engine file reaching into the game is the dependency `benilla-world` cannot have: not \ a lint, a compile error waiting for the crate to exist. Invert it (the engine publishes \ the fact, the game reads it), move the caller, or move the thing named.\n\n{}", render( &surface .iter() .map(|(k, v)| (v.len(), k)) .collect::>() ) ); } /// Every distinct engine item named by non-engine code, and the files that name it. fn measure() -> BTreeMap> { let app = PathBuf::from(env!("CARGO_MANIFEST_DIR")); let split = app .parent() .is_some_and(|c| c.join("benilla-world").is_dir()); let mut out: BTreeMap> = BTreeMap::new(); let src = app.join("src"); let tests = test_module_files(&src); for file in rs_files(&src) { let rel = file .strip_prefix(&src) .unwrap() .to_string_lossy() .to_string(); // Without the world crate the file's module decides its side; with it, every file here // is on the game side. if (!split && is_engine(&rel)) || tests.contains(&rel) { continue; } let text = std::fs::read_to_string(&file).unwrap(); for (path, _) in paths_in(&text, split, true) { out.entry(path).or_default().insert(rel.clone()); } } drop_module_paths(out) } /// Drop every entry that is only the path to another entry: `particles::buffer` and /// `mesh_tag::spawn_tag` look alike, but a module path is a strict prefix of another named item /// and a function is a prefix of nothing. fn drop_module_paths( all: BTreeMap>, ) -> BTreeMap> { let keys: Vec = all.keys().cloned().collect(); all.into_iter() .filter(|(k, _)| { let prefix = format!("{k}::"); !keys.iter().any(|other| other.starts_with(&prefix)) }) .collect() } /// Every gameplay item named by an engine file. Empty once the world crate exists, where such a /// name is a compile error. fn measure_back() -> BTreeMap> { let app = PathBuf::from(env!("CARGO_MANIFEST_DIR")); if app .parent() .is_some_and(|c| c.join("benilla-world").is_dir()) { return BTreeMap::new(); // the crate graph enforces it } let mut out: BTreeMap> = BTreeMap::new(); let src = app.join("src"); let tests = test_module_files(&src); for file in rs_files(&src) { let rel = file .strip_prefix(&src) .unwrap() .to_string_lossy() .to_string(); if tests.contains(&rel) { continue; } // Instruments may see both sides: they sit above the engine. let root = rel.split(['/', '.']).next().unwrap_or(""); if !is_engine(&rel) || INSTRUMENT_ROOTS.contains(&root) { continue; } let text = std::fs::read_to_string(&file).unwrap(); for (path, _) in paths_in(&text, false, false) { out.entry(path).or_default().insert(rel.clone()); } } out } /// Every source file that is a test module in its own file: declared `#[cfg(test)] mod x;`, /// with its body in `x.rs` or `x/mod.rs` beside its parent. Its names do not count. fn test_module_files(src: &Path) -> BTreeSet { let mut out = BTreeSet::new(); for file in rs_files(src) { let text = std::fs::read_to_string(&file).unwrap(); // `a.rs`'s `mod b;` is `a/b.rs`; so is `a/mod.rs`'s. let dir = match file.file_name().and_then(|n| n.to_str()) { Some("mod.rs") | Some("lib.rs") | Some("main.rs") => { file.parent().unwrap().to_path_buf() } _ => file.with_extension(""), }; let mut armed = false; for line in text.lines() { let t = line.trim(); if t == "#[cfg(test)]" { armed = true; continue; } if armed { if let Some(name) = t .strip_suffix(';') .and_then(|d| d.rsplit_once("mod ")) .map(|(_, n)| n) { for cand in [ dir.join(format!("{name}.rs")), dir.join(name).join("mod.rs"), ] { if cand.is_file() { out.insert( cand.strip_prefix(src) .unwrap() .to_string_lossy() .to_string(), ); } } } armed = false; } } } out } fn is_engine(rel: &str) -> bool { let root = rel.split(['/', '.']).next().unwrap_or(""); ENGINE_ROOTS.contains(&root) } /// Walk a source file and yield `(canonical item, line)` for every engine path it names in code. /// It follows `crate::root::` (or `benilla_world::` with the crate), expands a `use` brace group /// into its items, and stops a path at its first capitalised segment (`WorldAssets::get` is one). fn paths_in(text: &str, split: bool, want_engine: bool) -> Vec<(String, usize)> { let prefix = if split { "benilla_world::" } else { "crate::" }; let mut found = Vec::new(); let mut depth: i32 = 0; // A `#[cfg(test)]` guards the item after it. `test_at` arms at the attribute's depth `d`: a // braced item ends when the depth comes back to `d` (it never drops below), an unbraced one // (`mod tests;`, `use …;`) at its semicolon. let mut test_at: Option = None; let mut test_open = false; for (n, line) in logical_lines(text).iter().map(|(n, l)| (*n, l.as_str())) { let t = line.trim_start(); if t.starts_with("#[cfg(test)]") { test_at = Some(depth); test_open = false; } let skip = test_at.is_some() || t.starts_with("//") || t.starts_with('*'); if !skip { let mut rest = line; while let Some(i) = rest.find(prefix) { // A bare `xcrate::` is not our prefix; require a non-ident char before it. let boundary = i == 0 || !is_ident(rest.as_bytes()[i - 1] as char); let tail = &rest[i + prefix.len()..]; if boundary { // A crate-root item (`crate::SPAWN_XY`) belongs to the game, since `lib.rs` // stays in `benilla-app`; an engine file naming one is a reverse crossing. let root_item = !split && ident(tail).is_some_and(|(r, a)| { r.starts_with(char::is_uppercase) && !a.starts_with("::") }); if root_item && !want_engine { if let Some((r, _)) = ident(tail) { found.push((format!("crate::{r}"), n + 1)); } rest = &rest[i + prefix.len()..]; continue; } let (root, after) = if split { (String::new(), tail) } else { match ident(tail) { Some((r, a)) if a.starts_with("::") && !r.starts_with(char::is_uppercase) => { (r, &a[2..]) } _ => (String::new(), ""), } }; // An instrument is neither side: outside the doorway, and not a reverse // dependency either, since `WorldPlugins` registers it. let instrument = !split && INSTRUMENT_ROOTS.contains(&root.as_str()); let engine = split || (!instrument && ENGINE_ROOTS.contains(&root.as_str())); let keep = if want_engine { engine } else { !engine && !instrument }; if keep && !after.is_empty() { for tail in expand(after) { let full = if root.is_empty() { tail } else { format!("{root}::{tail}") }; let item = canon(&full); // A bare module name is not an item; `drop_module_paths` misses one // whose members are named only where the scan does not look. let bare_module = !item.contains("::") && !item.starts_with(char::is_uppercase); if !bare_module { found.push((item, n + 1)); } } } } rest = &rest[i + prefix.len()..]; } } depth += line.matches('{').count() as i32 - line.matches('}').count() as i32; if let Some(d) = test_at { if depth > d { test_open = true; } else if test_open || t.trim_end().ends_with(';') { test_at = None; test_open = false; } } } found } /// The file's lines, with a `use` brace group that spans several lines joined into one, since the /// scan is line-based. A joined line keeps its first line's number; a `//` inside it is cut (a /// `use` has no string literal for one to sit in). fn logical_lines(text: &str) -> Vec<(usize, String)> { fn open(s: &str) -> i32 { s.matches('{').count() as i32 - s.matches('}').count() as i32 } fn strip_comment(s: &str) -> &str { s.find("//").map_or(s, |i| &s[..i]) } let mut out = Vec::new(); let mut lines = text.lines().enumerate(); while let Some((n, line)) = lines.next() { let t = line.trim_start(); let is_use = t.starts_with("use ") || (t.starts_with("pub") && t.contains(" use ") && !t.contains('=')); if is_use && open(strip_comment(line)) > 0 { let mut joined = strip_comment(line).trim_end().to_string(); let mut depth = open(&joined); for (_, next) in lines.by_ref() { let next = strip_comment(next); joined.push(' '); joined.push_str(next.trim()); depth += open(next); if depth <= 0 { break; } } out.push((n, joined)); } else { out.push((n, line.to_string())); } } out } fn is_ident(c: char) -> bool { c.is_alphanumeric() || c == '_' } /// The leading identifier of `s`, and what follows it. fn ident(s: &str) -> Option<(String, &str)> { let end = s.find(|c: char| !is_ident(c)).unwrap_or(s.len()); (end > 0).then(|| (s[..end].to_string(), &s[end..])) } /// The dotted tails a path fragment names: one, or every leaf of a `use` brace group. fn expand(s: &str) -> Vec { let s = s.trim_start(); if !s.starts_with('{') { let mut out = String::new(); let mut rest = s; while let Some((seg, after)) = ident(rest) { if !out.is_empty() { out.push_str("::"); } out.push_str(&seg); if !after.starts_with("::") { break; } let tail = &after[2..]; // `interact::{WorldClick, WorldRightClick}`: a group hanging off a walked path. if tail.trim_start().starts_with('{') { let head = out; return expand(tail) .into_iter() .map(|leaf| format!("{head}::{leaf}")) .collect(); } rest = tail; } return if out.is_empty() { vec![] } else { vec![out] }; } let mut depth = 0usize; let mut end = s.len(); for (i, c) in s.char_indices() { match c { '{' => depth += 1, '}' => { depth -= 1; if depth == 0 { end = i; break; } } _ => {} } } let mut out = Vec::new(); let mut depth = 0usize; for part in split_top(&s[1..end], &mut depth) { let part = part.trim(); let part = part.split(" as ").next().unwrap_or(part).trim(); if part.is_empty() || part == "self" { continue; } match part.find('{') { Some(b) => { let head = part[..b].trim_end_matches(':'); for leaf in expand(&part[b..]) { out.push(format!("{head}::{leaf}")); } } None => out.extend(expand(part)), } } out } /// Split on commas that are not inside a nested brace group. fn split_top<'a>(s: &'a str, depth: &mut usize) -> Vec<&'a str> { let (mut out, mut start) = (Vec::new(), 0); for (i, c) in s.char_indices() { match c { '{' => *depth += 1, '}' => *depth = depth.saturating_sub(1), ',' if *depth == 0 => { out.push(&s[start..i]); start = i + 1; } _ => {} } } out.push(&s[start..]); out } /// A path folded to the symbol it names: everything up to and including the first capitalised /// segment (a type), or the whole path when every segment is lowercase (a function). fn canon(path: &str) -> String { let mut out: Vec<&str> = Vec::new(); for seg in path.split("::") { out.push(seg); if seg.starts_with(char::is_uppercase) { break; } } out.join("::") } fn rs_files(dir: &Path) -> Vec { let mut out = Vec::new(); let mut stack = vec![dir.to_path_buf()]; while let Some(d) = stack.pop() { for e in std::fs::read_dir(&d).into_iter().flatten().flatten() { let p = e.path(); if p.is_dir() { stack.push(p); } else if p.extension().is_some_and(|x| x == "rs") { out.push(p); } } } out.sort(); out } fn render(items: &[(usize, &String)]) -> String { let mut v = items.to_vec(); v.sort_by(|a, b| b.0.cmp(&a.0).then(a.1.cmp(b.1))); v.iter() .map(|(n, k)| format!(" {n:3} file(s) {k}")) .collect::>() .join("\n") } /// A `use` group that spans lines counts every member; the comment inside it is cut, and the /// closing `};` ends the statement. #[test] fn a_use_group_spanning_lines_counts_every_member() { let text = "use benilla_world::interact::{\n PickParts,\n ray_mesh_bounds, // a lead\n \ ray_posed_mesh,\n};\nfn f() {}\n"; let mut found: Vec = paths_in(text, true, true) .into_iter() .map(|(p, _)| p) .collect(); found.sort(); assert_eq!( found, [ "interact::PickParts", "interact::ray_mesh_bounds", "interact::ray_posed_mesh" ] ); }