use std::collections::{HashMap, HashSet, VecDeque};
use alba_syntax::Span;
use crate::error::CoreError;
use crate::eval::suggest;
use crate::model::{Beam, BeamId, Project};
fn all_ids(project: &Project) -> impl Iterator<Item = &str> {
project.beams.iter().map(|b| b.id.0.as_str())
}
fn by_id(project: &Project) -> HashMap<&str, &Beam> {
project.beams.iter().map(|b| (b.id.0.as_str(), b)).collect()
}
fn unknown_beam_error<'a>(
name: &str,
candidates: impl Iterator<Item = &'a str>,
span: Option<Span>,
) -> CoreError {
let message = format!("unknown beam `{name}`");
let err = match span {
Some(span) => CoreError::new(message, span),
None => CoreError::unlocated(message),
};
match suggest(name, candidates) {
Some(candidate) => err.with_help(format!("did you mean `{candidate}`?")),
None => err,
}
}
pub fn validate_graph(project: &Project) -> Result<(), CoreError> {
let by_id = by_id(project);
for beam in &project.beams {
for need in &beam.needs {
if !by_id.contains_key(need.value.0.as_str()) {
return Err(
unknown_beam_error(&need.value.0, all_ids(project), Some(need.span))
.with_source_id(beam.source),
);
}
}
}
if let Some(default) = &project.default
&& !by_id.contains_key(default.value.0.as_str())
{
return Err(unknown_beam_error(
&default.value.0,
all_ids(project),
Some(default.span),
));
}
for hook in &project.hooks {
let Some(&target) = by_id.get(hook.beam.value.0.as_str()) else {
return Err(unknown_beam_error(
&hook.beam.value.0,
all_ids(project),
Some(hook.beam.span),
)
.with_source_id(hook.source));
};
if target.params.len() > hook.arity {
let arguments = if hook.arity == 1 {
"argument"
} else {
"arguments"
};
let parameters = if target.params.len() == 1 {
"parameter"
} else {
"parameters"
};
return Err(CoreError::new(
format!(
"hook `{}` passes {} {arguments}, but beam `{}` declares {} {parameters}",
hook.name,
hook.arity,
target.id.0,
target.params.len()
),
hook.beam.span,
)
.with_source_id(hook.source));
}
}
let mut done: HashSet<&str> = HashSet::new();
for beam in &project.beams {
if done.contains(beam.id.0.as_str()) {
continue;
}
detect_cycle(beam, &by_id, &mut done)?;
}
Ok(())
}
enum Step<'a> {
Enter(&'a Beam),
Leave(&'a str),
}
fn detect_cycle<'a>(
root: &'a Beam,
by_id: &HashMap<&'a str, &'a Beam>,
done: &mut HashSet<&'a str>,
) -> Result<(), CoreError> {
let mut path: Vec<&'a str> = Vec::new();
let mut stack: Vec<Step<'a>> = vec![Step::Enter(root)];
while let Some(step) = stack.pop() {
let beam = match step {
Step::Leave(id) => {
path.pop();
done.insert(id);
continue;
}
Step::Enter(beam) => beam,
};
let id = beam.id.0.as_str();
if let Some(pos) = path.iter().position(|&on_path| on_path == id) {
let mut cycle: Vec<&str> = path[pos..].to_vec();
cycle.push(id);
return Err(CoreError::new(
format!("dependency cycle: {}", cycle.join(" → ")),
beam.span,
)
.with_source_id(beam.source));
}
if done.contains(id) {
continue;
}
path.push(id);
stack.push(Step::Leave(id));
for need in beam.needs.iter().rev() {
stack.push(Step::Enter(by_id[need.value.0.as_str()]));
}
}
Ok(())
}
pub fn execution_subgraph(project: &Project, target: &BeamId) -> Result<Vec<BeamId>, CoreError> {
let by_id = by_id(project);
let Some(&start) = by_id.get(target.0.as_str()) else {
return Err(unknown_beam_error(&target.0, all_ids(project), None));
};
let mut seen: HashSet<&str> = HashSet::new();
let mut queue: VecDeque<&str> = VecDeque::new();
seen.insert(start.id.0.as_str());
queue.push_back(start.id.0.as_str());
let mut closure = Vec::new();
while let Some(id) = queue.pop_front() {
closure.push(BeamId(id.to_string()));
if let Some(&beam) = by_id.get(id) {
for need in &beam.needs {
if seen.insert(need.value.0.as_str()) {
queue.push_back(need.value.0.as_str());
}
}
}
}
Ok(closure)
}
#[cfg(test)]
mod tests {
use std::path::PathBuf;
use super::*;
use crate::eval::load_str;
use crate::loader::load_project;
use crate::model::SourceId;
fn sorted(ids: Vec<BeamId>) -> Vec<String> {
let mut v: Vec<String> = ids.into_iter().map(|id| id.0).collect();
v.sort();
v
}
fn write(path: PathBuf, content: &str) {
if let Some(parent) = path.parent() {
std::fs::create_dir_all(parent).unwrap();
}
std::fs::write(path, content).unwrap();
}
#[test]
fn unknown_need_suggests_closest() {
let err =
load_str("beam build { needs [tset] run \"x\" }\nbeam test { run \"y\" }").unwrap_err();
assert!(err.message.contains("unknown beam `tset`"));
assert_eq!(err.help.as_deref(), Some("did you mean `test`?"));
}
#[test]
fn unknown_need_points_at_the_offending_entry() {
const SOURCE: &str = "beam build { needs [test, tset] run \"x\" }\nbeam test { run \"y\" }";
let err = load_str(SOURCE).unwrap_err();
let span = err.span.expect("a declared `needs` entry has a location");
assert_eq!(&SOURCE[span.start..span.end], "tset");
}
#[test]
fn unknown_namespaced_need_points_at_the_whole_reference() {
const SOURCE: &str = "beam build { needs [api:tset] run \"x\" }";
let err = load_str(SOURCE).unwrap_err();
let span = err.span.expect("a declared `needs` entry has a location");
assert_eq!(&SOURCE[span.start..span.end], "api:tset");
}
#[test]
fn unknown_default_is_rejected_at_its_own_span() {
const SOURCE: &str = "default biuld\nbeam build { run \"x\" }";
let err = load_str(SOURCE).unwrap_err();
assert!(err.message.contains("unknown beam `biuld`"));
assert_eq!(err.help.as_deref(), Some("did you mean `build`?"));
let span = err.span.expect("a declared `default` has a location");
assert_eq!(&SOURCE[span.start..span.end], "biuld");
}
#[test]
fn a_default_naming_a_declared_beam_loads() {
let project = load_str("default build\nbeam build { run \"x\" }").unwrap();
assert_eq!(
project.default.as_ref().map(|d| d.value.0.as_str()),
Some("build")
);
}
#[test]
fn cycle_reports_exact_path() {
let err = load_str(
"
beam build { needs [codegen] run \"a\" }
beam codegen { needs [build] run \"b\" }
",
)
.unwrap_err();
assert!(err.message.contains("build → codegen → build"));
}
#[test]
fn subgraph_is_transitive_closure() {
let project = load_str(
"
beam a { run \"x\" }
beam b { needs [a] run \"x\" }
beam c { needs [b] run \"x\" }
beam unrelated { run \"x\" }
",
)
.unwrap();
let ids = execution_subgraph(&project, &BeamId("c".into())).unwrap();
assert_eq!(sorted(ids), vec!["a", "b", "c"]);
}
#[test]
fn self_cycle_reports_itself() {
let err = load_str("beam a { needs [a] run \"x\" }").unwrap_err();
assert!(err.message.contains("a → a"));
}
#[test]
fn cycle_unreachable_from_any_target_is_still_an_error() {
let err = load_str(
"
beam main { run \"x\" }
beam x { needs [y] run \"x\" }
beam y { needs [x] run \"x\" }
",
)
.unwrap_err();
assert!(err.message.contains("x → y → x"));
}
#[test]
fn duplicate_needs_do_not_duplicate_in_subgraph() {
let project = load_str(
"
beam a { run \"x\" }
beam b { needs [a, a] run \"x\" }
",
)
.unwrap();
let ids = execution_subgraph(&project, &BeamId("b".into())).unwrap();
assert_eq!(sorted(ids), vec!["a", "b"]);
}
#[test]
fn unknown_target_suggests_closest_without_a_source_location() {
let project = load_str("beam build { run \"x\" }").unwrap();
let err = execution_subgraph(&project, &BeamId("biuld".into())).unwrap_err();
assert!(err.message.contains("unknown beam `biuld`"));
assert_eq!(err.help.as_deref(), Some("did you mean `build`?"));
assert!(
err.span.is_none(),
"a target named outside the Beamfile has no span"
);
}
#[test]
fn cycle_report_is_deterministic_across_runs() {
let src = "
beam a1 { needs [a2] run \"x\" }
beam a2 { needs [a1] run \"x\" }
beam b1 { needs [b2] run \"x\" }
beam b2 { needs [b1] run \"x\" }
";
for _ in 0..20 {
let err = load_str(src).unwrap_err();
assert!(err.message.contains("a1 → a2 → a1"));
}
}
#[test]
fn cycle_message_excludes_the_non_cyclic_dfs_prefix() {
let err = load_str(
"
beam root { needs [mid] run \"x\" }
beam mid { needs [inner] run \"x\" }
beam inner { needs [x] run \"x\" }
beam x { needs [y] run \"x\" }
beam y { needs [x] run \"x\" }
",
)
.unwrap_err();
assert!(err.message.contains("x → y → x"));
assert!(!err.message.contains("root"));
assert!(!err.message.contains("mid"));
assert!(!err.message.contains("inner"));
}
#[test]
fn diamond_shaped_needs_deduplicates_shared_dependency() {
let project = load_str(
"
beam a { run \"x\" }
beam b { needs [a] run \"x\" }
beam c { needs [a] run \"x\" }
beam d { needs [b, c] run \"x\" }
",
)
.unwrap();
let ids = execution_subgraph(&project, &BeamId("d".into())).unwrap();
assert_eq!(ids.iter().filter(|id| id.0 == "a").count(), 1);
assert_eq!(sorted(ids), vec!["a", "b", "c", "d"]);
}
#[test]
fn subgraph_walks_a_deep_chain_completely() {
let project = load_str(
"
beam a { run \"x\" }
beam b { needs [a] run \"x\" }
beam c { needs [b] run \"x\" }
beam d { needs [c] run \"x\" }
beam e { needs [d] run \"x\" }
",
)
.unwrap();
let ids = execution_subgraph(&project, &BeamId("e".into())).unwrap();
assert_eq!(sorted(ids), vec!["a", "b", "c", "d", "e"]);
}
#[test]
fn cycle_detection_survives_a_very_deep_chain() {
const DEPTH: usize = 10_000;
let mut source = String::new();
for index in 0..DEPTH - 1 {
source.push_str(&format!(
"beam b{index} {{ needs [b{}] run \"x\" }}\n",
index + 1
));
}
source.push_str(&format!("beam b{} {{ run \"x\" }}\n", DEPTH - 1));
let project = load_str(&source).unwrap();
assert_eq!(project.beams.len(), DEPTH);
}
#[test]
fn a_cycle_at_the_bottom_of_a_deep_chain_is_reported_exactly() {
const DEPTH: usize = 5_000;
let mut source = String::new();
for index in 0..DEPTH - 1 {
source.push_str(&format!(
"beam b{index} {{ needs [b{}] run \"x\" }}\n",
index + 1
));
}
source.push_str(&format!(
"beam b{} {{ needs [b{}] run \"x\" }}\n",
DEPTH - 1,
DEPTH - 2
));
let err = load_str(&source).unwrap_err();
assert!(
err.message
.contains(&format!("b{} → b{} → b{}", DEPTH - 2, DEPTH - 1, DEPTH - 2))
);
assert!(!err.message.contains("b0 "));
}
#[test]
fn cycle_among_imported_namespaced_beams_reports_qualified_path() {
let dir = tempfile::tempdir().unwrap();
write(
dir.path().join("Beamfile"),
"import \"api/Beamfile\" as api\nbeam all { needs [api:x] run \"echo\" }",
);
write(
dir.path().join("api/Beamfile"),
"beam x { needs [y] run \"x\" }\nbeam y { needs [x] run \"y\" }",
);
let err = load_project(&dir.path().join("Beamfile")).unwrap_err();
assert!(err.error.message.contains("api:x → api:y → api:x"));
assert_eq!(err.error.source_id, SourceId(1));
}
#[test]
fn unknown_need_in_imported_file_reports_its_own_source_id() {
let dir = tempfile::tempdir().unwrap();
write(
dir.path().join("Beamfile"),
"import \"api/Beamfile\" as api\nbeam all { needs [api:build] run \"echo\" }",
);
write(
dir.path().join("api/Beamfile"),
"beam build { needs [tset] run \"x\" }\nbeam test { run \"y\" }",
);
let err = load_project(&dir.path().join("Beamfile")).unwrap_err();
assert!(err.error.message.contains("unknown beam `api:tset`"));
assert_eq!(err.error.help.as_deref(), Some("did you mean `api:test`?"));
assert_eq!(err.error.source_id, SourceId(1));
}
}