use std::borrow::Cow;
use std::collections::BTreeMap;
use std::path::{Path, PathBuf};
use brink_analyzer::{ModuleMap, ResolvedModule};
use brink_ir::{Diagnostic, DiagnosticCode, FileId, RESERVED_ROOTS, STORY_ROOT};
use rowan::TextRange;
use crate::include_graph::IncludeGraph;
pub(crate) struct FileModuleInput {
pub file: FileId,
pub stem: String,
pub declared: Option<String>,
pub was: Option<String>,
}
pub(crate) fn file_stem(path: &str) -> &str {
let name = path.rsplit(['/', '\\']).next().unwrap_or(path);
name.strip_suffix(".ink").unwrap_or(name)
}
pub(crate) fn root_relative_key<'a>(root: Option<&str>, path: &'a str) -> Cow<'a, str> {
let Some(root) = root.filter(|r| !r.is_empty()) else {
return Cow::Borrowed(path);
};
let root_abs = std::path::absolute(Path::new(root)).unwrap_or_else(|_| PathBuf::from(root));
let path_abs = std::path::absolute(Path::new(path)).unwrap_or_else(|_| PathBuf::from(path));
match path_abs.strip_prefix(&root_abs) {
Ok(rel) => Cow::Owned(rel.to_string_lossy().into_owned()),
Err(_) => Cow::Borrowed(path),
}
}
pub(crate) fn conventions_pointer_key<'a>(root: Option<&str>, pointer: &'a str) -> Cow<'a, str> {
if Path::new(pointer).is_absolute() {
root_relative_key(root, pointer)
} else {
Cow::Borrowed(pointer)
}
}
pub(crate) fn native_module_path(relative_path: &str) -> String {
native_module_path_in(RESERVED_ROOTS, relative_path)
}
fn native_module_path_in(roots: &[&str], relative_path: &str) -> String {
let without_ext = relative_path
.strip_suffix(".brink")
.unwrap_or(relative_path);
let mut segments = without_ext
.split(['/', '\\'])
.filter(|segment| !segment.is_empty() && *segment != ".");
let Some(first) = segments.next() else {
return String::from(STORY_ROOT);
};
let mut out = if roots.contains(&first) {
first.to_string()
} else {
format!("{STORY_ROOT}::{first}")
};
for segment in segments {
out.push_str("::");
out.push_str(segment);
}
out
}
pub(crate) fn resolve_modules(
inputs: &[FileModuleInput],
graph: &IncludeGraph,
) -> (ModuleMap, Vec<Diagnostic>) {
let mut resolved: BTreeMap<FileId, ResolvedModule> = BTreeMap::new();
for input in inputs {
let module = match &input.declared {
Some(name) => ResolvedModule {
name: name.clone(),
declared: true,
was: input.was.clone(),
},
None => ResolvedModule {
name: input.stem.clone(),
declared: false,
was: None,
},
};
resolved.insert(input.file, module);
}
for _ in 0..inputs.len() {
let mut changed = false;
for input in inputs {
if input.declared.is_some() {
continue; }
if resolved.get(&input.file).is_some_and(|m| m.declared) {
continue; }
let mut parents: Vec<FileId> = graph.included_by(input.file).to_vec();
parents.sort_unstable();
for parent in parents {
if let Some(parent_module) = resolved.get(&parent)
&& parent_module.declared
{
let inherited = parent_module.clone();
resolved.insert(input.file, inherited);
changed = true;
break;
}
}
}
if !changed {
break;
}
}
let mut was_by_name: BTreeMap<String, String> = BTreeMap::new();
for module in resolved.values() {
if module.declared
&& let Some(was) = &module.was
{
was_by_name
.entry(module.name.clone())
.or_insert_with(|| was.clone());
}
}
for module in resolved.values_mut() {
if module.declared
&& module.was.is_none()
&& let Some(was) = was_by_name.get(&module.name)
{
module.was = Some(was.clone());
}
}
let declared_names: std::collections::BTreeSet<&str> = resolved
.values()
.filter(|m| m.declared)
.map(|m| m.name.as_str())
.collect();
let mut diagnostics = Vec::new();
for input in inputs {
let module = &resolved[&input.file];
if !module.declared && declared_names.contains(module.name.as_str()) {
diagnostics.push(Diagnostic {
file: input.file,
range: TextRange::default(),
message: format!("{}: `{}`", DiagnosticCode::E085.title(), module.name),
code: DiagnosticCode::E085,
});
}
}
(resolved, diagnostics)
}
#[cfg(test)]
mod tests {
use super::*;
use std::sync::{Mutex, MutexGuard, OnceLock};
fn cwd_lock() -> MutexGuard<'static, ()> {
static LOCK: OnceLock<Mutex<()>> = OnceLock::new();
LOCK.get_or_init(|| Mutex::new(()))
.lock()
.unwrap_or_else(std::sync::PoisonError::into_inner)
}
struct RestoreCwd(PathBuf);
impl Drop for RestoreCwd {
fn drop(&mut self) {
let _ = std::env::set_current_dir(&self.0);
}
}
#[test]
fn native_module_path_derives_purely_from_relative_path() {
assert_eq!(native_module_path("barter.brink"), "story::barter");
assert_eq!(
native_module_path("market/barter.brink"),
"story::market::barter"
);
assert_eq!(
native_module_path("npcs/quests/intro.brink"),
"story::npcs::quests::intro"
);
assert_eq!(
native_module_path("market\\barter.brink"),
"story::market::barter"
);
assert_eq!(native_module_path("./main.brink"), "story::main");
}
#[test]
fn native_module_path_roots_a_std_mounted_key_as_a_peer_of_story() {
assert_eq!(
native_module_path("std/conventions/screenplay.brink"),
"std::conventions::screenplay"
);
assert_eq!(native_module_path("std.brink"), "std");
assert_eq!(
native_module_path("std\\conventions\\screenplay.brink"),
"std::conventions::screenplay"
);
assert_eq!(
native_module_path("stdlib/helpers.brink"),
"story::stdlib::helpers"
);
}
#[test]
fn native_module_path_roots_every_reserved_root_as_a_peer_of_story() {
for root in RESERVED_ROOTS {
assert_eq!(
native_module_path(&format!("{root}/leaf.brink")),
format!("{root}::leaf"),
"reserved root `{root}` must mint as its own peer root, not under story::"
);
}
}
#[test]
fn native_module_path_in_generalizes_to_a_second_reserved_root() {
let roots: &[&str] = &["std", "gizmo"];
assert_eq!(
native_module_path_in(roots, "gizmo/leaf.brink"),
"gizmo::leaf"
);
assert_eq!(
native_module_path_in(roots, "market/barter.brink"),
"story::market::barter"
);
}
#[test]
fn root_relative_key_is_identity_without_a_root() {
assert_eq!(
root_relative_key(None, "market/barter.brink"),
"market/barter.brink"
);
assert_eq!(
root_relative_key(Some(""), "market/barter.brink"),
"market/barter.brink"
);
}
#[test]
fn root_relative_key_strips_a_declared_root() {
let root = "/home/dev/game";
assert_eq!(
root_relative_key(Some(root), "/home/dev/game/market/barter.brink"),
"market/barter.brink"
);
assert_eq!(
native_module_path(&root_relative_key(
Some(root),
"/home/dev/game/market/barter.brink"
)),
native_module_path("market/barter.brink"),
"the whole point: absolute-keyed identity must equal compile identity"
);
assert_eq!(
root_relative_key(Some("/home/dev/game/"), "/home/dev/game/main.brink"),
"main.brink"
);
}
#[test]
fn root_relative_key_absolutizes_a_relative_path_against_an_absolute_root() {
let _cwd_guard = cwd_lock();
let cwd = std::env::current_dir().expect("process must have a cwd");
let root = cwd.to_string_lossy().into_owned();
assert_eq!(root_relative_key(Some(&root), "main.ink"), "main.ink");
assert_eq!(root_relative_key(Some(&root), "./main.ink"), "main.ink");
assert_eq!(
root_relative_key(Some(&root), "sub/main.ink"),
"sub/main.ink"
);
}
#[test]
fn conventions_pointer_key_passes_a_relative_pointer_through_and_strips_an_absolute_one() {
assert_eq!(
conventions_pointer_key(Some("/home/dev/game"), "conventions.brink"),
"conventions.brink"
);
assert_eq!(
conventions_pointer_key(Some("/home/dev/game"), "std/conventions.brink"),
"std/conventions.brink"
);
assert_eq!(
conventions_pointer_key(None, "conventions.brink"),
"conventions.brink"
);
assert_eq!(
conventions_pointer_key(Some("/home/dev/game"), "/home/dev/game/conventions.brink"),
"conventions.brink"
);
assert_eq!(
conventions_pointer_key(Some("/home/dev/game"), "/elsewhere/conventions.brink"),
"/elsewhere/conventions.brink"
);
}
#[test]
fn conventions_pointer_key_ignores_the_process_cwd() {
let _cwd_guard = cwd_lock();
let original_cwd = std::env::current_dir().expect("process must have a cwd");
let unique = format!(
"{}-{}",
std::process::id(),
std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.map(|d| d.as_nanos())
.unwrap_or_default()
);
let tmp_root = std::env::temp_dir().join(format!("brink-issue-2320-{unique}"));
let tmp_cwd = tmp_root.join("scenes");
std::fs::create_dir_all(&tmp_cwd).expect("create the nested decoy cwd dir");
let restore = RestoreCwd(original_cwd);
std::env::set_current_dir(&tmp_cwd).expect("chdir into the nested decoy cwd");
let root = tmp_root.to_string_lossy().into_owned();
let resolved = conventions_pointer_key(Some(&root), "conventions.brink");
assert_eq!(
resolved, "conventions.brink",
"a relative pointer must resolve against the declared root \
({tmp_root:?}), not the process cwd ({tmp_cwd:?}) — got {resolved:?} \
(the pre-fix bug produces \"scenes/conventions.brink\" here)"
);
let resolved_nested = conventions_pointer_key(Some(&root), "std/conventions.brink");
assert_eq!(resolved_nested, "std/conventions.brink");
std::env::set_current_dir(&restore.0).expect("restore real cwd");
let _ = std::fs::remove_dir_all(&tmp_root);
}
#[test]
fn root_relative_key_leaves_paths_outside_the_root_alone() {
assert_eq!(
root_relative_key(Some("/home/dev/game"), "/elsewhere/stray.brink"),
"/elsewhere/stray.brink"
);
assert_eq!(
root_relative_key(Some("/home/dev/game"), "/home/dev/game-assets/a.brink"),
"/home/dev/game-assets/a.brink"
);
}
fn input(file: u32, stem: &str, declared: Option<&str>) -> FileModuleInput {
FileModuleInput {
file: FileId(file),
stem: stem.to_string(),
declared: declared.map(str::to_string),
was: None,
}
}
#[test]
fn stem_helper_strips_dir_and_ext() {
assert_eq!(file_stem("src/quest_3.ink"), "quest_3");
assert_eq!(file_stem("story.ink"), "story");
assert_eq!(file_stem("a/b/c.ink"), "c");
assert_eq!(file_stem("noext"), "noext");
}
#[test]
fn undeclared_file_is_stem_module_not_qualifying() {
let inputs = vec![input(0, "story", None)];
let (map, diags) = resolve_modules(&inputs, &IncludeGraph::new());
assert!(diags.is_empty());
let m = &map[&FileId(0)];
assert_eq!(m.name, "story");
assert!(!m.declared);
}
#[test]
fn declared_module_qualifies() {
let inputs = vec![input(0, "story", Some("quest"))];
let (map, diags) = resolve_modules(&inputs, &IncludeGraph::new());
assert!(diags.is_empty());
let m = &map[&FileId(0)];
assert_eq!(m.name, "quest");
assert!(m.declared);
}
#[test]
fn included_file_inherits_declared_module() {
let inputs = vec![input(0, "head", Some("quest")), input(1, "part", None)];
let mut graph = IncludeGraph::new();
graph.update(FileId(0), vec![FileId(1)]);
let (map, diags) = resolve_modules(&inputs, &graph);
assert!(diags.is_empty());
let m = &map[&FileId(1)];
assert_eq!(m.name, "quest", "included file inherits includer's module");
assert!(m.declared);
}
#[test]
fn inheritance_propagates_down_a_chain() {
let inputs = vec![
input(0, "head", Some("quest")),
input(1, "mid", None),
input(2, "leaf", None),
];
let mut graph = IncludeGraph::new();
graph.update(FileId(0), vec![FileId(1)]);
graph.update(FileId(1), vec![FileId(2)]);
let (map, _diags) = resolve_modules(&inputs, &graph);
assert_eq!(map[&FileId(2)].name, "quest");
assert!(map[&FileId(2)].declared);
}
#[test]
fn undeclared_include_of_undeclared_stays_stem() {
let inputs = vec![input(0, "head", None), input(1, "part", None)];
let mut graph = IncludeGraph::new();
graph.update(FileId(0), vec![FileId(1)]);
let (map, diags) = resolve_modules(&inputs, &graph);
assert!(diags.is_empty());
assert_eq!(map[&FileId(1)].name, "part");
assert!(!map[&FileId(1)].declared);
}
#[test]
fn stem_collision_with_declared_module_is_e085() {
let inputs = vec![input(0, "head", Some("quest")), input(1, "quest", None)];
let (_map, diags) = resolve_modules(&inputs, &IncludeGraph::new());
assert_eq!(diags.len(), 1);
assert_eq!(diags[0].code, DiagnosticCode::E085);
assert_eq!(diags[0].file, FileId(1));
}
#[test]
fn no_collision_when_same_name_is_declared() {
let inputs = vec![input(0, "a", Some("quest")), input(1, "b", Some("quest"))];
let (map, diags) = resolve_modules(&inputs, &IncludeGraph::new());
assert!(
diags.is_empty(),
"same declared module is a merge, not a clash"
);
assert!(map[&FileId(0)].declared && map[&FileId(1)].declared);
}
}