fluidattacks-blends 0.32.0

Blends imperative shell: parsing, AST-graph construction, serialization
Documentation
use std::path::{Component, Path};

use blends_domain::stack::{build_stack_graph, StackGraph};
use blends_domain::syntax::SyntaxGraph;

use crate::content::Content;

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