merman-cli 0.8.0-alpha.3

CLI to parse/layout/render Mermaid diagrams to SVG/PNG/JPG/PDF/ASCII/Unicode (headless).
use crate::cli::{
    Cli, Command, CompletionArgs, DetectArgs, LayoutArgs, LintArgs, LintOutputFormat,
    LintRulesArgs, ParseArgs, RenderCliArgs,
};
use crate::config::{engine_for, layout_options, math_renderer, parse_options, site_config_for};
use crate::error::CliError;
use crate::io::{read_input, write_stdout, write_stdout_line};
use crate::render::{render_plan_for_mmdc, render_plan_for_subcommand, run_render};
use clap::CommandFactory;
use merman_analysis::document::analyze_document;
use merman_analysis::{AnalysisPayload, AnalysisRuleConfig, Analyzer, SourceDescriptor};
use serde::Serialize;
use serde_json::Value;
use std::fmt::Write as _;
use std::path::Path;

#[derive(Serialize)]
struct MetaOut<'a> {
    diagram_type: &'a str,
    config: &'a Value,
    effective_config: &'a Value,
    title: Option<&'a str>,
}

#[derive(Serialize)]
struct ParseOut<'a> {
    meta: MetaOut<'a>,
    model: &'a Value,
}

pub(crate) fn run(cli: Cli) -> Result<i32, CliError> {
    let exit_code = match cli.command {
        Some(Command::Detect(args)) => {
            run_detect(args)?;
            0
        }
        Some(Command::Parse(args)) => {
            run_parse(args)?;
            0
        }
        Some(Command::Layout(args)) => {
            run_layout(args)?;
            0
        }
        Some(Command::Lint(args)) => run_lint(args)?,
        Some(Command::LintRules(args)) => {
            run_lint_rules(args)?;
            0
        }
        Some(Command::Render(args)) => {
            let plan = render_plan_for_subcommand(args)?;
            run_render(plan)?;
            0
        }
        Some(Command::Completion(args)) => {
            run_completion(args)?;
            0
        }
        None => {
            let plan = render_plan_for_mmdc(cli.input, cli.export)?;
            run_render(plan)?;
            0
        }
    };
    Ok(exit_code)
}

fn run_detect(args: DetectArgs) -> Result<(), CliError> {
    let text = read_input(args.input.as_deref(), false)?;
    let engine = engine_for(&args.parse, &RenderCliArgs::default())?;
    let Some(meta) = engine.parse_metadata_sync(&text, parse_options(&args.parse))? else {
        return Err(CliError::NoDiagram);
    };
    write_stdout_line(&meta.diagram_type)?;
    Ok(())
}

fn run_parse(args: ParseArgs) -> Result<(), CliError> {
    let text = read_input(args.input.as_deref(), false)?;
    let engine = engine_for(&args.parse, &RenderCliArgs::default())?;
    let Some(parsed) = engine.parse_diagram_sync(&text, parse_options(&args.parse))? else {
        return Err(CliError::NoDiagram);
    };

    if args.meta {
        let out = ParseOut {
            meta: MetaOut {
                diagram_type: &parsed.meta.diagram_type,
                config: parsed.meta.config.as_value(),
                effective_config: parsed.meta.effective_config.as_value(),
                title: parsed.meta.title.as_deref(),
            },
            model: &parsed.model,
        };
        print_json(&out, args.pretty)?;
    } else {
        print_json(&parsed.model, args.pretty)?;
    }
    Ok(())
}

fn run_layout(args: LayoutArgs) -> Result<(), CliError> {
    let text = read_input(args.input.as_deref(), false)?;
    let engine = engine_for(&args.parse, &args.render)?;
    let layout = layout_options(&args.render, math_renderer(args.render.math_renderer)?);
    let Some(layouted) =
        merman::render::layout_diagram_sync(&engine, &text, parse_options(&args.parse), &layout)?
    else {
        return Err(CliError::NoDiagram);
    };
    print_json(&layouted, args.pretty)
}

fn run_lint(args: LintArgs) -> Result<i32, CliError> {
    let text = read_input(lint_input_path(&args), false)?;
    let source_path = lint_display_path(args.input.as_deref(), args.stdin_file_name.as_deref());
    let markdown_mode = args.markdown || is_markdown_input(source_path.as_deref());
    let source = lint_source_descriptor(markdown_mode, source_path.as_deref());
    let analyzer = Analyzer::with_options(lint_analyzer_options(&args, source.clone())?);

    let payload = analyze_document(&text, &analyzer, source);

    match args.format {
        LintOutputFormat::Json => print_json(&payload, args.pretty)?,
        LintOutputFormat::Text => print_lint_text(&payload)?,
    }

    Ok(i32::from(!payload.valid))
}

fn run_lint_rules(args: LintRulesArgs) -> Result<(), CliError> {
    match args.format {
        LintOutputFormat::Json => {
            let response = if args.configurable {
                merman_analysis::configurable_rule_catalog_response()
            } else {
                merman_analysis::rule_catalog_response()
            };
            print_json(&response, args.pretty)
        }
        LintOutputFormat::Text => {
            let catalog = if args.configurable {
                merman_analysis::configurable_rule_catalog()
            } else {
                merman_analysis::rule_catalog()
            };
            print_lint_rules_text(&catalog)
        }
    }
}

fn run_completion(args: CompletionArgs) -> Result<(), CliError> {
    let mut command = Cli::command();
    let mut output = Vec::new();
    clap_complete::generate(args.shell, &mut command, "merman-cli", &mut output);
    write_stdout(&output)
}

fn print_json<T: Serialize>(value: &T, pretty: bool) -> Result<(), CliError> {
    if pretty {
        write_stdout_line(&serde_json::to_string_pretty(value)?)?;
    } else {
        write_stdout_line(&serde_json::to_string(value)?)?;
    }
    Ok(())
}

fn print_lint_rules_text(catalog: &[merman_analysis::RuleCatalogEntry]) -> Result<(), CliError> {
    let mut output = String::new();
    output
        .push_str("ID\tSeverity\tProfile\tOrigin\tConfigurable\tFixable\tEvidence\tDescription\n");
    for rule in catalog {
        writeln!(
            output,
            "{}\t{}\t{}\t{}\t{}\t{}\t{}\t{}",
            rule.id,
            rule.default_severity.as_str(),
            rule.default_profile.as_str(),
            rule.origin.as_str(),
            rule.configurable,
            rule.fixable,
            rule.evidence.join(","),
            rule.description
        )
        .expect("writing to a String should not fail");
    }
    write_stdout(output.as_bytes())
}

fn print_lint_text(payload: &AnalysisPayload) -> Result<(), CliError> {
    let mut output = String::new();
    if payload.diagnostics.is_empty() {
        output.push_str("No Mermaid diagnostics.\n");
        return write_stdout(output.as_bytes());
    }

    let path = payload.source.path.as_deref().unwrap_or("-");
    for diagnostic in &payload.diagnostics {
        let location = diagnostic
            .span
            .as_ref()
            .map(|span| format!("{path}:{}:{}", span.line, span.column))
            .unwrap_or_else(|| path.to_string());
        writeln!(
            output,
            "{location}: {} {}: {}",
            diagnostic.severity.as_str(),
            diagnostic.id,
            diagnostic.message
        )
        .expect("writing to a String should not fail");
    }

    writeln!(
        output,
        "{} error(s), {} warning(s), {} info(s), {} hint(s)",
        payload.summary.errors,
        payload.summary.warnings,
        payload.summary.infos,
        payload.summary.hints
    )
    .expect("writing to a String should not fail");
    write_stdout(output.as_bytes())
}

fn lint_analyzer_options(
    args: &LintArgs,
    source: SourceDescriptor,
) -> Result<merman_analysis::AnalysisOptions, CliError> {
    let parse = merman::ParseOptions {
        suppress_errors: false,
    };
    let site_config = site_config_for(
        &crate::cli::ParseCliArgs {
            config_file: args.config_file.clone(),
            theme: None,
            fixed_today: args.fixed_today,
            fixed_local_offset_minutes: args.fixed_local_offset_minutes,
            ..Default::default()
        },
        &RenderCliArgs::default(),
    )?;
    Ok(merman_analysis::AnalysisOptions::default()
        .with_parse_options(parse)
        .with_source(source)
        .with_site_config(site_config)
        .with_fixed_today(args.fixed_today)
        .with_fixed_local_offset_minutes(args.fixed_local_offset_minutes)
        .with_max_source_bytes(args.max_source_bytes)
        .with_rule_config(lint_rule_config(args)))
}

fn lint_rule_config(args: &LintArgs) -> AnalysisRuleConfig {
    let mut config = AnalysisRuleConfig::default();
    if let Some(profile) = args.lint_profile {
        config.set_profile(profile);
    }
    for rule_id in &args.enable_rules {
        config.enable_rule(rule_id.clone());
    }
    for rule_id in &args.disable_rules {
        config.disable_rule(rule_id.clone());
    }
    for override_ in &args.rule_severities {
        config.set_rule_severity(override_.rule_id.clone(), override_.severity);
    }
    config
}

fn lint_display_path(input: Option<&str>, stdin_file_name: Option<&str>) -> Option<String> {
    match input {
        Some("-") | None => stdin_file_name.map(ToString::to_string),
        Some(path) => Some(path.to_string()),
    }
}

fn lint_input_path<'a>(args: &'a LintArgs) -> Option<&'a str> {
    match args.input.as_deref() {
        Some(path) => Some(path),
        None if args.stdin_file_name.is_some() => Some("-"),
        None => None,
    }
}

fn lint_source_descriptor(markdown_mode: bool, path: Option<&str>) -> SourceDescriptor {
    if markdown_mode {
        return merman_analysis::source_descriptor_for_markdown_path(path);
    }

    let mut source = SourceDescriptor::diagram();
    if let Some(path) = path {
        source = source.with_path(path);
    }
    source
}

fn is_markdown_input(input: Option<&str>) -> bool {
    input
        .map(Path::new)
        .map(merman_analysis::markdown::is_markdown_path)
        .unwrap_or(false)
}