meta-ast 0.7.0

Polyglot static-analysis engine: extract symbols and cross-language dependency graphs from 9 supported source languages, with optional MetaCall deployment manifest generation.
Documentation
use crate::model::Symbol;
use crate::output::OutputFormat;
use crate::pipeline::GraphAnalysis;
use std::path::PathBuf;

#[derive(Debug, Clone)]
pub struct EmitConfig {
    /// Explicit output path. `None` writes the document to stdout.
    pub output: Option<PathBuf>,
    /// Serialization format for the text document.
    pub format: OutputFormat,
    /// Generate the HTML dashboard instead of the text document.
    pub html: bool,
    /// Open the written dashboard in the default browser.
    pub open_browser: bool,
}

/// Serialize and emit the symbol inspection results.
///
/// If `config.output` is `Some(path)`, writes the serialized contents to that file.
/// Otherwise, prints the contents directly to stdout.
pub fn emit_inspect(symbols: &mut Vec<Symbol>, config: &EmitConfig) -> anyhow::Result<()> {
    let content = crate::output::inspect::serialize_inspect(symbols, &config.format)?;
    match &config.output {
        Some(path) => {
            crate::output::write_atomic(path, content.as_bytes())?;
        }
        None => {
            println!("{content}");
        }
    }
    Ok(())
}

/// Serialize and emit the dependency graph analysis results.
///
/// If `config.html` is true, generates the interactive HTML dashboard. It is
/// written to `config.output`, or printed to stdout when no path is given, in
/// which case there is nothing for `config.open_browser` to open. Otherwise,
/// the graph is serialized into the requested text format (JSON/YAML) and
/// written to `config.output`, or printed to stdout.
pub fn emit_graph(analysis: &GraphAnalysis, config: &EmitConfig) -> anyhow::Result<()> {
    if config.html {
        let html = crate::output::dashboard::to_graph_html(
            &analysis.graph,
            &analysis.scc,
            analysis.snapshot_id.to_raw() as u64,
        )?;
        match &config.output {
            Some(path) => {
                crate::output::write_atomic(path, html.as_bytes())?;
                if config.open_browser {
                    open_in_browser(path)?;
                }
            }
            None => println!("{html}"),
        }
    } else {
        let content = crate::output::graph::serialize_graph(
            &analysis.graph,
            &analysis.scc,
            analysis.snapshot_id.to_raw() as u64,
            &config.format,
        )?;
        match &config.output {
            Some(path) => {
                crate::output::write_atomic(path, content.as_bytes())?;
            }
            None => {
                println!("{content}");
            }
        }
    }
    Ok(())
}

/// Hand the written dashboard to the operating system's default browser.
fn open_in_browser(path: &std::path::Path) -> anyhow::Result<()> {
    // A file URL keeps a non-UTF-8 path intact. `to_string_lossy` would
    // replace the unencodable bytes, and the OS handler expects a URL.
    let absolute = if path.is_absolute() {
        path.to_path_buf()
    } else {
        std::env::current_dir()?.join(path)
    };
    match url::Url::from_file_path(&absolute) {
        Ok(url) => {
            if let Err(e) = webbrowser::open(url.as_str()) {
                tracing::warn!(error = %e, "could not open browser");
            }
        }
        Err(()) => tracing::warn!(
            path = %absolute.display(),
            "cannot open the dashboard in a browser"
        ),
    }
    Ok(())
}

#[cfg(test)]
mod tests {
    use super::*;
    use crate::graph::{GraphBuilder, SccAnalysis};
    use crate::language::LangId;
    use crate::model::{LineColumn, SnapshotId, SourceRange, Symbol, SymbolId, SymbolKind};
    use crate::output::OutputFormat;

    #[test]
    fn emit_inspect_writes_to_path() {
        let temp_dir = std::env::temp_dir().join("emit_inspect_writes_to_path");
        if temp_dir.exists() {
            let _ = std::fs::remove_dir_all(&temp_dir);
        }
        std::fs::create_dir_all(&temp_dir).unwrap();
        let file_path = temp_dir.join("output.json");
        let mut symbols = vec![Symbol {
            id: SymbolId::new(1).unwrap(),
            name: "test".into(),
            kind: SymbolKind::Function,
            language: LangId::Python,
            file_path: PathBuf::from("a.py"),
            source_range: SourceRange {
                byte_start: 0,
                byte_end: 10,
                start: LineColumn { line: 1, column: 0 },
                end: LineColumn {
                    line: 1,
                    column: 10,
                },
            },
            name_range: None,
            visibility: None,
            signature: None,
            docstring: None,
            is_async: false,
        }];
        let config = EmitConfig {
            output: Some(file_path.clone()),
            format: OutputFormat::Json,
            html: false,
            open_browser: false,
        };
        emit_inspect(&mut symbols, &config).unwrap();
        assert!(file_path.exists());
        let content = std::fs::read_to_string(file_path).unwrap();
        assert!(content.contains("test"));
        let _ = std::fs::remove_dir_all(&temp_dir);
    }

    #[test]
    fn emit_inspect_prints_to_stdout_when_no_path() {
        let mut symbols = vec![Symbol {
            id: SymbolId::new(1).unwrap(),
            name: "test".into(),
            kind: SymbolKind::Function,
            language: LangId::Python,
            file_path: PathBuf::from("a.py"),
            source_range: SourceRange {
                byte_start: 0,
                byte_end: 10,
                start: LineColumn { line: 1, column: 0 },
                end: LineColumn {
                    line: 1,
                    column: 10,
                },
            },
            name_range: None,
            visibility: None,
            signature: None,
            docstring: None,
            is_async: false,
        }];
        let config = EmitConfig {
            output: None,
            format: OutputFormat::Json,
            html: false,
            open_browser: false,
        };
        emit_inspect(&mut symbols, &config).unwrap();
    }

    #[test]
    fn emit_graph_html_writes_file() {
        let temp_dir = std::env::temp_dir().join("emit_graph_html_writes_file");
        if temp_dir.exists() {
            let _ = std::fs::remove_dir_all(&temp_dir);
        }
        std::fs::create_dir_all(&temp_dir).unwrap();
        let file_path = temp_dir.join("graph.html");

        let builder = GraphBuilder::new(SnapshotId::new(1).unwrap());
        let graph = builder.build();
        let scc = SccAnalysis::analyze(graph.graph());
        let analysis = GraphAnalysis {
            graph,
            scc,
            snapshot_id: SnapshotId::new(1).unwrap(),
            extractions: vec![],
            scope: crate::graph::resolver::FlattenedScopeCache::default(),
            references: Vec::new(),
        };

        let config = EmitConfig {
            output: Some(file_path.clone()),
            format: OutputFormat::Json,
            html: true,
            open_browser: false,
        };
        emit_graph(&analysis, &config).unwrap();
        assert!(file_path.exists());
        let content = std::fs::read_to_string(file_path).unwrap();
        assert!(content.contains("<!DOCTYPE html>"));
        let _ = std::fs::remove_dir_all(&temp_dir);
    }

    #[test]
    fn emit_graph_text_json_output() {
        let temp_dir = std::env::temp_dir().join("emit_graph_text_json_output");
        if temp_dir.exists() {
            let _ = std::fs::remove_dir_all(&temp_dir);
        }
        std::fs::create_dir_all(&temp_dir).unwrap();
        let file_path = temp_dir.join("graph.json");

        let builder = GraphBuilder::new(SnapshotId::new(1).unwrap());
        let graph = builder.build();
        let scc = SccAnalysis::analyze(graph.graph());
        let analysis = GraphAnalysis {
            graph,
            scc,
            snapshot_id: SnapshotId::new(1).unwrap(),
            extractions: vec![],
            scope: crate::graph::resolver::FlattenedScopeCache::default(),
            references: Vec::new(),
        };

        let config = EmitConfig {
            output: Some(file_path.clone()),
            format: OutputFormat::Json,
            html: false,
            open_browser: false,
        };
        emit_graph(&analysis, &config).unwrap();
        assert!(file_path.exists());
        let content = std::fs::read_to_string(file_path).unwrap();
        assert!(content.contains("nodes"));
        let _ = std::fs::remove_dir_all(&temp_dir);
    }
}