Skip to main content

fluidattacks_blends/
stack.rs

1use 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/// Builds `syntax_graph`'s stack graph, naming the file's module by its path
9/// under `root`; `None` when the file is not under `root`.
10#[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(&current)? {
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}