use brink_ir::symbols::{RESERVED_ROOTS, STORY_ROOT, is_reserved_root_module};
use brink_ir::{Diagnostic, DiagnosticCode, FileId, HirFile};
use crate::manifest::ModuleMap;
#[must_use]
pub fn is_path_shaped_conventions_pointer(pointer: &str) -> bool {
pointer.contains('/')
|| pointer.contains('\\')
|| std::path::Path::new(pointer)
.extension()
.is_some_and(|ext| ext.eq_ignore_ascii_case("brink"))
}
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
}
#[must_use]
pub fn conventions_confinement_diagnostics(
files: &[(FileId, &HirFile)],
modules: &ModuleMap,
conventions: Option<&str>,
) -> Vec<Diagnostic> {
if modules.is_empty() {
return Vec::new();
}
let mut out = Vec::new();
for &(file_id, hir) in files {
if hir.claim_handlers.is_empty() {
continue;
}
if modules
.get(&file_id)
.is_some_and(|m| is_reserved_root_module(&m.name))
{
continue;
}
let Some(pointer) = conventions else {
out.extend(conventions_unconfigured_diagnostics(file_id, hir));
continue;
};
if !is_path_shaped_conventions_pointer(pointer) {
continue;
}
let expected_module = native_module_path(pointer);
if !modules.values().any(|m| m.name == expected_module) {
tracing::warn!(
"[project] conventions = \"{pointer}\" does not match any file in the project \
(expected module `{expected_module}`) — conventions-module confinement (E169) \
is skipped until this is fixed"
);
out.extend(conventions_pointer_unresolvable_diagnostics(
file_id, hir, pointer,
));
continue;
}
let Some(this_module) = modules.get(&file_id).map(|m| m.name.as_str()) else {
continue;
};
let is_conventions_module = this_module == expected_module;
out.extend(conventions_module_diagnostics(
file_id,
hir,
is_conventions_module,
pointer,
));
}
out
}
#[must_use]
pub fn conventions_module_diagnostics(
file_id: FileId,
hir: &HirFile,
is_conventions_module: bool,
pointer: &str,
) -> Vec<Diagnostic> {
if is_conventions_module || hir.claim_handlers.is_empty() {
return Vec::new();
}
hir.claim_handlers
.iter()
.map(|handler| Diagnostic {
file: file_id,
range: handler.annotation,
code: DiagnosticCode::E169,
message: format!(
"`{name}` claims prose with `@[convention(claims = \"…\", order = …)]`, but \
pattern-claiming handlers may only be declared in the project's configured \
conventions module (`brink.toml`'s `[project] conventions = \"{pointer}\"`) — move \
`{name}` there",
name = handler.name.text,
),
})
.collect()
}
#[must_use]
pub fn conventions_pointer_unresolvable_diagnostics(
file_id: FileId,
hir: &HirFile,
pointer: &str,
) -> Vec<Diagnostic> {
hir.claim_handlers
.iter()
.map(|handler| Diagnostic {
file: file_id,
range: handler.annotation,
code: DiagnosticCode::E169,
message: format!(
"`{name}` claims prose with `@[convention(claims = \"…\", order = …)]`, but \
the project's configured conventions module — `brink.toml`'s `[project] \
conventions = \"{pointer}\"` — does not match any file in the project, so \
this handler's confinement cannot be checked yet. The pointer and the \
project's files disagree: the target may have been typo'd, moved, or \
deleted — or the `brink.toml` was discovered at a nested key, so the \
module the pointer names does not match the file's real module. Fix the \
`conventions` pointer or the project layout so the two agree",
name = handler.name.text,
),
})
.collect()
}
#[must_use]
pub fn conventions_unconfigured_diagnostics(file_id: FileId, hir: &HirFile) -> Vec<Diagnostic> {
hir.claim_handlers
.iter()
.map(|handler| Diagnostic {
file: file_id,
range: handler.annotation,
code: DiagnosticCode::E169,
message: format!(
"`{name}` claims prose with `@[convention(claims = \"…\", order = …)]`, but no \
conventions module is configured for this project — set `brink.toml`'s \
`[project] conventions = \"…\"` to the module `{name}` should live in",
name = handler.name.text,
),
})
.collect()
}
#[cfg(test)]
mod tests {
use super::*;
use brink_ir::hir::lower_native;
use crate::manifest::ResolvedModule;
fn build_native(src: &str) -> HirFile {
let parsed = brink_syntax_native::parse(src);
assert!(parsed.errors().is_empty(), "{:?}", parsed.errors());
let (hir, _manifest, _diags) = lower_native::lower(FileId(0), &parsed.tree());
hir
}
const CLAIMING_SRC: &str = "@[convention(claims = \"^INT\\\\. (?<place>.+)$\", order = 10)]\n\
fn interior(place: content) {\n return place;\n}\n\
flow main() {\n INT. MARKET SQUARE\n}\n";
#[test]
fn no_claim_handlers_is_always_silent() {
let hir = build_native("flow main() {\n hi\n}\n");
let diags = conventions_module_diagnostics(
FileId(0),
&hir,
false,
"conventions.brink",
);
assert!(diags.is_empty(), "{diags:?}");
}
#[test]
fn claim_handler_in_the_conventions_module_is_silent() {
let hir = build_native(CLAIMING_SRC);
let diags = conventions_module_diagnostics(
FileId(0),
&hir,
true,
"conventions.brink",
);
assert!(diags.is_empty(), "{diags:?}");
}
#[test]
fn claim_handler_outside_the_conventions_module_is_e169() {
let hir = build_native(CLAIMING_SRC);
let diags = conventions_module_diagnostics(
FileId(0),
&hir,
false,
"conventions.brink",
);
assert_eq!(diags.len(), 1, "{diags:?}");
assert_eq!(diags[0].code, DiagnosticCode::E169);
assert!(diags[0].message.contains("interior"), "{diags:?}");
assert!(diags[0].message.contains("conventions.brink"), "{diags:?}");
assert_eq!(diags[0].range, hir.claim_handlers[0].annotation);
}
#[test]
fn one_diagnostic_per_declared_handler() {
let src = "@[convention(claims = \"^A$\", order = 10)]\nfn a() {\n return \"a\";\n}\n\
@[convention(claims = \"^B$\", order = 20)]\nfn b() {\n return \"b\";\n}\n\
flow main() {\n hi\n}\n";
let hir = build_native(src);
assert_eq!(hir.claim_handlers.len(), 2, "{:?}", hir.claim_handlers);
let diags = conventions_module_diagnostics(FileId(0), &hir, false, "conventions.brink");
assert_eq!(diags.len(), 2, "{diags:?}");
assert!(diags.iter().all(|d| d.code == DiagnosticCode::E169));
}
#[test]
fn a_handler_that_never_wins_a_claim_is_still_diagnosed() {
let src = "@[convention(claims = \"^INT\\\\. (?<place>.+)$\", order = 10)]\n\
fn interior(place: content) {\n return place;\n}\n\
flow main() {\n hi\n}\n";
let hir = build_native(src);
assert!(hir.element_matches.is_empty(), "{:?}", hir.element_matches);
assert_eq!(hir.claim_handlers.len(), 1, "{:?}", hir.claim_handlers);
let diags = conventions_module_diagnostics(FileId(0), &hir, false, "conventions.brink");
assert_eq!(diags.len(), 1, "{diags:?}");
}
#[test]
fn no_claim_handlers_is_silent_even_when_unconfigured() {
let hir = build_native("flow main() {\n hi\n}\n");
let diags = conventions_unconfigured_diagnostics(FileId(0), &hir);
assert!(diags.is_empty(), "{diags:?}");
}
#[test]
fn claim_handler_with_no_configured_module_is_e169() {
let hir = build_native(CLAIMING_SRC);
let diags = conventions_unconfigured_diagnostics(FileId(0), &hir);
assert_eq!(diags.len(), 1, "{diags:?}");
assert_eq!(diags[0].code, DiagnosticCode::E169);
assert!(diags[0].message.contains("interior"), "{diags:?}");
assert!(
diags[0]
.message
.contains("no conventions module is configured"),
"{diags:?}"
);
assert_eq!(diags[0].range, hir.claim_handlers[0].annotation);
}
#[test]
fn one_diagnostic_per_declared_handler_when_unconfigured() {
let src = "@[convention(claims = \"^A$\", order = 10)]\nfn a() {\n return \"a\";\n}\n\
@[convention(claims = \"^B$\", order = 20)]\nfn b() {\n return \"b\";\n}\n\
flow main() {\n hi\n}\n";
let hir = build_native(src);
assert_eq!(hir.claim_handlers.len(), 2, "{:?}", hir.claim_handlers);
let diags = conventions_unconfigured_diagnostics(FileId(0), &hir);
assert_eq!(diags.len(), 2, "{diags:?}");
assert!(diags.iter().all(|d| d.code == DiagnosticCode::E169));
}
#[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");
}
#[test]
fn native_module_path_in_generalizes_to_a_second_reserved_root() {
let roots = &["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"
);
}
fn resolved_module(name: &str) -> ResolvedModule {
ResolvedModule {
name: name.to_owned(),
declared: true,
was: None,
}
}
#[test]
fn correctly_placed_handler_is_silent() {
let hir = build_native(CLAIMING_SRC);
let files = [(FileId(0), &hir)];
let mut modules = ModuleMap::new();
modules.insert(FileId(0), resolved_module("story::conventions"));
let diags =
conventions_confinement_diagnostics(&files, &modules, Some("conventions.brink"));
assert!(diags.is_empty(), "{diags:?}");
}
#[test]
fn misplaced_handler_with_a_configured_module_is_e169() {
let hir = build_native(CLAIMING_SRC);
let files = [(FileId(0), &hir)];
let mut modules = ModuleMap::new();
modules.insert(FileId(0), resolved_module("story::other"));
modules.insert(FileId(1), resolved_module("story::conventions"));
let diags =
conventions_confinement_diagnostics(&files, &modules, Some("conventions.brink"));
assert_eq!(diags.len(), 1, "{diags:?}");
assert_eq!(diags[0].code, DiagnosticCode::E169);
assert!(diags[0].message.contains("conventions.brink"), "{diags:?}");
}
#[test]
fn unset_conventions_reports_unconfigured() {
let hir = build_native(CLAIMING_SRC);
let files = [(FileId(0), &hir)];
let mut modules = ModuleMap::new();
modules.insert(FileId(0), resolved_module("story::main"));
let diags = conventions_confinement_diagnostics(&files, &modules, None);
assert_eq!(diags.len(), 1, "{diags:?}");
assert!(
diags[0]
.message
.contains("no conventions module is configured"),
"{diags:?}"
);
}
#[test]
fn preset_shaped_pointer_stays_silent() {
let hir = build_native(CLAIMING_SRC);
let files = [(FileId(0), &hir)];
let mut modules = ModuleMap::new();
modules.insert(FileId(0), resolved_module("story::main"));
let diags = conventions_confinement_diagnostics(&files, &modules, Some("screenplay"));
assert!(diags.is_empty(), "{diags:?}");
}
#[test]
fn a_completely_empty_modules_map_opts_out_of_the_whole_check() {
let hir = build_native(CLAIMING_SRC);
let files = [(FileId(0), &hir)];
let modules = ModuleMap::new();
assert!(conventions_confinement_diagnostics(&files, &modules, None).is_empty());
assert!(
conventions_confinement_diagnostics(&files, &modules, Some("conventions.brink"))
.is_empty()
);
}
#[test]
fn unresolvable_pointer_is_e169_naming_the_pointer_not_a_destination() {
let hir = build_native(CLAIMING_SRC);
let files = [(FileId(0), &hir)];
let mut modules = ModuleMap::new();
modules.insert(FileId(0), resolved_module("story::somewhere_else"));
let diags =
conventions_confinement_diagnostics(&files, &modules, Some("conventions.brink"));
assert_eq!(
diags.iter().map(|d| d.code).collect::<Vec<_>>(),
vec![DiagnosticCode::E169],
"{diags:?}"
);
assert!(
diags[0].message.contains("does not match any file"),
"{diags:?}"
);
assert!(
!diags[0].message.contains("may only be declared"),
"must not use `conventions_module_diagnostics`'s \"move it \
there\" message — there is no correct destination to name — \
got {diags:?}"
);
}
#[test]
fn a_mounted_reserved_root_file_is_exempt_even_when_unconfigured() {
let hir = build_native(CLAIMING_SRC);
let files = [(FileId(0), &hir)];
let mut modules = ModuleMap::new();
modules.insert(FileId(0), resolved_module("std::conventions::screenplay"));
let diags = conventions_confinement_diagnostics(&files, &modules, None);
assert!(diags.is_empty(), "{diags:?}");
}
#[test]
fn a_claiming_file_absent_from_modules_stays_silent() {
let hir = build_native(CLAIMING_SRC);
let files = [(FileId(0), &hir)];
let mut modules = ModuleMap::new();
modules.insert(FileId(1), resolved_module("story::conventions"));
let diags =
conventions_confinement_diagnostics(&files, &modules, Some("conventions.brink"));
assert!(diags.is_empty(), "{diags:?}");
}
#[test]
fn no_claim_handlers_is_silent_regardless_of_configuration() {
let hir = build_native("flow main() {\n hi\n}\n");
let files = [(FileId(0), &hir)];
let modules = ModuleMap::new();
assert!(conventions_confinement_diagnostics(&files, &modules, None).is_empty());
assert!(
conventions_confinement_diagnostics(&files, &modules, Some("conventions.brink"))
.is_empty()
);
}
}