fluidattacks-blends 0.6.1

Blends imperative shell: parsing, AST-graph construction, serialization
Documentation
//! Syntax-graph construction seam: builds the syntax graph for a file,
//! applies the cfg overlay and logs the outcomes.

use blends_domain::ast::AstGraph;
use blends_domain::syntax;
use blends_domain::syntax::cfg;
use blends_domain::syntax::cfg::SyntaxCfgError;
use blends_domain::syntax::SyntaxGraph;
use blends_domain::syntax::SyntaxNode;

use crate::content::Content;

fn trace_missing_readers(syntax_graph: &SyntaxGraph, content: &Content) {
    for (n_id, node) in &syntax_graph.nodes {
        if let SyntaxNode::MissingNode { node_type } = node {
            tracing::debug!(
                node_type,
                language = ?content.language,
                node_id = n_id.0,
                path = %content.path.display(),
                "missing syntax reader"
            );
        }
    }
}

fn apply_cfg_overlay(
    syntax_graph: &mut SyntaxGraph,
    content: &Content,
    is_multifile: bool,
) -> bool {
    match cfg::add_syntax_cfg(syntax_graph, is_multifile) {
        Ok(()) => true,
        Err(error @ SyntaxCfgError::MissingCfgBuilder { .. }) => {
            tracing::error!(
                %error,
                path = %content.path.display(),
                "missing cfg builder"
            );
            true
        }
        Err(
            error @ (SyntaxCfgError::MissingSyntaxNode { .. }
            | SyntaxCfgError::UnexpectedSyntaxShape { .. }),
        ) => {
            tracing::error!(
                %error,
                path = %content.path.display(),
                "unable to build the syntax_graph"
            );
            false
        }
    }
}

#[must_use]
pub fn get_syntax_graph(
    ast_graph: &AstGraph,
    content: &Content,
    with_cfg: Option<bool>,
    with_metadata: Option<bool>,
) -> Option<SyntaxGraph> {
    let path = content.path.display().to_string();
    let with_metadata = with_metadata.unwrap_or(false);
    let mut syntax_graph =
        match syntax::build_syntax_graph(ast_graph, &path, content.language, with_metadata) {
            Ok(syntax_graph) => syntax_graph,
            Err(syntax::SyntaxGraphError::UnsupportedLanguage) => {
                tracing::debug!(
                    language = ?content.language,
                    path = %content.path.display(),
                    "syntax dispatcher not implemented yet"
                );
                return None;
            }
            Err(error) => {
                tracing::error!(
                    %error,
                    path = %content.path.display(),
                    "unable to build the syntax_graph"
                );
                return None;
            }
        };

    trace_missing_readers(&syntax_graph, content);

    if with_cfg.unwrap_or(true) && !apply_cfg_overlay(&mut syntax_graph, content, with_metadata) {
        return None;
    }

    syntax_graph.finalize_symbol_index();
    Some(syntax_graph)
}

#[cfg(test)]
mod tests {
    use super::get_syntax_graph;
    use crate::ast::get_ast_graph;
    use crate::content::Content;
    use crate::language::Language;
    use blends_domain::ast::{AstGraph, AstNode};
    use blends_domain::path_search::get_backward_paths;
    use blends_domain::path_search::search::definition_search;
    use blends_domain::query::get_all_scope_definitions;
    use blends_domain::syntax::SyntaxNode;
    use blends_domain::NodeId;
    use std::path::PathBuf;
    use test_case::test_case;

    fn content_for(language: Language, extension: &str, text: &str) -> Content {
        Content {
            bytes: text.as_bytes().to_vec(),
            text: text.to_owned(),
            language,
            path: PathBuf::from(format!("test.{extension}")),
        }
    }

    fn single_node_ast(node_type: &str) -> AstGraph {
        let mut ast = AstGraph::new();
        ast.add_node(NodeId(1), AstNode::new(1, 1, node_type.to_owned()));
        ast
    }

    #[test]
    fn builds_the_syntax_graph_for_a_supported_language() {
        let content = content_for(Language::Yaml, "yaml", "key: value");
        let ast = single_node_ast("stream");

        let result = get_syntax_graph(&ast, &content, None, None);

        let graph = result.expect("syntax graph should be built");
        assert!(!graph.nodes.is_empty());
    }

    #[test]
    fn definition_search_resolves_the_second_local_declarator() {
        let content = content_for(
            Language::Java,
            "java",
            "class T { void run() { String car = \"a\", plane = \"b\", boat = \"c\"; System.out.println(plane); } }",
        );
        let ast = get_ast_graph(&content).expect("java source should parse");

        let graph = get_syntax_graph(&ast, &content, None, None).expect("graph should be built");

        let lookup_id = graph
            .nodes
            .iter()
            .find_map(|(n_id, node)| match node {
                SyntaxNode::SymbolLookup { symbol, .. } if symbol == "plane" => Some(*n_id),
                _ => None,
            })
            .expect("plane lookup should exist");
        let def_id = get_backward_paths(&graph, lookup_id, None)
            .iter()
            .find_map(|path| definition_search(&graph, path, "plane"))
            .expect("plane definition should resolve");

        assert!(matches!(
            graph.nodes.get(&def_id),
            Some(SyntaxNode::VariableDeclaration { variable, .. }) if variable == "plane"
        ));
    }

    fn lookup_for(graph: &blends_domain::syntax::SyntaxGraph, wanted: &str) -> NodeId {
        graph
            .nodes
            .iter()
            .find_map(|(n_id, node)| match node {
                SyntaxNode::SymbolLookup { symbol, .. } if symbol == wanted => Some(*n_id),
                _ => None,
            })
            .expect("lookup should exist")
    }

    #[test]
    fn scope_definitions_resolve_the_second_local_declarator() {
        let content = content_for(
            Language::Java,
            "java",
            "class T { void run() { String car = \"a\", plane = \"b\", boat = \"c\"; System.out.println(plane); } }",
        );
        let ast = get_ast_graph(&content).expect("java source should parse");

        let graph = get_syntax_graph(&ast, &content, None, None).expect("graph should be built");

        let definitions = get_all_scope_definitions(&graph, lookup_for(&graph, "plane"));

        let &[def_id] = definitions.as_slice() else {
            panic!("expected one scope definition for plane");
        };
        assert!(matches!(
            graph.nodes.get(&def_id),
            Some(SyntaxNode::VariableDeclaration { variable, variable_type: Some(variable_type), .. })
                if variable == "plane" && variable_type == "String"
        ));
    }

    #[test]
    fn scope_definitions_resolve_the_second_interface_constant() {
        let content = content_for(
            Language::Java,
            "java",
            "interface Limits { int MIN = 1, MAX = 10; default int clamp(int value) { return value > MAX ? MAX : value; } }",
        );
        let ast = get_ast_graph(&content).expect("java source should parse");

        let graph = get_syntax_graph(&ast, &content, None, None).expect("graph should be built");

        let definitions = get_all_scope_definitions(&graph, lookup_for(&graph, "MAX"));

        let &[def_id] = definitions.as_slice() else {
            panic!("expected one scope definition for MAX");
        };
        assert!(matches!(
            graph.nodes.get(&def_id),
            Some(SyntaxNode::VariableDeclaration { variable, variable_type: Some(variable_type), .. })
                if variable == "MAX" && variable_type == "int"
        ));
    }

    #[test]
    fn metadata_node_added_at_zero_when_requested() {
        let content = content_for(Language::Yaml, "yaml", "key: value");
        let ast = single_node_ast("stream");

        let result = get_syntax_graph(&ast, &content, None, Some(true));

        let graph = result.expect("syntax graph should be built");
        assert_eq!(
            graph.nodes.get(&NodeId(0)),
            Some(&SyntaxNode::Metadata {
                path: "test.yaml".to_owned(),
                structure: std::collections::BTreeMap::new(),
                instances: std::collections::BTreeMap::new(),
                imports: std::vec::Vec::new(),
                package: None,
            })
        );
    }

    #[test_case(None ; "by default")]
    #[test_case(Some(false) ; "when explicitly disabled")]
    fn no_metadata_node(with_metadata: Option<bool>) {
        let content = content_for(Language::Yaml, "yaml", "key: value");
        let ast = single_node_ast("stream");

        let result = get_syntax_graph(&ast, &content, None, with_metadata);

        let graph = result.expect("syntax graph should be built");
        assert!(!graph.nodes.contains_key(&NodeId(0)));
    }

    #[test]
    fn language_without_dispatcher_has_no_syntax_graph() {
        let content = content_for(Language::Kotlin, "kt", "class A {}");
        let ast = single_node_ast("program");

        assert!(get_syntax_graph(&ast, &content, None, None).is_none());
    }

    #[test]
    fn unhandled_root_node_type_degrades_to_missing_node_and_recurses() {
        let content = content_for(Language::Yaml, "yaml", "key: value");
        let mut ast = single_node_ast("unknown_node_type_xyz");
        ast.add_node(NodeId(2), AstNode::new(1, 5, "another_unknown".to_owned()));
        ast.add_edge(NodeId(1), NodeId(2), 0);

        let result = get_syntax_graph(&ast, &content, None, None);

        let graph = result.expect("syntax graph should be built");
        assert_eq!(
            graph.nodes.get(&NodeId(1)),
            Some(&SyntaxNode::MissingNode {
                node_type: "unknown_node_type_xyz".to_owned()
            })
        );
        assert_eq!(
            graph.nodes.get(&NodeId(2)),
            Some(&SyntaxNode::MissingNode {
                node_type: "another_unknown".to_owned()
            })
        );
        assert!(graph
            .edges
            .get(&NodeId(1))
            .is_some_and(|adjacent| adjacent.contains_key(&NodeId(2))));
    }

    #[test]
    fn fatal_reader_error_returns_none() {
        let content = content_for(Language::Yaml, "yaml", "[]");
        let ast = single_node_ast("flow_node");

        assert!(get_syntax_graph(&ast, &content, None, None).is_none());
    }

    #[test]
    fn empty_ast_graph_returns_none() {
        let content = content_for(Language::Yaml, "yaml", "");

        assert!(get_syntax_graph(&AstGraph::new(), &content, None, None).is_none());
    }

    #[test]
    fn ast_graph_without_root_node_returns_none() {
        let content = content_for(Language::Yaml, "yaml", "key: value");
        let mut ast = AstGraph::new();
        ast.add_node(NodeId(7), AstNode::new(1, 1, "stream".to_owned()));

        assert!(get_syntax_graph(&ast, &content, None, None).is_none());
    }

    #[test]
    fn cfg_marks_overlay_the_ast_edges_by_default() {
        let content = content_for(Language::Yaml, "yaml", "key: value");
        let ast = get_ast_graph(&content).expect("yaml should parse");

        let result = get_syntax_graph(&ast, &content, None, None);

        let graph = result.expect("syntax graph should be built");
        assert!(graph
            .edges
            .get(&NodeId(1))
            .is_some_and(|adjacent| adjacent.values().any(|edge| edge.cfg.is_some())));
    }

    #[test]
    fn no_cfg_marks_when_disabled() {
        let content = content_for(Language::Yaml, "yaml", "key: value");
        let ast = get_ast_graph(&content).expect("yaml should parse");

        let result = get_syntax_graph(&ast, &content, Some(false), None);

        let graph = result.expect("syntax graph should be built");
        assert!(graph
            .edges
            .values()
            .flat_map(|adjacent| adjacent.values())
            .all(|edge| edge.cfg.is_none()));
    }

    #[test]
    fn empty_yaml_builds_a_lone_file_node() {
        let content = content_for(Language::Yaml, "yaml", "");
        let ast = get_ast_graph(&content).expect("empty yaml should parse");

        let result = get_syntax_graph(&ast, &content, None, None);

        let graph = result.expect("syntax graph should be built");
        assert_eq!(graph.nodes.len(), 1);
        assert_eq!(graph.nodes.get(&NodeId(1)), Some(&SyntaxNode::File));
    }
}