Welcome to Chapter 8 of our journey to build a production-grade Mermaid code analyzer and fixer. In the previous chapters, we laid the foundational components: the lexer, parser, AST, validator, rule engine, and diagnostics system. These are the core engines of our tool, but without a robust command-line interface (CLI), our powerful backend remains inaccessible to users.
This chapter focuses entirely on building a user-friendly and feature-rich CLI for our mermaid-analyzer tool. We will leverage the clap crate for argument parsing, providing a familiar and intuitive experience for developers. Our CLI will support multiple output modes: lint for reporting issues, fix for applying safe transformations, and strict for enforcing the highest level of correctness. We’ll also ensure our output is clear, actionable, and visually appealing using colored terminal output, mirroring the excellent diagnostics provided by the Rust compiler itself.
By the end of this chapter, you will have a fully functional mermaid-analyzer executable that can be invoked from the terminal, taking Mermaid code as input (from files or stdin) and producing diagnostics or fixed output based on the chosen mode. This is a critical step towards making our tool practical and deployable, transforming it from a collection of libraries into a tangible, useful application.
Planning & Design
A well-designed CLI is crucial for developer experience. Our tool needs to be intuitive, provide clear feedback, and offer flexible options for different use cases.
CLI Architecture
The CLI will act as the orchestrator, taking user input (commands, flags, file paths) and coordinating the execution flow through our previously built components.
Explanation of the CLI Flow:
- User Input: The user interacts with the
mermaid-analyzerexecutable via the command line. - Argument Parsing: The
clapcrate parses the command-line arguments, identifying the subcommand (lint,fix,strict), input files, and any flags (e.g.,--output,--format). - Command Handling: Based on the subcommand, a dedicated handler function is invoked.
- Core Component Integration: Each handler orchestrates the calls to our
lexer,parser,validator,rule_engine,diagnostics, andformattermodules. - Output Formatting: A dedicated output module takes the results (diagnostics, fixed code) and formats them for terminal display, including colored output for readability.
- User Feedback: The formatted output is printed to the console, providing the user with the requested information or fixed code.
File Structure for the CLI Module
We’ll organize our CLI logic within a src/cli module to keep it separate from the core library components.
src/
├── main.rs # Entry point, calls cli::run
├── cli/
│ ├── mod.rs # Public interface for CLI, main run logic
│ ├── args.rs # Defines CLI arguments and subcommands using clap
│ ├── handlers.rs # Contains logic for each subcommand (lint, fix, strict)
│ ├── output.rs # Helpers for formatting and printing colored output
│ └── error.rs # CLI-specific error types
├── lexer/ # (existing)
├── parser/ # (existing)
├── ast/ # (existing)
├── validator/ # (existing)
├── diagnostics/ # (existing)
├── rule_engine/ # (existing)
└── formatter/ # (existing)
Command-Line Arguments
We’ll define the following structure for our CLI:
- Main Command:
mermaid-analyzer - Subcommands:
lint: Reports issues without making changes.--file <PATH>: Input Mermaid file.--format <FORMAT>: Output format (e.g.,text(default),json).--warn-as-error: Treat warnings as errors, exiting with non-zero code.
fix: Applies safe fixes and outputs corrected code.--file <PATH>: Input Mermaid file.--output <PATH>: Output file for fixed code (defaults to stdout).--check: Only report if fixes would be applied, do not write.--diff: Show a diff of changes.
strict: Combines lint and fix, failing on any issue and only applying guaranteed safe fixes.--file <PATH>: Input Mermaid file.--output <PATH>: Output file for fixed code (defaults to stdout).--check: Only report if fixes would be applied, do not write.--diff: Show a diff of changes.
- Global Options:
--verbose/-v: Increase logging verbosity.--quiet/-q: Suppress non-error output.--color <WHEN>: Control colored output (e.g.,auto,always,never).
Step-by-Step Implementation
a) Setup/Configuration
First, let’s add the necessary dependencies to our Cargo.toml. We’ll use clap for argument parsing, owo-colors for colored terminal output, anyhow for simplified error handling, tracing for structured logging, and tracing-subscriber to configure it.
Cargo.toml
[package]
name = "mermaid-analyzer"
version = "0.1.0"
edition = "2021"
[dependencies]
# CLI Argument parsing
clap = { version = "4.5", features = ["derive"] }
# Colored terminal output
owo-colors = "3.5"
# Flexible error handling
anyhow = "1.0"
# Structured logging
tracing = "0.1"
tracing-subscriber = { version = "0.3", features = ["env-filter"] }
# Diffing library for fix mode
similar = "2.2"
# Text formatting for diagnostics
termwidth = "0.1" # A simple crate to get terminal width for better formatting
# Internal crates (assuming a workspace or path dependencies)
# For a single project, these would be modules within src/lib.rs or direct files.
# For this tutorial, we'll assume they are modules directly within src/.
# In a real-world multi-crate project, these would be:
# mermaid-analyzer-lexer = { path = "./crates/lexer" }
# mermaid-analyzer-parser = { path = "./crates/parser" }
# ...
# But for simplicity, we'll treat them as modules:
# (No explicit Cargo.toml entry for internal modules)
Next, let’s set up our src/main.rs to initialize logging and delegate to our cli module.
src/main.rs
use anyhow::Result;
use tracing_subscriber::{EnvFilter, FmtSubscriber};
mod cli;
mod lexer;
mod parser;
mod ast;
mod validator;
mod diagnostics;
mod rule_engine;
mod formatter;
#[tokio::main] // Assuming we might use async I/O in the future, good practice
async fn main() -> Result<()> {
// Initialize logging
// Reads RUST_LOG environment variable, e.g., RUST_LOG="info"
let subscriber = FmtSubscriber::builder()
.with_env_filter(EnvFilter::from_default_env())
.finish();
tracing::subscriber::set_global_default(subscriber)
.expect("setting default subscriber failed");
cli::run_cli().await
}
Explanation:
anyhow::Result: Simplifies error handling by allowing?operator for functions returningResult.tracingandtracing_subscriber: We initialize a tracing subscriber that reads theRUST_LOGenvironment variable. This allows users to control logging verbosity (e.g.,RUST_LOG=debug mermaid-analyzer lint ...).mod cli;: Declares ourclimodule, which will contain all CLI-specific logic.#[tokio::main]: While our current CLI might not be heavily async, usingtokio::mainsets up an async runtime, which is a good forward-looking practice for Rust applications that might involve network requests or complex file I/O. For now, it simply allowsasync fn main().cli::run_cli().await: Delegates the main CLI execution to ourclimodule.
b) Core Implementation - CLI Argument Parsing
We’ll define the CLI structure using clap’s derive macros.
src/cli/args.rs
use clap::{Parser, Subcommand, ValueEnum};
use std::path::PathBuf;
/// A strict Mermaid diagram analyzer and fixer.
#[derive(Parser, Debug)]
#[command(author, version, about, long_about = None)]
pub struct Cli {
/// Path to the Mermaid file to process. If not provided, reads from stdin.
#[arg(short, long, value_name = "FILE")]
pub file: Option<PathBuf>,
/// Controls when to use colors.
#[arg(long, default_value_t = ColorWhen::Auto, value_enum)]
pub color: ColorWhen,
/// Increase logging verbosity.
#[arg(short, long, action = clap::ArgAction::Count)]
pub verbose: u8,
/// Suppress all non-error output.
#[arg(short, long)]
pub quiet: bool,
#[command(subcommand)]
pub command: Command,
}
#[derive(Subcommand, Debug)]
pub enum Command {
/// Lints a Mermaid file for syntax and semantic errors.
Lint(LintOptions),
/// Fixes common Mermaid issues and outputs the corrected code.
Fix(FixOptions),
/// Lints and automatically fixes issues, failing on any remaining error or warning.
Strict(StrictOptions),
}
#[derive(Parser, Debug)]
pub struct LintOptions {
/// Output format for diagnostics.
#[arg(long, default_value_t = LintFormat::Text, value_enum)]
pub format: LintFormat,
/// Treat all warnings as errors, exiting with a non-zero status code.
#[arg(long)]
pub warn_as_error: bool,
}
#[derive(Parser, Debug)]
pub struct FixOptions {
/// Path to write the fixed Mermaid code. If not provided, prints to stdout.
#[arg(short, long, value_name = "FILE")]
pub output: Option<PathBuf>,
/// Only check if fixes would be applied, do not write changes.
#[arg(long)]
pub check: bool,
/// Show a diff of the changes applied.
#[arg(long)]
pub diff: bool,
}
#[derive(Parser, Debug)]
pub struct StrictOptions {
/// Path to write the fixed Mermaid code. If not provided, prints to stdout.
#[arg(short, long, value_name = "FILE")]
pub output: Option<PathBuf>,
/// Only check if fixes would be applied, do not write changes.
#[arg(long)]
pub check: bool,
/// Show a diff of the changes applied.
#[arg(long)]
pub diff: bool,
}
#[derive(ValueEnum, Debug, Clone, Copy)]
pub enum ColorWhen {
Auto,
Always,
Never,
}
#[derive(ValueEnum, Debug, Clone, Copy)]
pub enum LintFormat {
Text,
Json,
}
Explanation:
#[derive(Parser, Debug)]:clapmacros to automatically generate argument parsing logic.Clistruct: Represents the top-level command, holding global options and subcommands.#[command(author, version, about, long_about = None)]: Automatically populates help messages fromCargo.toml.#[arg(...)]: Attributes for individual arguments, defining short/long flags, default values, value names, etc.Subcommandenum: Defines thelint,fix, andstrictsubcommands, each with its own options struct.ValueEnum: Used for custom enum types thatclapshould parse from string inputs (e.g.,ColorWhen,LintFormat).
Now, let’s create the src/cli/mod.rs file to integrate clap and define the main run_cli function.
src/cli/mod.rs
use anyhow::{Context, Result};
use clap::Parser;
use tracing::{debug, error, info, warn};
pub mod args;
pub mod handlers;
pub mod output;
pub mod error; // We'll define CLI-specific errors here later
use args::{Cli, Command};
use handlers::{handle_fix_command, handle_lint_command, handle_strict_command};
/// Entry point for the CLI application.
pub async fn run_cli() -> Result<()> {
let cli = Cli::parse();
// Configure logging verbosity based on CLI flags
// This overrides RUST_LOG if specified via CLI
let log_level = match cli.verbose {
0 => "info",
1 => "debug",
_ => "trace",
};
if cli.quiet {
// Quiet mode overrides verbosity, only show errors
std::env::set_var("RUST_LOG", "error");
} else {
std::env::set_var("RUST_LOG", log_level);
}
// Re-initialize tracing subscriber with potentially updated RUST_LOG
// NOTE: In a production app, you might want a more sophisticated way
// to dynamically adjust logging without re-initializing the global subscriber.
// For simplicity, we assume this is called once at startup.
tracing_subscriber::fmt::fmt()
.with_env_filter(
tracing_subscriber::EnvFilter::from_default_env()
.add_directive(log_level.parse().unwrap())
)
.init();
info!("CLI arguments parsed: {:?}", cli);
let input_code = match &cli.file {
Some(path) => {
debug!("Reading input from file: {}", path.display());
tokio::fs::read_to_string(path)
.await
.with_context(|| format!("Failed to read file: {}", path.display()))?
}
None => {
debug!("Reading input from stdin");
let mut buffer = String::new();
tokio::io::stdin()
.read_to_string(&mut buffer)
.await
.context("Failed to read from stdin")?
}
};
match cli.command {
Command::Lint(options) => {
handle_lint_command(&input_code, cli.file.as_deref(), &options, cli.color).await
}
Command::Fix(options) => {
handle_fix_command(&input_code, cli.file.as_deref(), &options, cli.color).await
}
Command::Strict(options) => {
handle_strict_command(&input_code, cli.file.as_deref(), &options, cli.color).await
}
}
}
Explanation:
Cli::parse(): This is whereclapdoes its magic, parsing command-line arguments into ourClistruct.- Logging Configuration: We dynamically adjust the
RUST_LOGenvironment variable based on--verboseor--quietflags. This allows users to control the verbosity ofinfo!,debug!,trace!messages. Note the re-initialization of the tracing subscriber for simplicity; in a more complex application, you might use areload_handlefromtracing-subscriber. - Input Handling: The CLI can read Mermaid code either from a specified file (
--file) or from standard input (stdin). This is crucial for pipeline integration (e.g.,cat diagram.mmd | mermaid-analyzer lint). We usetokio::fs::read_to_stringandtokio::io::stdin().read_to_stringfor async file/stdin reading. - Command Dispatch: A
matchstatement dispatches to the appropriate handler function based on the parsed subcommand. Each handler receives the input code, optional file path, subcommand-specific options, and color preference.
c) Core Implementation - Output Formatting
Before implementing handlers, let’s create a utility module for consistent, colored output.
src/cli/output.rs
use crate::diagnostics::{Diagnostic, DiagnosticSeverity, Span};
use crate::cli::args::ColorWhen;
use owo_colors::{OwoColorize, Style};
use std::io::{self, Write};
use std::path::Path;
use tracing::warn;
use similar::{ChangeTag, TextDiff};
/// Determines if colored output should be used.
pub fn should_color(color_when: ColorWhen) -> bool {
match color_when {
ColorWhen::Always => true,
ColorWhen::Never => false,
ColorWhen::Auto => atty::is(atty::Stream::Stdout),
}
}
/// Styles a message based on diagnostic severity.
fn get_severity_style(severity: DiagnosticSeverity) -> Style {
match severity {
DiagnosticSeverity::Error => Style::new().red().bold(),
DiagnosticSeverity::Warning => Style::new().yellow().bold(),
DiagnosticSeverity::Info => Style::new().blue().bold(),
DiagnosticSeverity::Hint => Style::new().cyan().bold(),
}
}
/// Formats and prints a single diagnostic message.
pub fn print_diagnostic(
diagnostic: &Diagnostic,
source_code: &str,
file_path: Option<&Path>,
color_enabled: bool,
) {
let mut stdout = io::stdout().lock();
// Determine the base style for the severity label
let severity_style = get_severity_style(diagnostic.severity);
let severity_label = format!("{}", diagnostic.severity).to_uppercase();
// Print header: file:line:col - SEVERITY[CODE]: Message
let file_info = if let Some(path) = file_path {
format!("{}:{}:{} ", path.display(), diagnostic.span.start_line, diagnostic.span.start_col)
} else {
String::new()
};
let header_message = format!(
"{}{}{}[{}]: {}",
file_info,
if color_enabled { severity_label.style(severity_style) } else { severity_label.normal() },
if color_enabled { ": ".bold() } else { ": ".normal() },
if color_enabled { diagnostic.code.bold() } else { diagnostic.code.normal() },
if color_enabled { diagnostic.message.bold() } else { diagnostic.message.normal() },
);
writeln!(stdout, "{}", header_message).expect("Failed to write to stdout");
// Print code snippet with highlight
if let Some(code_lines) = source_code.lines().nth(diagnostic.span.start_line - 1) {
let line_num_str = format!("{} | ", diagnostic.span.start_line);
let padding = " ".repeat(line_num_str.len());
writeln!(stdout, "{} {}", padding, if color_enabled { "|".blue() } else { "|".normal() }).expect("Failed to write to stdout");
writeln!(stdout, "{}{}", if color_enabled { line_num_str.blue() } else { line_num_str.normal() }, code_lines).expect("Failed to write to stdout");
// Calculate highlight span within the line
let highlight_start_col = diagnostic.span.start_col - 1;
let highlight_end_col = if diagnostic.span.start_line == diagnostic.span.end_line {
diagnostic.span.end_col - 1
} else {
// If diagnostic spans multiple lines, highlight to end of current line
code_lines.len()
};
let highlight_len = highlight_end_col.saturating_sub(highlight_start_col).max(1); // Ensure at least 1 char is highlighted
let highlight = format!(
"{}{}",
" ".repeat(highlight_start_col),
"^".repeat(highlight_len)
);
writeln!(stdout, "{} {}", padding, if color_enabled { highlight.red().bold() } else { highlight.normal() }).expect("Failed to write to stdout");
} else {
warn!("Could not retrieve source line {} for diagnostic.", diagnostic.span.start_line);
}
// Print help message if available
if let Some(help) = &diagnostic.help {
let help_label = if color_enabled { " help:".green().bold() } else { " help:".normal() };
writeln!(stdout, "{} {}", help_label, help).expect("Failed to write to stdout");
}
writeln!(stdout).expect("Failed to write to stdout"); // Empty line for separation
}
/// Prints a summary of applied fixes.
pub fn print_fix_summary(
fixed_count: usize,
warning_count: usize,
error_count: usize,
color_enabled: bool,
) {
let mut stdout = io::stdout().lock();
let total_issues = warning_count + error_count;
writeln!(stdout, "--- Analysis Summary ---").expect("Failed to write to stdout");
if fixed_count > 0 {
writeln!(stdout, "{} {} issues fixed.", if color_enabled { fixed_count.to_string().green().bold() } else { fixed_count.to_string().normal() }, if fixed_count == 1 { "issue" } else { "issues" }).expect("Failed to write to stdout");
}
if warning_count > 0 {
writeln!(stdout, "{} {} warnings.", if color_enabled { warning_count.to_string().yellow().bold() } else { warning_count.to_string().normal() }, if warning_count == 1 { "warning" } else { "warnings" }).expect("Failed to write to stdout");
}
if error_count > 0 {
writeln!(stdout, "{} {} errors.", if color_enabled { error_count.to_string().red().bold() } else { error_count.to_string().normal() }, if error_count == 1 { "error" } else { "errors" }).expect("Failed to write to stdout");
}
if total_issues == 0 && fixed_count == 0 {
writeln!(stdout, "No issues found. Diagram is {}!", if color_enabled { "clean".green().bold() } else { "clean".normal() }).expect("Failed to write to stdout");
}
writeln!(stdout, "------------------------").expect("Failed to write to stdout");
}
/// Prints a unified diff between original and fixed code.
pub fn print_diff(original: &str, fixed: &str, color_enabled: bool) {
let diff = TextDiff::from_lines(original, fixed);
let mut stdout = io::stdout().lock();
writeln!(stdout, "--- Diff ---").expect("Failed to write to stdout");
for change in diff.iter_all_changes() {
let line = format!("{}", change.value());
match change.tag() {
ChangeTag::Delete => writeln!(stdout, "{}", if color_enabled { format!("-{}", line).red() } else { format!("-{}", line).normal() }),
ChangeTag::Insert => writeln!(stdout, "{}", if color_enabled { format!("+{}", line).green() } else { format!("+{}", line).normal() }),
ChangeTag::Equal => writeln!(stdout, "{}", format!(" {}", line).normal()), // No color for equal lines
}.expect("Failed to write to stdout");
}
writeln!(stdout, "------------").expect("Failed to write to stdout");
}
Explanation:
should_color: Usesattycrate to detect if stdout is a TTY, enabling colors by default only for interactive terminals.get_severity_style: MapsDiagnosticSeveritytoowo-colorsstyles.print_diagnostic: This is the core function for displaying diagnostic messages. It mimicsrustc’s output format, including:- File path, line, and column.
- Colored severity label (ERROR, WARNING).
- Diagnostic code and message.
- Code snippet with a highlight underneath the problematic span.
- Optional help message.
print_fix_summary: Provides a concise summary of how many issues were found and fixed.print_diff: Uses thesimilarcrate to generate and print a unified diff between two strings, highlighting additions and deletions.
d) Core Implementation - CLI-specific Errors
A custom error type for the CLI can help distinguish between different failure modes.
src/cli/error.rs
use thiserror::Error;
#[derive(Error, Debug)]
pub enum CliError {
#[error("Failed to read input: {0}")]
InputReadError(#[from] std::io::Error),
#[error("Failed to write output to '{}': {0}", .0.display())]
OutputWriteError(std::path::PathBuf, #[source] std::io::Error),
#[error("Invalid Mermaid syntax: {0}")]
SyntaxError(String), // Simplified for now, will integrate with diagnostics
#[error("Processing failed: {0}")]
ProcessingError(String), // General processing error
#[error("Strict mode failure: {0} errors and {1} warnings found.")]
StrictModeFailure(usize, usize),
}
Explanation:
thiserror::Error: A derive macro that simplifies implementing theErrortrait, making custom errors easy to define and use.CliError: Defines various error conditions specific to the CLI’s operation, such as file I/O issues or strict mode violations.
e) Core Implementation - Handlers for lint, fix, strict
Now we’ll implement the actual logic for each subcommand in src/cli/handlers.rs. These handlers will orchestrate the calls to our lexer, parser, validator, rule engine, and formatter.
src/cli/handlers.rs
use anyhow::{Context, Result};
use std::io::{self, Write};
use std::path::Path;
use tracing::{debug, error, info, warn};
use crate::cli::args::{ColorWhen, LintFormat, LintOptions, FixOptions, StrictOptions};
use crate::cli::output::{should_color, print_diagnostic, print_fix_summary, print_diff};
use crate::cli::error::CliError;
use crate::lexer::Lexer;
use crate::parser::Parser;
use crate::validator::Validator;
use crate::diagnostics::{Diagnostic, DiagnosticSeverity, DiagnosticCollection};
use crate::rule_engine::{RuleEngine, RuleApplicationResult};
use crate::formatter::Formatter;
/// Handles the 'lint' subcommand.
pub async fn handle_lint_command(
input_code: &str,
file_path: Option<&Path>,
options: &LintOptions,
color_when: ColorWhen,
) -> Result<()> {
info!("Executing 'lint' command.");
let color_enabled = should_color(color_when);
let mut diagnostics = DiagnosticCollection::new();
// 1. Lexing
debug!("Starting lexing.");
let tokens = Lexer::new(input_code).tokenize(&mut diagnostics);
debug!("Lexing complete. Found {} tokens.", tokens.len());
// 2. Parsing
debug!("Starting parsing.");
let parse_result = Parser::new(tokens).parse(&mut diagnostics);
let ast = match parse_result {
Ok(ast) => {
debug!("Parsing complete. AST generated.");
Some(ast)
},
Err(_) => {
error!("Parsing failed. Diagnostics collected.");
None // Parser error means we can't build a full AST, but diagnostics are crucial
}
};
// 3. Validation (only if AST was successfully built)
if let Some(ast_root) = &ast {
debug!("Starting validation.");
Validator::new().validate(ast_root, &mut diagnostics);
debug!("Validation complete.");
} else {
warn!("Skipping semantic validation due to parsing errors.");
}
// Output diagnostics
match options.format {
LintFormat::Text => {
for diag in diagnostics.iter() {
print_diagnostic(diag, input_code, file_path, color_enabled);
}
}
LintFormat::Json => {
// In a real-world scenario, you'd serialize to a proper JSON array of diagnostics.
// For now, we'll just print a basic representation.
let json_output = serde_json::to_string_pretty(diagnostics.iter().collect::<Vec<_>>())
.context("Failed to serialize diagnostics to JSON")?;
println!("{}", json_output);
}
}
let error_count = diagnostics.errors().count();
let warning_count = diagnostics.warnings().count();
if error_count > 0 || (options.warn_as_error && warning_count > 0) {
error!(
"Linting failed: {} errors, {} warnings.",
error_count, warning_count
);
// Exit with a non-zero status code to indicate failure in CI/CD pipelines
std::process::exit(1);
} else if warning_count > 0 {
info!("Linting complete: {} warnings found.", warning_count);
} else {
info!("Linting complete: No issues found.");
}
Ok(())
}
/// Handles the 'fix' subcommand.
pub async fn handle_fix_command(
input_code: &str,
file_path: Option<&Path>,
options: &FixOptions,
color_when: ColorWhen,
) -> Result<()> {
info!("Executing 'fix' command.");
let color_enabled = should_color(color_when);
let mut diagnostics = DiagnosticCollection::new();
// 1. Lexing
let tokens = Lexer::new(input_code).tokenize(&mut diagnostics);
// 2. Parsing
let mut ast = Parser::new(tokens).parse(&mut diagnostics)
.map_err(|e| CliError::ProcessingError(format!("Parsing failed: {}", e)))?;
// 3. Apply rules to fix AST
debug!("Applying rule engine fixes.");
let rule_engine = RuleEngine::new(); // In a real app, this would be configured with specific rules
let fix_result = rule_engine.apply_fixes(&mut ast, &mut diagnostics);
debug!("Rule engine applied {} fixes.", fix_result.fixes_applied);
// 4. Format the fixed AST back to Mermaid code
let formatter = Formatter::new(); // Formatter might have options later
let fixed_code = formatter.format(&ast);
// Output diagnostics (if any remained after fixes)
for diag in diagnostics.iter() {
print_diagnostic(diag, input_code, file_path, color_enabled);
}
if options.check {
if fix_result.fixes_applied > 0 {
info!("'--check' mode: {} fixes would be applied.", fix_result.fixes_applied);
if options.diff {
print_diff(input_code, &fixed_code, color_enabled);
}
std::process::exit(1); // Indicate that changes are needed
} else {
info!("'--check' mode: No fixes needed.");
}
} else {
if options.diff {
print_diff(input_code, &fixed_code, color_enabled);
}
match &options.output {
Some(path) => {
debug!("Writing fixed code to file: {}", path.display());
tokio::fs::write(path, fixed_code)
.await
.map_err(|e| CliError::OutputWriteError(path.clone(), e))?;
info!("Fixed code written to {}", path.display());
}
None => {
debug!("Printing fixed code to stdout.");
io::stdout().write_all(fixed_code.as_bytes())
.context("Failed to write fixed code to stdout")?;
writeln!(io::stdout()).context("Failed to write newline")?; // Ensure a final newline
}
}
}
print_fix_summary(
fix_result.fixes_applied,
diagnostics.warnings().count(),
diagnostics.errors().count(),
color_enabled,
);
let error_count = diagnostics.errors().count();
if error_count > 0 {
error!("Fix command finished with {} errors.", error_count);
std::process::exit(1);
}
Ok(())
}
/// Handles the 'strict' subcommand.
pub async fn handle_strict_command(
input_code: &str,
file_path: Option<&Path>,
options: &StrictOptions,
color_when: ColorWhen,
) -> Result<()> {
info!("Executing 'strict' command.");
let color_enabled = should_color(color_when);
let mut diagnostics = DiagnosticCollection::new();
// 1. Lexing
let tokens = Lexer::new(input_code).tokenize(&mut diagnostics);
// 2. Parsing
let mut ast = Parser::new(tokens).parse(&mut diagnostics)
.map_err(|e| CliError::ProcessingError(format!("Parsing failed: {}", e)))?;
// Initial check for errors before applying any fixes in strict mode
// Strict mode fails immediately on *any* error or warning that cannot be safely fixed.
let initial_error_count = diagnostics.errors().count();
let initial_warning_count = diagnostics.warnings().count();
if initial_error_count > 0 {
for diag in diagnostics.iter() {
print_diagnostic(diag, input_code, file_path, color_enabled);
}
return Err(CliError::StrictModeFailure(initial_error_count, initial_warning_count).into());
}
// 3. Apply rules to fix AST (only if no initial errors)
debug!("Applying rule engine fixes in strict mode.");
let rule_engine = RuleEngine::new(); // Configured for strict, safe fixes
let fix_result = rule_engine.apply_safe_fixes(&mut ast, &mut diagnostics); // Assuming RuleEngine has apply_safe_fixes
debug!("Rule engine applied {} safe fixes.", fix_result.fixes_applied);
// Re-validate and check for remaining issues after fixes
let mut post_fix_diagnostics = DiagnosticCollection::new();
Validator::new().validate(&ast, &mut post_fix_diagnostics); // Re-validate fixed AST
// Combine diagnostics
diagnostics.extend(post_fix_diagnostics);
// Output all diagnostics
for diag in diagnostics.iter() {
print_diagnostic(diag, input_code, file_path, color_enabled);
}
let final_error_count = diagnostics.errors().count();
let final_warning_count = diagnostics.warnings().count();
if final_error_count > 0 || final_warning_count > 0 {
error!(
"Strict mode failed: {} errors and {} warnings remain after fixes.",
final_error_count, final_warning_count
);
return Err(CliError::StrictModeFailure(final_error_count, final_warning_count).into());
}
// 4. Format the fixed AST back to Mermaid code
let formatter = Formatter::new();
let fixed_code = formatter.format(&ast);
if options.check {
if fix_result.fixes_applied > 0 {
info!("'--check' mode (strict): {} safe fixes would be applied.", fix_result.fixes_applied);
if options.diff {
print_diff(input_code, &fixed_code, color_enabled);
}
std::process::exit(1); // Indicate that changes are needed
} else {
info!("'--check' mode (strict): No safe fixes needed.");
}
} else {
if options.diff {
print_diff(input_code, &fixed_code, color_enabled);
}
match &options.output {
Some(path) => {
debug!("Writing fixed code to file: {}", path.display());
tokio::fs::write(path, fixed_code)
.await
.map_err(|e| CliError::OutputWriteError(path.clone(), e))?;
info!("Fixed code written to {}", path.display());
}
None => {
debug!("Printing fixed code to stdout.");
io::stdout().write_all(fixed_code.as_bytes())
.context("Failed to write fixed code to stdout")?;
writeln!(io::stdout()).context("Failed to write newline")?;
}
}
}
print_fix_summary(
fix_result.fixes_applied,
final_warning_count,
final_error_count,
color_enabled,
);
Ok(())
}
Explanation:
- Each handler function (
handle_lint_command,handle_fix_command,handle_strict_command) follows a similar pattern:- Initialization: Determine color preference, create a
DiagnosticCollection. - Lexing: Creates a
Lexerand tokenizes the input. - Parsing: Creates a
Parserand attempts to build an AST. If parsing fails, the AST might beNone, but diagnostics are still collected. - Validation: If an AST was successfully built, a
Validatorchecks for semantic issues. - Rule Application (for
fixandstrict): ARuleEngineapplies fixes to the AST.strictmode might call a specificapply_safe_fixesmethod (which you would implement in yourrule_enginemodule). - Formatting (for
fixandstrict): AFormatterconverts the (potentially modified) AST back into Mermaid code. - Output:
- Diagnostics are printed using
print_diagnostic. - Fixed code is written to a file or stdout.
- Diffs are printed if requested.
- Summaries are printed.
- Diagnostics are printed using
- Error Handling & Exit Codes: Crucially, the handlers return
Result<()>and useanyhowfor error propagation. They also usestd::process::exit(1)(or propagate an error that causesmainto exit with 1) to indicate failure, which is vital for CI/CD pipelines.
- Initialization: Determine color preference, create a
LintFormat::Json: A placeholder is included for JSON output. In a real application, you would need to deriveserde::Serializefor yourDiagnosticstruct and useserde_jsonto serialize the collection.StrictModeFailure: A specific error type is returned for strict mode failures, clearly indicating why the tool exited.
f) Integrating with diagnostics, rule_engine, formatter (Recap and Assumed API)
For the handlers above to work, your diagnostics, rule_engine, and formatter modules (from previous chapters) need to expose specific public APIs. Let’s quickly review the assumed interfaces:
src/diagnostics/mod.rs (Expected API)
// Simplified example, actual implementation would be more robust
pub struct Diagnostic {
pub severity: DiagnosticSeverity,
pub code: String,
pub message: String,
pub span: Span,
pub help: Option<String>,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum DiagnosticSeverity {
Error,
Warning,
Info,
Hint,
}
pub struct DiagnosticCollection {
// ...
}
impl DiagnosticCollection {
pub fn new() -> Self { /* ... */ }
pub fn add(&mut self, diagnostic: Diagnostic) { /* ... */ }
pub fn iter(&self) -> impl Iterator<Item = &Diagnostic> { /* ... */ }
pub fn errors(&self) -> impl Iterator<Item = &Diagnostic> { /* ... */ }
pub fn warnings(&self) -> impl Iterator<Item = &Diagnostic> { /* ... */ }
pub fn extend(&mut self, other: DiagnosticCollection) { /* ... */ }
pub fn has_errors(&self) -> bool { /* ... */ }
pub fn has_warnings(&self) -> bool { /* ... */ }
}
src/rule_engine/mod.rs (Expected API)
// Simplified example, actual implementation would be more robust
use crate::ast::AstNode; // Assuming AstNode is your root AST type
use crate::diagnostics::DiagnosticCollection;
pub struct RuleApplicationResult {
pub fixes_applied: usize,
pub issues_introduced: usize, // e.g., if a fix causes a new issue
}
pub struct RuleEngine { /* ... */ }
impl RuleEngine {
pub fn new() -> Self { /* ... */ }
/// Applies all rules, including potentially aggressive ones.
pub fn apply_fixes(&self, ast: &mut AstNode, diagnostics: &mut DiagnosticCollection) -> RuleApplicationResult {
// ... iterate through rules, call rule.apply()
RuleApplicationResult { fixes_applied: 0, issues_introduced: 0 }
}
/// Applies only safe, non-controversial fixes.
pub fn apply_safe_fixes(&self, ast: &mut AstNode, diagnostics: &mut DiagnosticCollection) -> RuleApplicationResult {
// ... iterate through rules marked as 'safe', call rule.apply()
RuleApplicationResult { fixes_applied: 0, issues_introduced: 0 }
}
}
src/formatter/mod.rs (Expected API)
// Simplified example, actual implementation would be more robust
use crate::ast::AstNode; // Assuming AstNode is your root AST type
pub struct Formatter { /* ... */ }
impl Formatter {
pub fn new() -> Self { /* ... */ }
pub fn format(&self, ast: &AstNode) -> String {
// ... walk the AST and reconstruct the Mermaid code
"formatted mermaid code".to_string()
}
}
These are simplified representations. Your actual implementations from previous chapters should match these general public interfaces for the CLI to integrate smoothly.
Testing This Component
To test the CLI, we can compile the project and run various commands.
Build the project:
cargo build --releaseThis will create an executable at
target/release/mermaid-analyzer.Create a sample Mermaid file (e.g.,
test.mmd):graph TD A[Start] --> B(Process) B -- Invalid Arrow -> C{Decision} C -- Yes --> D[End] C -- No --X E[Error]Test
lintmode:target/release/mermaid-analyzer lint --file test.mmdExpected output: You should see diagnostic messages for the “Invalid Arrow” and potentially other issues, similar to Rust compiler output, with line and column numbers. The exit code should be 1 if errors are found, 0 otherwise.
# Test with warnings as errors target/release/mermaid-analyzer lint --file test.mmd --warn-as-error # Test JSON output target/release/mermaid-analyzer lint --file test.mmd --format json # Test stdin input echo "graph TD; A-->B" | target/release/mermaid-analyzer lintTest
fixmode:# This assumes your rule engine has a rule to fix "Invalid Arrow" target/release/mermaid-analyzer fix --file test.mmd --output fixed.mmd --diffExpected output: You should see a diff showing the changes, and a new
fixed.mmdfile containing the corrected Mermaid code.# Test --check flag target/release/mermaid-analyzer fix --file test.mmd --check # This should exit with code 1 if fixes are needed, 0 if not.Test
strictmode:# If the file has unfixable errors or remaining warnings, this should fail. target/release/mermaid-analyzer strict --file test.mmdExpected output: If
test.mmdstill has issues thatstrictmode deems unacceptable (either unfixable errors or warnings that strict mode doesn’t allow), it should print diagnostics and exit with a non-zero status. If it successfully fixes all issues and finds no remaining problems, it should print the fixed code (or write to--output) and exit with 0.Test
--colorand--verboseflags:target/release/mermaid-analyzer lint --file test.mmd --color always RUST_LOG=debug target/release/mermaid-analyzer lint --file test.mmd --verboseExpected output: Colored output should always be present, and you should see
debug!level logs when--verboseis used.
Production Considerations
When deploying a CLI tool, several factors ensure it’s robust, performant, and maintainable.
Error Handling:
- User-friendly messages: All errors, especially those originating from file I/O or parsing, should be presented in a clear, actionable way to the user, not just raw Rust panics or backtraces.
anyhowandthiserrorhelp achieve this. - Exit Codes: Crucial for CI/CD.
0for success,1for general failure (e.g., linting errors, unfixable issues), and potentially2for configuration errors or invalid arguments. Our handlers already usestd::process::exit(1). - Graceful Shutdown: Ensure temporary files are cleaned up and resources released even on error.
- User-friendly messages: All errors, especially those originating from file I/O or parsing, should be presented in a clear, actionable way to the user, not just raw Rust panics or backtraces.
Performance Optimization:
- Efficient I/O: Reading from stdin or large files should be non-blocking where possible or use buffered readers. Our use of
tokio::fsandtokio::io::stdinprovides async capabilities, which can be beneficial for very large inputs. - Minimal Allocations: Avoid excessive string copying or vector reallocations in core loops (lexer, parser, formatter). This is generally handled by careful design in the underlying modules.
- Lazy Loading: If the tool were to support many diagram types or complex rules, only loading necessary components could improve startup time. For our current scope, this isn’t a major concern.
- Efficient I/O: Reading from stdin or large files should be non-blocking where possible or use buffered readers. Our use of
Security Considerations:
- Path Traversal: When accepting file paths from user input, ensure they are properly sanitized or resolved to prevent malicious users from accessing unintended files (e.g.,
../etc/passwd).std::path::PathBufand canonicalization (path.canonicalize()) can help, though for simple read/write operations within the current directory, it’s less of a direct exploit vector. - Input Validation: While our lexer and parser handle invalid Mermaid syntax, ensure that the input itself isn’t excessively large, leading to resource exhaustion (DoS). Streaming input could mitigate this for extremely large files, but for typical Mermaid diagrams, direct reading is fine.
- Path Traversal: When accepting file paths from user input, ensure they are properly sanitized or resolved to prevent malicious users from accessing unintended files (e.g.,
Logging and Monitoring:
- Structured Logging:
tracingprovides structured logs that can be easily parsed by log aggregators. This is invaluable for debugging in production or understanding tool behavior. - Configurable Verbosity: Our
--verboseand--quietflags, combined withRUST_LOG, allow users to control how much information the tool outputs. - Metrics (Future): For a highly critical tool, integrating metrics (e.g., processing time, number of fixes applied) could be useful for performance monitoring.
- Structured Logging:
Code Review Checkpoint
At this point, we have significantly enhanced our mermaid-analyzer by building a robust and user-friendly CLI.
Summary of what was built:
- A
src/climodule encapsulating all CLI logic. - Argument parsing using
clapforlint,fix, andstrictsubcommands, along with global options. - Input handling from files or stdin.
- Handlers for each subcommand that orchestrate the lexing, parsing, validation, rule application, and formatting steps.
- A dedicated
src/cli/output.rsmodule for consistent, colored terminal output, including Rust-compiler-like diagnostics and diffs. - CLI-specific error handling using
thiserror. - Integration with
tracingfor configurable logging.
Files created/modified:
Cargo.toml: Addedclap,owo-colors,anyhow,tracing,tracing-subscriber,similar,termwidth.src/main.rs: Initializedtracing, delegated tocli::run_cli.src/cli/mod.rs: Main CLI entry point, argument parsing, input handling, command dispatch.src/cli/args.rs:clapstructs for CLI arguments.src/cli/handlers.rs: Logic forlint,fix,strictsubcommands.src/cli/output.rs: Functions for colored diagnostics, fix summaries, and diffs.src/cli/error.rs: Custom error types for CLI operations.
How it integrates with existing code:
The CLI acts as the top-level orchestrator, calling the public APIs of our previously developed lexer, parser, ast, validator, diagnostics, rule_engine, and formatter modules. It provides the input, receives diagnostics or processed ASTs, and then formats the output for the user. It does not modify the core logic of these underlying components; rather, it consumes their services.
Common Issues & Solutions
Issue: “command not found: mermaid-analyzer”
- Cause: The executable is not in your system’s PATH, or you haven’t built it.
- Solution:
- Ensure you’ve run
cargo build --release. - Call the executable directly using its full path:
target/release/mermaid-analyzer .... - For easier use, add
target/releaseto your system’s PATH, or install the tool globally:cargo install --path .(from the project root, aftercargo build).
- Ensure you’ve run
Issue:
clapparsing errors or unexpected argument behavior.- Cause: Mismatch between
clapattribute configuration and how arguments are passed on the command line, or incorrectValueEnumvariants. - Solution:
- Carefully review
src/cli/args.rsfor correct#[arg(...)]and#[command(...)]attributes. - Check
clap’s generated help message:mermaid-analyzer --helpormermaid-analyzer lint --help. This is often the quickest way to spot discrepancies. - Ensure
ValueEnumvariants are uppercase (or match the expected string input case) if you’re passing them as command-line arguments.
- Carefully review
- Cause: Mismatch between
Issue: No colored output / unexpected color behavior.
- Cause:
owo-colors(oratty) might not detect a TTY, or the--colorflag is overriding. - Solution:
- Explicitly use
--color always:mermaid-analyzer lint --file test.mmd --color always. - Check if your terminal supports ANSI escape codes. Some minimal environments might not.
- If piping output (e.g.,
mermaid-analyzer lint --file test.mmd | less -R),attywill correctly detect that stdout is not a TTY and disable colors.less -Ris often needed to view colored output from piped commands.
- Explicitly use
- Cause:
Issue: “Failed to read file: No such file or directory”
- Cause: The
--filepath is incorrect, or the file doesn’t exist. - Solution:
- Double-check the file path for typos.
- Ensure the file has read permissions.
- Use an absolute path or a path relative to where you’re running the
mermaid-analyzercommand.
- Cause: The
Testing & Verification
To verify the functionality implemented in this chapter, perform the following checks:
Basic Linting:
- Create a valid
simple.mmdfile (e.g.,graph TD; A-->B;). Runmermaid-analyzer lint --file simple.mmd. Expected: Exit code 0, “No issues found.” - Create an invalid
invalid.mmdfile (e.g.,graph TD; A--?B;). Runmermaid-analyzer lint --file invalid.mmd. Expected: Diagnostic error message, exit code 1.
- Create a valid
Fixing Functionality:
- Create a
needs_fix.mmdfile with issues yourrule_enginecan fix (e.g., missing quotes around labels, non-standard arrows). - Run
mermaid-analyzer fix --file needs_fix.mmd --output fixed_output.mmd --diff. Expected: Diff output,fixed_output.mmdcontains corrected Mermaid code. Comparefixed_output.mmdmanually. - Run
mermaid-analyzer fix --file needs_fix.mmd --check. Expected: Exit code 1, summary indicating fixes would be applied. - Run
mermaid-analyzer fix --file fixed_output.mmd --check. Expected: Exit code 0, summary indicating no fixes needed.
- Create a
Strict Mode:
- Run
mermaid-analyzer strict --file needs_fix.mmd. Expected: If issues are fixable and no warnings remain, it should output fixed code and exit 0. If any unfixable errors or warnings remain, it should output diagnostics and exit 1. - Test with a file that has a clear unfixable error. Expected: Immediate failure with diagnostics and exit code 1.
- Run
Input from Stdin:
echo "graph TD; A-->B;" | mermaid-analyzer lint. Expected: Exit code 0.echo "graph TD; A--?B;" | mermaid-analyzer lint. Expected: Diagnostic error, exit code 1.
Logging:
- Run with
RUST_LOG=debug mermaid-analyzer lint --file simple.mmd. Expected: Detailed debug logs about lexing, parsing, etc. - Run with
mermaid-analyzer lint --file simple.mmd -v. Expected: Same as above. - Run with
mermaid-analyzer lint --file invalid.mmd -q. Expected: Only error output, noinfo!ordebug!messages.
- Run with
By thoroughly testing these scenarios, you can verify that the CLI is correctly interpreting arguments, delegating to the core logic, and producing the expected output and exit codes for various situations.
Summary & Next Steps
In this comprehensive chapter, we successfully built the command-line interface for our mermaid-analyzer tool. We leveraged the clap crate to define a robust and intuitive argument structure, enabling lint, fix, and strict modes. We implemented handlers for each mode, orchestrating the flow through our lexer, parser, AST, validator, rule engine, and formatter. Crucially, we designed a sophisticated output system using owo-colors to provide Rust-compiler-like diagnostics, diffs, and summaries, enhancing the user experience significantly. We also addressed production considerations such as error handling, performance, security, and logging.
Our mermaid-analyzer is now a fully usable executable, capable of being run from the terminal and integrated into development workflows.
In the next chapter, Chapter 9: Advanced Rule Engine Features and Custom Rule Development, we will delve deeper into the rule_engine. We’ll explore how to create more complex and context-aware rules, discuss rule configuration, and lay the groundwork for a potential plugin system, allowing users to extend the tool with their own custom Mermaid validation and fixing logic. This will further enhance the tool’s flexibility and power, making it adaptable to diverse project requirements.