fluidattacks-blends-domain 0.22.0

Blends functional core: pure AST graph to syntax graph (no_std)
Documentation
//! Counterpart of `blends/syntax/builders/export_statement.py` (the
//! `StackGraph` wiring of default exports and reexports is excluded: the
//! migration covers only AST, Syntax and CFG).

use alloc::string::String;
use alloc::vec::Vec;

use crate::syntax::SyntaxNode;
use crate::syntax::{SyntaxGraphArgs, SyntaxGraphError};
use crate::NodeId;

/// What the export statement reader hands to its builder.
#[derive(Clone, PartialEq, Eq, Debug, Default)]
pub struct ExportStatement {
    pub expression: Option<String>,
    pub declaration_id: Option<NodeId>,
    pub alias: Option<String>,
    pub default_id: Option<NodeId>,
    pub specifier_ids: Vec<NodeId>,
}

/// Build the `Export` node of one exported name.
pub fn build_exported_name_node(
    args: &mut SyntaxGraphArgs<'_>,
    n_id: NodeId,
    expression: String,
    alias: Option<String>,
) -> NodeId {
    args.syntax_graph.add_node(
        n_id,
        SyntaxNode::Export {
            expression: Some(expression),
            declaration_id: None,
            alias: alias.filter(|value| !value.is_empty()),
            default_id: None,
        },
    );
    n_id
}

pub fn build_export_statement_node(
    args: &mut SyntaxGraphArgs<'_>,
    n_id: NodeId,
    statement: ExportStatement,
) -> Result<NodeId, SyntaxGraphError> {
    args.syntax_graph.add_node(
        n_id,
        SyntaxNode::Export {
            expression: statement.expression.filter(|value| !value.is_empty()),
            declaration_id: statement.declaration_id,
            alias: statement.alias.filter(|value| !value.is_empty()),
            default_id: statement.default_id,
        },
    );

    if let Some(declaration_id) = statement.declaration_id {
        let built = args.generic(declaration_id)?;
        args.syntax_graph.add_ast_edge(n_id, built);
    }

    for spec_id in statement.specifier_ids {
        let built = args.generic(spec_id)?;
        args.syntax_graph.add_ast_edge(n_id, built);
    }

    if let Some(default_id) = statement.default_id {
        args.syntax_graph.add_node(default_id, SyntaxNode::Default);
        args.syntax_graph.add_ast_edge(n_id, default_id);
    }

    Ok(n_id)
}

#[cfg(test)]
mod tests {
    use super::{build_export_statement_node, build_exported_name_node, ExportStatement};
    use crate::ast::{AstGraph, AstNode};
    use crate::syntax::{
        SyntaxGraph, SyntaxGraphArgs, SyntaxGraphError, SyntaxMetadata, SyntaxNode, SyntaxReader,
    };
    use crate::{Language, NodeId};
    use alloc::borrow::ToOwned;
    use alloc::string::String;
    use alloc::vec;

    const STATEMENT: NodeId = NodeId(1);

    fn no_dispatch(_: &str) -> Option<SyntaxReader> {
        None
    }

    /// Stands for the specifier reader: every dispatched node becomes `m`.
    #[expect(
        clippy::unnecessary_wraps,
        reason = "the signature must match the reader dispatch table"
    )]
    fn name_reader(
        args: &mut SyntaxGraphArgs<'_>,
        n_id: NodeId,
    ) -> Result<Option<NodeId>, SyntaxGraphError> {
        Ok(Some(build_exported_name_node(
            args,
            n_id,
            String::from("./module.m"),
            Some(String::from("m")),
        )))
    }

    #[expect(
        clippy::unnecessary_wraps,
        reason = "the signature must match the dispatch lookup"
    )]
    fn dispatch_names(_: &str) -> Option<SyntaxReader> {
        Some(name_reader)
    }

    /// Builds `statement` at `STATEMENT` and returns the outcome with its graph.
    fn build(
        ast: &AstGraph,
        dispatch: fn(&str) -> Option<SyntaxReader>,
        statement: ExportStatement,
    ) -> (Result<NodeId, SyntaxGraphError>, SyntaxGraph) {
        let mut graph = SyntaxGraph::new();
        let mut meta = SyntaxMetadata::seeded(NodeId(10));
        let result = {
            let mut args =
                SyntaxGraphArgs::new(Language::JavaScript, ast, &mut graph, &mut meta, dispatch);
            build_export_statement_node(&mut args, STATEMENT, statement)
        };
        (result, graph)
    }

    fn has_ast_edge(graph: &SyntaxGraph, to: NodeId) -> bool {
        graph
            .edges
            .get(&STATEMENT)
            .is_some_and(|adjacent| adjacent.contains_key(&to))
    }

    #[test]
    fn the_declaration_specifiers_and_default_keyword_are_all_wired() {
        let mut ast = AstGraph::new();
        ast.add_node(NodeId(2), AstNode::new(1, 1, "declaration".to_owned()));
        ast.add_node(NodeId(3), AstNode::new(1, 1, "export_specifier".to_owned()));

        let (result, graph) = build(
            &ast,
            dispatch_names,
            ExportStatement {
                expression: Some(String::from("./module")),
                declaration_id: Some(NodeId(2)),
                default_id: Some(NodeId(4)),
                specifier_ids: vec![NodeId(3)],
                ..ExportStatement::default()
            },
        );

        assert_eq!(result.expect("the builder must succeed"), STATEMENT);
        assert_eq!(
            graph.nodes.get(&STATEMENT),
            Some(&SyntaxNode::Export {
                expression: Some(String::from("./module")),
                declaration_id: Some(NodeId(2)),
                alias: None,
                default_id: Some(NodeId(4)),
            })
        );
        assert!(has_ast_edge(&graph, NodeId(2)));
        assert_eq!(
            graph.nodes.get(&NodeId(3)),
            Some(&SyntaxNode::Export {
                expression: Some(String::from("./module.m")),
                declaration_id: None,
                alias: Some(String::from("m")),
                default_id: None,
            })
        );
        assert!(has_ast_edge(&graph, NodeId(3)));
        assert_eq!(graph.nodes.get(&NodeId(4)), Some(&SyntaxNode::Default));
        assert!(has_ast_edge(&graph, NodeId(4)));
        assert!(graph.symbol_index.is_empty());
    }

    #[test]
    fn an_exported_name_keeps_its_expression_and_drops_an_empty_alias() {
        let ast = AstGraph::new();
        let mut graph = SyntaxGraph::new();
        let mut meta = SyntaxMetadata::seeded(NodeId(10));
        let result = {
            let mut args = SyntaxGraphArgs::new(
                Language::JavaScript,
                &ast,
                &mut graph,
                &mut meta,
                no_dispatch,
            );
            build_exported_name_node(
                &mut args,
                NodeId(5),
                String::from("handler"),
                Some(String::new()),
            )
        };

        assert_eq!(result, NodeId(5));
        assert_eq!(
            graph.nodes.get(&NodeId(5)),
            Some(&SyntaxNode::Export {
                expression: Some(String::from("handler")),
                declaration_id: None,
                alias: None,
                default_id: None,
            })
        );
    }

    #[test]
    fn empty_strings_are_normalized_to_none_and_optional_fields_are_skipped() {
        let (result, graph) = build(
            &AstGraph::new(),
            no_dispatch,
            ExportStatement {
                expression: Some(String::new()),
                alias: Some(String::new()),
                ..ExportStatement::default()
            },
        );

        assert_eq!(result.expect("the builder must succeed"), STATEMENT);
        assert_eq!(
            graph.nodes.get(&STATEMENT),
            Some(&SyntaxNode::Export {
                expression: None,
                declaration_id: None,
                alias: None,
                default_id: None,
            })
        );
        assert_eq!(graph.nodes.len(), 1);
    }

    #[test]
    fn missing_declaration_ast_node_propagates_the_error() {
        let (result, _) = build(
            &AstGraph::new(),
            no_dispatch,
            ExportStatement {
                declaration_id: Some(NodeId(2)),
                ..ExportStatement::default()
            },
        );

        assert!(matches!(result, Err(SyntaxGraphError::MissingAstNode)));
    }
}