Welcome to Chapter 5 of our journey to build a production-grade Mermaid analyzer and fixer. In the previous chapters, we successfully developed a robust lexer to tokenize Mermaid input and a sophisticated parser to transform those tokens into a strongly typed Abstract Syntax Tree (AST). With the raw structure of the Mermaid diagram now represented in a programmatic form, it’s time to introduce the critical next phase: the Strict Validation Layer.

This chapter will guide you through designing and implementing a comprehensive validation system that scrutinizes the generated AST for both syntax and semantic correctness. While our parser handled basic grammatical rules, the validator goes deeper, ensuring that the diagram makes logical sense and adheres to the strict rules of Mermaid. We will detect issues like undefined nodes in edges, duplicate node IDs, illegal nesting, and other structural inconsistencies that a simple grammar-based parser might miss. The ultimate goal is to produce actionable diagnostics, similar to what the Rust compiler provides, empowering developers to quickly identify and rectify issues in their Mermaid code.

By the end of this chapter, you will have a Validator component capable of taking any Mermaid AST and returning a structured list of diagnostics. This output will be crucial for the subsequent rule engine and formatter, laying a solid foundation for a truly reliable and production-ready Mermaid tool. Let’s dive in and elevate our tool’s intelligence to pinpoint and explain common Mermaid pitfalls.


Planning & Design

The validation layer acts as a gatekeeper, ensuring that the AST, despite being syntactically correct according to the grammar, also adheres to the semantic rules and best practices of Mermaid diagrams. This involves traversing the AST and applying a series of checks.

Component Architecture

Our validation process will integrate directly after the parsing phase. The Validator component will receive the AST and produce a list of Diagnostic objects. Each Diagnostic will encapsulate details about an error or warning, including its level, a unique error code, a descriptive message, the precise location (span) in the source code, and actionable help messages.

Here’s a high-level overview of the data flow and component interaction:

flowchart TD A[Mermaid Source Code] --> B{Lexer} B --> C[Tokens Stream] C --> D{Parser} D --> E[Abstract Syntax Tree (AST)] E --> F{Validator} F --> G[List of Diagnostics] G --> H[CLI Reporter] subgraph Validator_Internal["Validator Internal Logic"] F_a[Traverse AST] --> F_b{Syntax Checks} F_b -->|Errors/Warnings| G F_a --> F_c{Semantic Checks} F_c -->|Errors/Warnings| G F_c --> F_d[Collect Defined Nodes] F_d --> F_e[Check Edge References] F_e -->|Undefined Node| G F_d --> F_f[Check Duplicate Node IDs] F_f -->|Duplicate ID| G F_c --> F_g[Validate Nesting/Direction] F_g -->|Invalid Structure| G end F_a --.-> F_b F_a --.-> F_c

File Structure

We’ll introduce new modules for diagnostics and the validator, alongside modifications to existing ones.

.
├── src/
│   ├── main.rs
│   ├── lib.rs
│   ├── lexer/
│   │   ├── mod.rs
│   │   └── token.rs
│   ├── parser/
│   │   ├── mod.rs
│   │   └── ast.rs  <-- AST definitions
│   ├── diagnostics/  <-- NEW: Defines Diagnostic types and error codes
│   │   ├── mod.rs
│   │   ├── diagnostic.rs
│   │   └── error_codes.rs
│   ├── validator/    <-- NEW: Contains the core validation logic
│   │   ├── mod.rs
│   │   └── validator.rs
│   └── utils/
│       └── span.rs   <-- REFACTOR: Move Span here for wider use
└── tests/
    ├── lexer_tests.rs
    ├── parser_tests.rs
    ├── validator_tests.rs <-- NEW: Tests for the validator

Diagnostic Design

Our Diagnostic struct will be comprehensive, allowing for rich error reporting.

// src/diagnostics/diagnostic.rs
pub enum DiagnosticLevel {
    Error,
    Warning,
    Note,
    Help,
}

pub struct Span {
    pub start: usize,
    pub end: usize,
    pub line: usize,
    pub column: usize,
}

pub struct Diagnostic {
    pub level: DiagnosticLevel,
    pub code: String, // e.g., "E0001", "W0001"
    pub message: String,
    pub span: Option<Span>,
    pub help: Option<String>,
    pub notes: Vec<String>,
    pub suggested_fix: Option<String>, // For future auto-fixing
}

Error Codes

We’ll define a set of unique error codes. This allows for programmatic identification of issues and provides a consistent reference for documentation and user support.

// src/diagnostics/error_codes.rs
pub enum ErrorCode {
    // Syntax-related errors (E0xxx)
    E0001, // Missing graph declaration
    E0002, // Invalid character in label
    E0003, // Unexpected token (should be caught by parser, but context-sensitive checks)
    E0004, // Malformed label syntax
    E0005, // Invalid arrow type for diagram

    // Semantic-related errors (E1xxx)
    E1001, // Undefined node in edge
    E1002, // Duplicate node ID
    E1003, // Illegal subgraph nesting
    E1004, // Invalid direction for diagram type
    E1005, // Circular dependency detected (future work)
    E1006, // Self-referencing node in edge without explicit loop syntax

    // Warnings (W0xxx)
    W0001, // Unused node declaration
    W0002, // Ambiguous label (e.g., node ID and label are identical)
    W0003, // Inconsistent arrow style
    W0004, // Missing quotes around label with special characters
}

impl ErrorCode {
    pub fn as_str(&self) -> &'static str {
        match self {
            ErrorCode::E0001 => "E0001",
            ErrorCode::E0002 => "E0002",
            ErrorCode::E0003 => "E0003",
            ErrorCode::E0004 => "E0004",
            ErrorCode::E0005 => "E0005",
            ErrorCode::E1001 => "E1001",
            ErrorCode::E1002 => "E1002",
            ErrorCode::E1003 => "E1003",
            ErrorCode::E1004 => "E1004",
            ErrorCode::E1005 => "E1005",
            ErrorCode::E1006 => "E1006",
            ErrorCode::W0001 => "W0001",
            ErrorCode::W0002 => "W0002",
            ErrorCode::W0003 => "W0003",
            ErrorCode::W0004 => "W0004",
        }
    }
}

Step-by-Step Implementation

a) Setup/Configuration: src/diagnostics and src/utils/span.rs

First, let’s create the necessary files and define our core diagnostic types. We’ll also centralize the Span definition.

1. Create src/utils directory and span.rs

mkdir -p src/utils
touch src/utils/span.rs

File: src/utils/span.rs

#[derive(Debug, Clone, PartialEq, Eq)]
pub struct Span {
    pub start: usize, // Start byte index in the source string
    pub end: usize,   // End byte index in the source string (exclusive)
    pub line: usize,  // Start line number (1-indexed)
    pub column: usize, // Start column number (1-indexed, byte-offset)
}

impl Span {
    pub fn new(start: usize, end: usize, line: usize, column: usize) -> Self {
        Span { start, end, line, column }
    }

    pub fn len(&self) -> usize {
        self.end - self.start
    }

    pub fn is_empty(&self) -> bool {
        self.len() == 0
    }
}
  • Explanation: The Span struct precisely identifies a region in the source code. start and end are byte indices, which are robust for UTF-8. line and column provide human-readable location. We derive Debug, Clone, PartialEq, Eq for convenience in testing and debugging.

2. Create src/diagnostics directory and files

mkdir -p src/diagnostics
touch src/diagnostics/mod.rs
touch src/diagnostics/diagnostic.rs
touch src/diagnostics/error_codes.rs

File: src/diagnostics/mod.rs

pub mod diagnostic;
pub mod error_codes;

pub use diagnostic::{Diagnostic, DiagnosticLevel, Span};
pub use error_codes::ErrorCode;
  • Explanation: This mod.rs makes the diagnostic types publicly accessible from src/diagnostics.

File: src/diagnostics/diagnostic.rs

use crate::diagnostics::error_codes::ErrorCode;
use crate::utils::span::Span; // Use the centralized Span

#[derive(Debug, Clone, PartialEq, Eq)]
pub enum DiagnosticLevel {
    Error,
    Warning,
    Note,
    Help,
}

#[derive(Debug, Clone, PartialEq, Eq)]
pub struct Diagnostic {
    pub level: DiagnosticLevel,
    pub code: ErrorCode, // Use the enum
    pub message: String,
    pub span: Option<Span>,
    pub help: Option<String>,
    pub notes: Vec<String>,
    pub suggested_fix: Option<String>, // For future auto-fixing
}

impl Diagnostic {
    pub fn new(level: DiagnosticLevel, code: ErrorCode, message: String) -> Self {
        Diagnostic {
            level,
            code,
            message,
            span: None,
            help: None,
            notes: Vec::new(),
            suggested_fix: None,
        }
    }

    pub fn with_span(mut self, span: Span) -> Self {
        self.span = Some(span);
        self
    }

    pub fn with_help(mut self, help: String) -> Self {
        self.help = Some(help);
        self
    }

    pub fn with_note(mut self, note: String) -> Self {
        self.notes.push(note);
        self
    }

    pub fn with_suggested_fix(mut self, fix: String) -> Self {
        self.suggested_fix = Some(fix);
        self
    }
}

// Implement Display for Diagnostic for pretty printing (Rust-compiler style)
impl std::fmt::Display for Diagnostic {
    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
        let level_str = match self.level {
            DiagnosticLevel::Error => "error",
            DiagnosticLevel::Warning => "warning",
            DiagnosticLevel::Note => "note",
            DiagnosticLevel::Help => "help",
        };

        write!(f, "{}[{}]: {}", level_str, self.code.as_str(), self.message)?;

        if let Some(span) = &self.span {
            // In a real CLI, we'd read the source file to highlight the span.
            // For now, we'll just print the location.
            write!(f, "\n    --> {}:{}:{}", "input.mmd", span.line, span.column)?;
            // TODO: Add source snippet highlighting here in a later chapter
        }

        if let Some(help) = &self.help {
            write!(f, "\n    = help: {}", help)?;
        }

        for note in &self.notes {
            write!(f, "\n    = note: {}", note)?;
        }

        if let Some(fix) = &self.suggested_fix {
            write!(f, "\n    = suggested fix: {}", fix)?;
        }

        Ok(())
    }
}
  • Explanation: This file defines the Diagnostic struct and its associated methods for construction. It also provides a Display implementation that formats diagnostics in a compiler-like style, including error level, code, message, and location. We’re using input.mmd as a placeholder filename for now.

File: src/diagnostics/error_codes.rs

// This file was defined in the planning section, just ensure it's here.
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum ErrorCode {
    // Syntax-related errors (E0xxx)
    E0001, // Missing graph declaration
    E0002, // Invalid character in label
    E0003, // Unexpected token (should be caught by parser, but context-sensitive checks)
    E0004, // Malformed label syntax
    E0005, // Invalid arrow type for diagram

    // Semantic-related errors (E1xxx)
    E1001, // Undefined node in edge
    E1002, // Duplicate node ID
    E1003, // Illegal subgraph nesting
    E1004, // Invalid direction for diagram type
    E1005, // Circular dependency detected (future work)
    E1006, // Self-referencing node in edge without explicit loop syntax

    // Warnings (W0xxx)
    W0001, // Unused node declaration
    W0002, // Ambiguous label (e.g., node ID and label are identical)
    W0003, // Inconsistent arrow style
    W0004, // Missing quotes around label with special characters
}

impl ErrorCode {
    pub fn as_str(&self) -> &'static str {
        match self {
            ErrorCode::E0001 => "E0001",
            ErrorCode::E0002 => "E0002",
            ErrorCode::E0003 => "E0003",
            ErrorCode::E0004 => "E0004",
            ErrorCode::E0005 => "E0005",
            ErrorCode::E1001 => "E1001",
            ErrorCode::E1002 => "E1002",
            ErrorCode::E1003 => "E1003",
            ErrorCode::E1004 => "E1004",
            ErrorCode::E1005 => "E1005",
            ErrorCode::E1006 => "E1006",
            ErrorCode::W0001 => "W0001",
            ErrorCode::W0002 => "W0002",
            ErrorCode::W0003 => "W0003",
            ErrorCode::W0004 => "W0004",
        }
    }
}
  • Explanation: This enum provides strongly typed error codes, making our diagnostics more robust and manageable. The as_str method provides the string representation for display.

3. Update src/lib.rs and src/parser/ast.rs

We need to ensure Span is correctly integrated into our AST nodes.

File: src/lib.rs

// Add these modules
pub mod diagnostics;
pub mod utils; // Make utils public

// Re-export Span from utils
pub use utils::span::Span;

// ... other existing pub mods

File: src/parser/ast.rs (Partial, only showing changes)

use crate::utils::span::Span; // Import Span

// --- Diagram Types ---
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum Diagram {
    Flowchart(Flowchart),
    Sequence(SequenceDiagram),
    Class(ClassDiagram),
    // Add a default span for the entire diagram, or None if not directly tied
    // For now, let's assume Diagram itself doesn't have a span directly,
    // its components do.
}

// --- Flowchart Specifics ---
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct Flowchart {
    pub direction: Option<FlowchartDirection>,
    pub statements: Vec<FlowchartStatement>,
    pub span: Span, // Add span to Flowchart
}

#[derive(Debug, Clone, PartialEq, Eq)]
pub enum FlowchartStatement {
    Node(Node),
    Edge(Edge),
    Subgraph(Subgraph),
    // Add span to the statement enum if needed for generic statement errors
}

#[derive(Debug, Clone, PartialEq, Eq)]
pub struct Node {
    pub id: String,
    pub label: Option<String>,
    pub kind: NodeKind,
    pub span: Span, // Crucial for diagnostics
}

#[derive(Debug, Clone, PartialEq, Eq)]
pub struct Edge {
    pub source: EdgeEndpoint,
    pub target: EdgeEndpoint,
    pub label: Option<String>,
    pub arrow: ArrowType,
    pub span: Span, // Crucial for diagnostics
}

#[derive(Debug, Clone, PartialEq, Eq)]
pub enum EdgeEndpoint {
    Node(String, Span), // Store span for node ID reference
    Subgraph(String, Span), // Store span for subgraph ID reference
}

#[derive(Debug, Clone, PartialEq, Eq)]
pub struct Subgraph {
    pub id: String,
    pub label: Option<String>,
    pub statements: Vec<FlowchartStatement>,
    pub span: Span, // Crucial for diagnostics
}

// ... similar additions for SequenceDiagram, ClassDiagram, etc.
// Ensure all relevant AST nodes (Node, Edge, Subgraph, Participant, Class, etc.)
// have a `pub span: Span` field. If you already did this in Chapter 4, great!
// Otherwise, go back and add it now.
  • Explanation: We’ve updated the parser::ast module to use the Span struct from src/utils/span.rs. Critically, every AST node that can be a source of an error (e.g., Node, Edge, Subgraph, EdgeEndpoint) must carry its Span information. This allows the validator to report errors with precise locations.

b) Core Implementation: src/validator/validator.rs

Now, let’s build the Validator itself.

1. Create src/validator directory and validator.rs

mkdir -p src/validator
touch src/validator/mod.rs
touch src/validator/validator.rs

File: src/validator/mod.rs

pub mod validator;

pub use validator::Validator;
  • Explanation: Makes the Validator publicly accessible.

File: src/validator/validator.rs

use std::collections::{HashMap, HashSet};

use crate::diagnostics::{Diagnostic, DiagnosticLevel, ErrorCode, Span};
use crate::parser::ast::{
    ArrowType, ClassDiagram, Diagram, Edge, EdgeEndpoint, Flowchart, FlowchartStatement, Node,
    NodeKind, SequenceDiagram, Subgraph,
};

#[derive(Debug)]
pub struct Validator {
    diagnostics: Vec<Diagnostic>,
    // Store defined node IDs and their spans for semantic checks
    defined_nodes: HashMap<String, Span>,
    // Keep track of visited node IDs to detect duplicates within the same scope
    current_scope_nodes: HashSet<String>,
    // Reference to the original source text (optional, but useful for rich diagnostics)
    // For this chapter, we'll assume we don't have it here directly, but could add it.
}

impl Validator {
    pub fn new() -> Self {
        Validator {
            diagnostics: Vec::new(),
            defined_nodes: HashMap::new(),
            current_scope_nodes: HashSet::new(),
        }
    }

    /// Validates the given Mermaid AST and returns a list of diagnostics.
    pub fn validate(mut self, ast: &Diagram) -> Vec<Diagnostic> {
        self.diagnostics.clear(); // Clear any previous diagnostics

        match ast {
            Diagram::Flowchart(flowchart) => self.validate_flowchart(flowchart),
            Diagram::Sequence(sequence) => self.validate_sequence_diagram(sequence),
            Diagram::Class(class) => self.validate_class_diagram(class),
            // Handle other diagram types as they are implemented
        }

        self.diagnostics
    }

    /// Validates a Flowchart diagram.
    fn validate_flowchart(&mut self, flowchart: &Flowchart) {
        // Clear scope for top-level diagram
        self.defined_nodes.clear();
        self.current_scope_nodes.clear();

        // Rule E0001: Missing graph declaration (This should largely be caught by parser,
        // but if parser is lenient, we can check if flowchart is "empty" or malformed)
        // For our strict parser, if we have a Flowchart enum, the 'graph' keyword was present.
        // So this check might be more about an empty flowchart or one missing direction.
        if flowchart.statements.is_empty() && flowchart.direction.is_none() {
             self.diagnostics.push(
                Diagnostic::new(
                    DiagnosticLevel::Warning, // Could be a warning if empty but valid.
                    ErrorCode::W0001,
                    "Flowchart is empty or has no statements.".to_string(),
                )
                .with_span(flowchart.span.clone())
                .with_help("Consider adding nodes and edges to your flowchart.".to_string()),
            );
        }

        self.validate_flowchart_statements(&flowchart.statements);

        // After all statements are processed, clear defined_nodes for next diagram
        self.defined_nodes.clear();
    }

    /// Validates a list of flowchart statements (nodes, edges, subgraphs).
    fn validate_flowchart_statements(&mut self, statements: &[FlowchartStatement]) {
        // First pass: Collect all defined nodes in this scope and check for duplicates
        let mut local_nodes: HashSet<String> = HashSet::new();
        for statement in statements {
            match statement {
                FlowchartStatement::Node(node) => {
                    if !local_nodes.insert(node.id.clone()) {
                        self.diagnostics.push(
                            Diagnostic::new(
                                DiagnosticLevel::Error,
                                ErrorCode::E1002,
                                format!("Duplicate node ID '{}' found in the same scope.", node.id),
                            )
                            .with_span(node.span.clone())
                            .with_help("Node IDs must be unique within their immediate scope. Rename or remove the duplicate node.".to_string()),
                        );
                    }
                    // Add to global defined nodes
                    self.defined_nodes.insert(node.id.clone(), node.span.clone());

                    // Rule E0002: Invalid character in label (basic check)
                    // Mermaid allows almost anything in quoted labels, but unquoted labels
                    // have restrictions. We'll simplify for now.
                    if let Some(label) = &node.label {
                        if !label.is_empty() && label.contains(|c: char| !c.is_alphanumeric() && !c.is_whitespace() && !"-_.@#$".contains(c)) {
                            self.diagnostics.push(
                                Diagnostic::new(
                                    DiagnosticLevel::Warning, // Warning, as Mermaid might render it anyway
                                    ErrorCode::W0004, // Suggest quotes
                                    format!("Label '{}' contains special characters. Consider enclosing it in quotes.", label),
                                )
                                .with_span(node.span.clone())
                                .with_help(format!("Labels with special characters (e.g., `{}`) should be enclosed in double quotes: `node_id[\"{}\"]`", label, label))
                                .with_suggested_fix(format!("\"{}\"", label)),
                            );
                        }
                    }
                }
                FlowchartStatement::Subgraph(subgraph) => {
                    if !local_nodes.insert(subgraph.id.clone()) {
                        self.diagnostics.push(
                            Diagnostic::new(
                                DiagnosticLevel::Error,
                                ErrorCode::E1002,
                                format!("Duplicate subgraph ID '{}' found in the same scope.", subgraph.id),
                            )
                            .with_span(subgraph.span.clone())
                            .with_help("Subgraph IDs must be unique within their immediate scope. Rename or remove the duplicate subgraph.".to_string()),
                        );
                    }
                    // Add to global defined nodes (subgraphs can be referenced like nodes)
                    self.defined_nodes.insert(subgraph.id.clone(), subgraph.span.clone());
                }
                _ => {} // Edges don't define nodes
            }
        }

        // Second pass: Validate edges and recursively validate subgraphs
        for statement in statements {
            match statement {
                FlowchartStatement::Edge(edge) => self.validate_edge(edge),
                FlowchartStatement::Subgraph(subgraph) => self.validate_subgraph(subgraph),
                _ => {}
            }
        }
    }

    /// Validates an Edge statement.
    fn validate_edge(&mut self, edge: &Edge) {
        // Rule E1001: Undefined node in edge
        self.check_endpoint_definition(&edge.source, "source node or subgraph");
        self.check_endpoint_definition(&edge.target, "target node or subgraph");

        // Rule E1006: Self-referencing node in edge without explicit loop syntax
        // This is a common pattern for loops, but if it's not explicitly drawn as a loop,
        // it can be confusing. For strictness, we'll flag it.
        // TODO: This check might need more context about actual loop syntax.
        // For now, a simple direct self-reference check.
        if let (EdgeEndpoint::Node(src_id, _), EdgeEndpoint::Node(tgt_id, _)) = (&edge.source, &edge.target) {
            if src_id == tgt_id {
                self.diagnostics.push(
                    Diagnostic::new(
                        DiagnosticLevel::Warning,
                        ErrorCode::W0002, // Re-using W0002 for now, or create W0005 for self-reference
                        format!("Node '{}' is self-referencing in an edge. Consider using explicit loop syntax if supported by diagram type.", src_id),
                    )
                    .with_span(edge.span.clone())
                    .with_help("A node connecting to itself might be better represented with a specific loop style if the diagram type supports it, or ensure this is intentional.".to_string()),
                );
            }
        }
        // Rule E0005: Invalid arrow type for diagram
        // Flowcharts allow various arrow types, but specific ones might be restricted in future.
        // For now, we assume all parsed arrow types are valid for flowcharts.
        // This rule would be more relevant for Sequence or Class diagrams.
    }

    /// Helper to check if an endpoint (node/subgraph) is defined.
    fn check_endpoint_definition(&mut self, endpoint: &EdgeEndpoint, endpoint_type: &str) {
        let (id, span) = match endpoint {
            EdgeEndpoint::Node(id, span) => (id, span),
            EdgeEndpoint::Subgraph(id, span) => (id, span),
        };

        if !self.defined_nodes.contains_key(id) {
            self.diagnostics.push(
                Diagnostic::new(
                    DiagnosticLevel::Error,
                    ErrorCode::E1001,
                    format!("Undefined {} '{}' referenced in an edge.", endpoint_type, id),
                )
                .with_span(span.clone())
                .with_help("Ensure all nodes and subgraphs used in edges are explicitly defined with a unique ID.".to_string()),
            );
        }
    }

    /// Validates a Subgraph statement.
    fn validate_subgraph(&mut self, subgraph: &Subgraph) {
        // Recursively validate statements within the subgraph.
        // Note: For strict scope, we'd push/pop `current_scope_nodes` here.
        // For simplicity, `defined_nodes` is global for all flowcharts, allowing cross-subgraph references.
        // If strict local scoping for node IDs is required, `defined_nodes` would be a stack.
        // For Mermaid, cross-subgraph references are common, so a global `defined_nodes` is acceptable.
        self.validate_flowchart_statements(&subgraph.statements);

        // Rule E1003: Illegal subgraph nesting (e.g., in a diagram type that doesn't support it)
        // This is primarily for other diagram types. For Flowcharts, arbitrary nesting is generally allowed.
    }

    /// Validates a Sequence Diagram.
    fn validate_sequence_diagram(&mut self, sequence: &SequenceDiagram) {
        // TODO: Implement specific validation rules for Sequence Diagrams
        // e.g., participant definitions, valid message types, activation blocks.
        self.diagnostics.push(
            Diagnostic::new(
                DiagnosticLevel::Note,
                ErrorCode::E0000, // Placeholder
                "Sequence diagram validation is not yet fully implemented.".to_string(),
            )
            .with_span(sequence.span.clone())
            .with_help("This is a placeholder. Full validation for sequence diagrams will be added in future iterations.".to_string()),
        );
    }

    /// Validates a Class Diagram.
    fn validate_class_diagram(&mut self, class: &ClassDiagram) {
        // TODO: Implement specific validation rules for Class Diagrams
        // e.g., class definitions, relationships, members.
        self.diagnostics.push(
            Diagnostic::new(
                DiagnosticLevel::Note,
                ErrorCode::E0000, // Placeholder
                "Class diagram validation is not yet fully implemented.".to_string(),
            )
            .with_span(class.span.clone())
            .with_help("This is a placeholder. Full validation for class diagrams will be added in future iterations.".to_string()),
        );
    }
}
  • Explanation:
    • The Validator struct holds a list of Diagnostic objects and a HashMap (defined_nodes) to track all node and subgraph IDs encountered, along with their Spans. This HashMap is crucial for semantic checks like “undefined node in edge.”
    • validate is the entry point, dispatching to diagram-specific validation methods.
    • validate_flowchart orchestrates the validation for flowcharts. It first clears the scope, then performs two passes over the statements:
      1. First pass: Collects all Node and Subgraph IDs, adding them to defined_nodes and local_nodes. It also checks for E1002: Duplicate node ID within the current scope.
      2. Second pass: Validates Edge statements (checking for E1001: Undefined node in edge and W0002: Self-referencing node) and recursively calls validate_subgraph for nested subgraphs.
    • check_endpoint_definition is a helper for E1001.
    • Basic W0004: Missing quotes around label with special characters is included for nodes.
    • Placeholders for SequenceDiagram and ClassDiagram validation are added, indicating future extensibility.
    • Important Note on Scoping: Mermaid’s scoping rules for node IDs are somewhat flexible. A node defined in a subgraph can often be referenced from outside it. Our current defined_nodes: HashMap<String, Span> tracks all defined IDs globally within a single diagram, which aligns well with this flexibility. If strict lexical scoping were required (e.g., node IDs only visible within their subgraph), defined_nodes would need to be a stack of HashMaps. For now, the global approach is simpler and often sufficient for Mermaid.

c) Integrate Validator into src/lib.rs

Now, let’s integrate our new Validator into the main library flow.

File: src/lib.rs (Partial, only showing changes)

pub mod lexer;
pub mod parser;
pub mod diagnostics;
pub mod utils;
pub mod validator; // Add validator module

use lexer::Lexer;
use parser::Parser;
use diagnostics::Diagnostic;
use validator::Validator;
use parser::ast::Diagram; // Assuming Diagram is visible here

/// Processes Mermaid code through lexing, parsing, and validation.
/// Returns the AST and any collected diagnostics.
pub fn process_mermaid_code(input: &str) -> (Option<Diagram>, Vec<Diagnostic>) {
    let mut diagnostics: Vec<Diagnostic> = Vec::new();

    // 1. Lexing
    let (tokens, lexer_diagnostics) = Lexer::new(input).tokenize();
    diagnostics.extend(lexer_diagnostics);

    if tokens.is_empty() {
        // If lexer produced no tokens, parsing won't work.
        return (None, diagnostics);
    }

    // 2. Parsing
    let mut parser = Parser::new(tokens);
    let ast_result = parser.parse();
    diagnostics.extend(parser.diagnostics()); // Collect parser-specific diagnostics

    let ast = match ast_result {
        Ok(ast) => ast,
        Err(_) => {
            // Parser already added its errors to diagnostics.
            // If parsing failed fundamentally, we might not have a full AST for validation.
            return (None, diagnostics);
        }
    };

    // 3. Validation
    let validator = Validator::new();
    let validation_diagnostics = validator.validate(&ast);
    diagnostics.extend(validation_diagnostics);

    (Some(ast), diagnostics)
}
  • Explanation: The process_mermaid_code function now includes a third step: validation. After parsing, the Validator::new().validate(&ast) method is called, and any diagnostics it produces are added to the overall list. This function now returns both the (optional) AST and the complete list of diagnostics from all stages.

d) Testing This Component

It’s crucial to test our Validator thoroughly to ensure it catches the intended errors and doesn’t produce false positives.

1. Create tests/validator_tests.rs

touch tests/validator_tests.rs

File: tests/validator_tests.rs

use mermaid_analyzer::{
    diagnostics::{DiagnosticLevel, ErrorCode},
    process_mermaid_code,
};

#[test]
fn test_flowchart_undefined_node_in_edge() {
    let input = r#"
        graph TD
        A --> B
        C --> D
    "#;
    let (_, diagnostics) = process_mermaid_code(input);

    assert!(!diagnostics.is_empty(), "Expected diagnostics for undefined nodes");
    assert_eq!(diagnostics.len(), 2, "Expected 2 diagnostics");

    let diag1 = &diagnostics[0];
    assert_eq!(diag1.level, DiagnosticLevel::Error);
    assert_eq!(diag1.code, ErrorCode::E1001);
    assert!(diag1.message.contains("Undefined source node or subgraph 'A'"));
    assert!(diag1.span.is_some());

    let diag2 = &diagnostics[1];
    assert_eq!(diag2.level, DiagnosticLevel::Error);
    assert_eq!(diag2.code, ErrorCode::E1001);
    assert!(diag2.message.contains("Undefined source node or subgraph 'C'"));
    assert!(diag2.span.is_some());
}

#[test]
fn test_flowchart_duplicate_node_id() {
    let input = r#"
        graph TD
        A[Node A]
        B[Node B]
        A[Another Node A]
    "#;
    let (_, diagnostics) = process_mermaid_code(input);

    assert!(!diagnostics.is_empty(), "Expected diagnostics for duplicate node ID");
    assert_eq!(diagnostics.len(), 1, "Expected 1 diagnostic");

    let diag = &diagnostics[0];
    assert_eq!(diag.level, DiagnosticLevel::Error);
    assert_eq!(diag.code, ErrorCode::E1002);
    assert!(diag.message.contains("Duplicate node ID 'A' found"));
    assert!(diag.span.is_some());
}

#[test]
fn test_flowchart_duplicate_subgraph_id() {
    let input = r#"
        graph TD
        subgraph MySub["My Subgraph"]
            A[Node A]
        end
        subgraph MySub["Another Subgraph"]
            B[Node B]
        end
    "#;
    let (_, diagnostics) = process_mermaid_code(input);

    assert!(!diagnostics.is_empty(), "Expected diagnostics for duplicate subgraph ID");
    assert_eq!(diagnostics.len(), 1, "Expected 1 diagnostic");

    let diag = &diagnostics[0];
    assert_eq!(diag.level, DiagnosticLevel::Error);
    assert_eq!(diag.code, ErrorCode::E1002);
    assert!(diag.message.contains("Duplicate subgraph ID 'MySub' found"));
    assert!(diag.span.is_some());
}

#[test]
fn test_flowchart_valid_with_subgraphs() {
    let input = r#"
        graph TD
        A[Start] --> B(Process)
        subgraph Sub1["First Sub"]
            B --> C{Decision}
            C -- Yes --> D[Task 1]
            C -- No --> E[Task 2]
        end
        D --> F[End]
        E --> F
        F --> G[Finish]
        Sub1 --> G // Reference subgraph as a node
    "#;
    let (_, diagnostics) = process_mermaid_code(input);

    assert!(diagnostics.is_empty(), "Expected no diagnostics for valid flowchart, got: {:?}", diagnostics);
}

#[test]
fn test_flowchart_label_with_special_chars_warning() {
    let input = r#"
        graph TD
        A[Node with spaces and !@#$]
    "#;
    let (_, diagnostics) = process_mermaid_code(input);

    assert!(!diagnostics.is_empty(), "Expected warning for special characters in label");
    assert_eq!(diagnostics.len(), 1, "Expected 1 diagnostic");

    let diag = &diagnostics[0];
    assert_eq!(diag.level, DiagnosticLevel::Warning);
    assert_eq!(diag.code, ErrorCode::W0004);
    assert!(diag.message.contains("Label 'Node with spaces and !@#$' contains special characters. Consider enclosing it in quotes."));
    assert!(diag.span.is_some());
    assert!(diag.suggested_fix.is_some());
    assert_eq!(diag.suggested_fix.as_ref().unwrap(), "\"Node with spaces and !@#$\"");
}

#[test]
fn test_flowchart_self_referencing_node_warning() {
    let input = r#"
        graph TD
        A[Node A] --> A
    "#;
    let (_, diagnostics) = process_mermaid_code(input);

    assert!(!diagnostics.is_empty(), "Expected warning for self-referencing node");
    assert_eq!(diagnostics.len(), 1, "Expected 1 diagnostic");

    let diag = &diagnostics[0];
    assert_eq!(diag.level, DiagnosticLevel::Warning);
    assert_eq!(diag.code, ErrorCode::W0002);
    assert!(diag.message.contains("Node 'A' is self-referencing in an edge."));
    assert!(diag.span.is_some());
}

#[test]
fn test_flowchart_empty_diagram_warning() {
    let input = r#"
        graph TD
    "#;
    let (_, diagnostics) = process_mermaid_code(input);

    assert!(!diagnostics.is_empty(), "Expected warning for empty flowchart");
    assert_eq!(diagnostics.len(), 1, "Expected 1 diagnostic");

    let diag = &diagnostics[0];
    assert_eq!(diag.level, DiagnosticLevel::Warning);
    assert_eq!(diag.code, ErrorCode::W0001);
    assert!(diag.message.contains("Flowchart is empty or has no statements."));
}

#[test]
fn test_flowchart_multiple_errors() {
    let input = r#"
        graph TD
        A --> B
        C --> D
        A[Duplicate A]
        E[Node E !]
    "#;
    let (_, diagnostics) = process_mermaid_code(input);

    assert!(!diagnostics.is_empty(), "Expected multiple diagnostics");
    assert_eq!(diagnostics.len(), 4, "Expected 4 diagnostics (2 undefined, 1 duplicate, 1 bad label)");

    // Sort diagnostics by code and then by span start to ensure consistent order
    let mut sorted_diagnostics = diagnostics.clone();
    sorted_diagnostics.sort_by(|a, b| {
        a.code.as_str().cmp(b.code.as_str())
            .then_with(|| a.span.as_ref().map_or(0, |s| s.start).cmp(&b.span.as_ref().map_or(0, |s| s.start)))
    });

    // Check E1001 (Undefined node)
    assert_eq!(sorted_diagnostics[0].code, ErrorCode::E1001);
    assert_eq!(sorted_diagnostics[1].code, ErrorCode::E1001);
    // Check E1002 (Duplicate node ID)
    assert_eq!(sorted_diagnostics[2].code, ErrorCode::E1002);
    // Check W0004 (Special chars in label)
    assert_eq!(sorted_diagnostics[3].code, ErrorCode::W0004);
}
  • Explanation: These tests cover various error scenarios for flowcharts: undefined nodes in edges, duplicate node IDs (for both nodes and subgraphs), valid diagrams, labels with special characters (warning), self-referencing nodes (warning), and empty diagrams (warning). The process_mermaid_code function is used, which now includes the full lexer -> parser -> validator pipeline. We assert on the number of diagnostics, their level, code, and a part of their message, along with the presence of a span. Sorting diagnostics in test_flowchart_multiple_errors helps ensure consistent test results when multiple errors are present.

Production Considerations

  1. Error Handling & Reporting: The Diagnostic struct is designed for robust error reporting. In a production CLI, we would implement a DiagnosticReporter that takes the Vec<Diagnostic> and pretty-prints them to the console, potentially highlighting the relevant source code lines using libraries like termcolor or codespan-reporting. This will be covered in a later chapter on CLI implementation.
  2. Performance Optimization:
    • AST Traversal: For very large diagrams, recursive AST traversal can be a performance bottleneck or lead to stack overflows. Rust’s call stack is generally deep, but for extremely deep nesting, an iterative approach or explicit stack management might be considered.
    • HashMap Lookups: Using HashMap for defined_nodes provides O(1) average-case lookup, which is efficient.
    • String Clones: Minimize string cloning where possible. For defined_nodes, we clone the node ID strings once. For diagnostics, messages are owned Strings, which is appropriate.
  3. Security Considerations: While a Mermaid validator doesn’t typically face direct security threats, strict validation helps prevent malformed or malicious input from causing unexpected behavior or crashes in downstream rendering engines or other tools that consume the validated AST. By enforcing correctness, we enhance the overall robustness of the system.
  4. Logging and Monitoring: The diagnostics themselves serve as the primary output for monitoring the correctness of Mermaid code. For the tool itself, internal logging (e.g., using log crate) could track performance metrics or unexpected internal states, though for a simple CLI tool, this might be overkill.

Code Review Checkpoint

At this point, you should have implemented the core validation logic for Flowcharts.

Files Created/Modified:

  • src/utils/span.rs: New file, centralizing Span definition.
  • src/diagnostics/mod.rs: New module, exports diagnostic types.
  • src/diagnostics/diagnostic.rs: New file, defines Diagnostic struct and its Display implementation.
  • src/diagnostics/error_codes.rs: New file, defines ErrorCode enum.
  • src/validator/mod.rs: New module, exports Validator.
  • src/validator/validator.rs: New file, contains the Validator struct and its validation methods.
  • src/lib.rs: Modified to import new modules and integrate the Validator into process_mermaid_code.
  • src/parser/ast.rs: Modified to include Span in relevant AST nodes (if not already done).
  • tests/validator_tests.rs: New file, containing unit tests for the validation layer.

Integration with Existing Code:

The process_mermaid_code function in src/lib.rs now orchestrates the entire pipeline: lexing -> parsing -> validation. All diagnostics from these stages are collected into a single Vec<Diagnostic>, which is returned along with the AST. This unified diagnostic approach is critical for providing a consistent user experience.


Common Issues & Solutions

  1. False Positives/Negatives:
    • Issue: The validator reports an error for valid Mermaid code (false positive) or misses an actual error (false negative).
    • Debugging: This often points to a misinterpretation of Mermaid’s official syntax rules. Double-check the Mermaid documentation for the specific diagram type and construct. Use mermaid.live to verify how a snippet renders.
    • Solution: Refine the validation logic in src/validator/validator.rs. Ensure checks are precise and cover all edge cases allowed by Mermaid. Add more specific unit tests to reproduce the false positive/negative.
  2. Performance on Large Diagrams:
    • Issue: Validation takes too long for very large Mermaid diagrams (thousands of nodes/edges).
    • Debugging: Use Rust’s built-in cargo bench (after adding benchmarks) or simple Instant::now() measurements to profile the validate function. Identify which parts of the traversal or data structures are slow.
    • Solution: Ensure efficient data structures are used (e.g., HashMap for node lookups). Avoid excessive cloning. If recursion depth becomes an issue, consider converting recursive traversals to iterative ones using an explicit stack.
  3. Inaccurate Span Information:
    • Issue: Diagnostics point to the wrong line/column or highlight the wrong token.
    • Debugging: Trace the Span propagation from the lexer, through the parser, and into the AST. Ensure that each AST node correctly captures the span of its corresponding source text.
    • Solution: Review the Span generation in src/lexer/mod.rs and src/parser/mod.rs. Confirm that Span::new parameters (start, end, line, column) are always correct relative to the original source string.

Testing & Verification

To verify the work in this chapter, run your tests:

cargo test

You should see all your existing lexer and parser tests pass, along with the new validator_tests.rs tests.

What should work now:

  • The process_mermaid_code function can now take Mermaid input, tokenize it, parse it into an AST, and then validate that AST.
  • It should correctly identify and report:
    • Undefined nodes or subgraphs referenced in edges (E1001).
    • Duplicate node/subgraph IDs within the same scope (E1002).
    • Warnings for special characters in unquoted labels (W0004).
    • Warnings for self-referencing nodes (W0002).
    • Warnings for empty flowcharts (W0001).
  • The diagnostics are structured according to our Diagnostic type, including level, code, message, and span.
  • The Display implementation for Diagnostic should print messages in a clear, compiler-like format.

How to verify everything is correct:

  1. Run cargo test: All tests, especially validator_tests.rs, should pass.

  2. Manual Testing: Create main.rs (if you haven’t already, or temporarily modify it) to call process_mermaid_code with various Mermaid snippets, both valid and intentionally invalid, and print the resulting diagnostics.

    File: src/main.rs (Temporary for testing)

    use mermaid_analyzer::process_mermaid_code;
    
    fn main() {
        let test_cases = vec![
            (
                "Valid Flowchart",
                r#"
                graph TD
                A[Start] --> B(Process)
                B --> C{Decision}
                C -- Yes --> D[Task 1]
                C -- No --> E[Task 2]
                D --> F[End]
                E --> F
                "#,
            ),
            (
                "Undefined Node Error",
                r#"
                graph TD
                A --> B
                C --> D
                "#,
            ),
            (
                "Duplicate Node ID Error",
                r#"
                graph TD
                A[Node A]
                B[Node B]
                A[Another Node A]
                "#,
            ),
            (
                "Label with Special Chars Warning",
                r#"
                graph TD
                A[Node with !@#$ and spaces]
                "#,
            ),
            (
                "Self-Referencing Node Warning",
                r#"
                graph TD
                A[Node A] --> A
                "#,
            ),
            (
                "Empty Flowchart Warning",
                r#"
                graph TD
                "#,
            ),
        ];
    
        for (name, code) in test_cases {
            println!("--- Testing: {} ---", name);
            println!("Mermaid Code:\n{}", code);
            let (ast_opt, diagnostics) = process_mermaid_code(code);
    
            if let Some(ast) = ast_opt {
                println!("AST Generated: {:?}", ast);
            } else {
                println!("No AST generated due to critical errors.");
            }
    
            if diagnostics.is_empty() {
                println!("No diagnostics reported. Code is valid.");
            } else {
                println!("Diagnostics:");
                for diag in diagnostics {
                    println!("{}", diag);
                }
            }
            println!("\n");
        }
    }
    

    Run this with cargo run and observe the output. This manual verification helps confirm that the diagnostics are user-friendly and accurate.


Summary & Next Steps

In this chapter, we successfully implemented a crucial component of our Mermaid analyzer: the Strict Validation Layer. We designed a comprehensive Diagnostic system, complete with error levels, unique codes, and rich contextual information, mirroring the high standards of the Rust compiler. We then built the Validator itself, which traverses the AST to detect both post-parsing syntax issues and complex semantic errors such as undefined node references, duplicate IDs, and potentially ambiguous constructs. The integration into our process_mermaid_code function ensures that every Mermaid input now undergoes a thorough correctness check.

This validation layer is fundamental. It provides the necessary feedback to developers and ensures that subsequent processing steps, like our upcoming rule engine and formatter, operate on a semantically sound representation of the Mermaid diagram.

In Chapter 6: Deterministic Rule Engine and AST Transformations, we will build upon this foundation. We will design and implement a flexible rule engine using a Rule trait system, allowing us to define and apply various checks and safe, reversible fixes directly to the AST. This will empower our tool to not only report issues but also to automatically correct common Mermaid pitfalls, moving us closer to a fully functional linter and formatter.