fluidattacks_blends/
stack.rs1use std::path::{Component, Path};
2
3use blends_domain::stack::{build_stack_graph, StackGraph};
4use blends_domain::syntax::SyntaxGraph;
5
6use crate::content::Content;
7
8#[must_use]
11pub fn get_stack_graph(
12 syntax_graph: &SyntaxGraph,
13 content: &Content,
14 root: &Path,
15) -> Option<StackGraph> {
16 let Some(module_path) = module_path(&content.path, root) else {
17 tracing::debug!(
18 path = %content.path.display(),
19 root = %root.display(),
20 "file outside the stack graph root"
21 );
22 return None;
23 };
24 Some(build_stack_graph(
25 syntax_graph,
26 content.language,
27 &module_path,
28 ))
29}
30
31fn module_path(path: &Path, root: &Path) -> Option<String> {
32 let parts = path
33 .strip_prefix(root)
34 .ok()?
35 .components()
36 .map(|component| match component {
37 Component::Normal(part) => part.to_str(),
38 _ => None,
39 })
40 .collect::<Option<Vec<_>>>()?;
41 (!parts.is_empty()).then(|| parts.join("/"))
42}
43
44#[cfg(test)]
45mod tests {
46 use super::get_stack_graph;
47 use crate::content::Content;
48 use crate::graphs::get_graphs_from_path;
49 use blends_domain::stack::{StackGraph, StackNode};
50 use std::fs;
51 use std::path::{Path, PathBuf};
52 use test_case::test_case;
53
54 fn fixtures_dir() -> PathBuf {
55 Path::new(env!("CARGO_MANIFEST_DIR")).join("../../test/data/test_files/stack_graph")
56 }
57
58 fn python_fixtures(dir: &Path) -> Vec<PathBuf> {
59 let mut fixtures: Vec<PathBuf> = fs::read_dir(dir)
60 .expect("fixtures directory is readable")
61 .map(|entry| entry.expect("fixture entry is readable").path())
62 .flat_map(|path| {
63 if path.is_dir() {
64 python_fixtures(&path)
65 } else if path.extension().is_some_and(|ext| ext == "py") {
66 vec![path]
67 } else {
68 Vec::new()
69 }
70 })
71 .collect();
72 fixtures.sort();
73 fixtures
74 }
75
76 fn stack_for(path: &Path, root: &Path) -> Option<StackGraph> {
77 let syntax = get_graphs_from_path(path, Some(false), None)
78 .syntax
79 .expect("python fixture has a syntax graph");
80 let content = Content::from_path(path, None).expect("fixture is readable");
81 get_stack_graph(&syntax, &content, root)
82 }
83
84 fn module_chain(stack: &StackGraph) -> Option<Vec<&str>> {
85 let root = stack
86 .nodes
87 .iter()
88 .find_map(|(id, node)| matches!(node, StackNode::Root).then_some(*id))?;
89 let mut chain = Vec::new();
90 let mut current = root;
91 loop {
92 current = match stack.successors(current).as_slice() {
93 [(next, _)] => *next,
94 _ => return None,
95 };
96 match stack.nodes.get(¤t)? {
97 StackNode::Scope(_) => return Some(chain),
98 StackNode::PopSymbol(pop) => chain.push(pop.symbol.as_str()),
99 _ => return None,
100 }
101 }
102 }
103
104 #[test_case("case_closure.py", &["case_closure"] ; "a top level file is named by its stem")]
105 #[test_case("lib/utils.py", &["lib", ".", "utils"] ; "a nested file is named by its directories and stem")]
106 #[test_case("lib/__init__.py", &["lib"] ; "a package init is named by its directory")]
107 fn the_root_reaches_the_file_scope_through_its_module_path(fixture: &str, expected: &[&str]) {
108 let root = fixtures_dir();
109 let stack = stack_for(&root.join(fixture), &root).expect("fixture is under the root");
110
111 assert_eq!(module_chain(&stack).as_deref(), Some(expected));
112 }
113
114 #[test]
115 fn every_python_fixture_is_reachable_from_the_root_through_a_module_path() {
116 let root = fixtures_dir();
117 let unreachable: Vec<_> = python_fixtures(&root)
118 .into_iter()
119 .filter(|path| {
120 stack_for(path, &root)
121 .as_ref()
122 .and_then(module_chain)
123 .is_none_or(|chain| chain.is_empty())
124 })
125 .collect();
126
127 assert_eq!(unreachable, Vec::<PathBuf>::new());
128 }
129
130 #[test]
131 fn python_fixtures_cover_every_file_of_the_stack_graph_baseline() {
132 assert_eq!(python_fixtures(&fixtures_dir()).len(), 106);
133 }
134
135 #[test]
136 fn a_file_outside_the_root_builds_no_stack_graph() {
137 let root = fixtures_dir();
138 let path = root.join("case_closure.py");
139
140 assert!(stack_for(&path, &root.join("lib")).is_none());
141 }
142}