use std::path::{Component, Path};
use blends_domain::stack::{build_stack_graph, StackGraph};
use blends_domain::syntax::SyntaxGraph;
use crate::content::Content;
#[must_use]
pub fn get_stack_graph(
syntax_graph: &SyntaxGraph,
content: &Content,
root: &Path,
) -> Option<StackGraph> {
let Some(module_path) = module_path(&content.path, root) else {
tracing::debug!(
path = %content.path.display(),
root = %root.display(),
"file outside the stack graph root"
);
return None;
};
Some(build_stack_graph(
syntax_graph,
content.language,
&module_path,
))
}
fn module_path(path: &Path, root: &Path) -> Option<String> {
let parts = path
.strip_prefix(root)
.ok()?
.components()
.map(|component| match component {
Component::Normal(part) => part.to_str(),
_ => None,
})
.collect::<Option<Vec<_>>>()?;
(!parts.is_empty()).then(|| parts.join("/"))
}
#[cfg(test)]
mod tests {
use super::get_stack_graph;
use crate::content::Content;
use crate::graphs::get_graphs_from_path;
use blends_domain::stack::{StackGraph, StackNode};
use std::fs;
use std::path::{Path, PathBuf};
use test_case::test_case;
fn fixtures_dir() -> PathBuf {
Path::new(env!("CARGO_MANIFEST_DIR")).join("../../test/data/test_files/stack_graph")
}
fn python_fixtures(dir: &Path) -> Vec<PathBuf> {
let mut fixtures: Vec<PathBuf> = fs::read_dir(dir)
.expect("fixtures directory is readable")
.map(|entry| entry.expect("fixture entry is readable").path())
.flat_map(|path| {
if path.is_dir() {
python_fixtures(&path)
} else if path.extension().is_some_and(|ext| ext == "py") {
vec![path]
} else {
Vec::new()
}
})
.collect();
fixtures.sort();
fixtures
}
fn stack_for(path: &Path, root: &Path) -> Option<StackGraph> {
let syntax = get_graphs_from_path(path, Some(false), None)
.syntax
.expect("python fixture has a syntax graph");
let content = Content::from_path(path, None).expect("fixture is readable");
get_stack_graph(&syntax, &content, root)
}
fn module_chain(stack: &StackGraph) -> Option<Vec<&str>> {
let root = stack
.nodes
.iter()
.find_map(|(id, node)| matches!(node, StackNode::Root).then_some(*id))?;
let mut chain = Vec::new();
let mut current = root;
loop {
current = match stack.successors(current).as_slice() {
[(next, _)] => *next,
_ => return None,
};
match stack.nodes.get(¤t)? {
StackNode::Scope(_) => return Some(chain),
StackNode::PopSymbol(pop) => chain.push(pop.symbol.as_str()),
_ => return None,
}
}
}
#[test_case("case_closure.py", &["case_closure"] ; "a top level file is named by its stem")]
#[test_case("lib/utils.py", &["lib", ".", "utils"] ; "a nested file is named by its directories and stem")]
#[test_case("lib/__init__.py", &["lib"] ; "a package init is named by its directory")]
fn the_root_reaches_the_file_scope_through_its_module_path(fixture: &str, expected: &[&str]) {
let root = fixtures_dir();
let stack = stack_for(&root.join(fixture), &root).expect("fixture is under the root");
assert_eq!(module_chain(&stack).as_deref(), Some(expected));
}
#[test]
fn every_python_fixture_is_reachable_from_the_root_through_a_module_path() {
let root = fixtures_dir();
let unreachable: Vec<_> = python_fixtures(&root)
.into_iter()
.filter(|path| {
stack_for(path, &root)
.as_ref()
.and_then(module_chain)
.is_none_or(|chain| chain.is_empty())
})
.collect();
assert_eq!(unreachable, Vec::<PathBuf>::new());
}
#[test]
fn python_fixtures_cover_every_file_of_the_stack_graph_baseline() {
assert_eq!(python_fixtures(&fixtures_dir()).len(), 106);
}
#[test]
fn a_file_outside_the_root_builds_no_stack_graph() {
let root = fixtures_dir();
let path = root.join("case_closure.py");
assert!(stack_for(&path, &root.join("lib")).is_none());
}
}