Mermaid structural linter.
Usage: python3 lint.py <file.md|file.mermaid>
Exits 0 if clean, 1 if errors found.
# ── constants ──────────────────────────────────────────────────────────────────
"flowchart", "graph", "sequenceDiagram", "classDiagram",
"stateDiagram", "stateDiagram-v2", "erDiagram", "gantt",
"pie", "gitGraph", "mindmap", "timeline", "journey",
"quadrantChart", "requirementDiagram", "sankey-beta",
"c4Context", "c4Container", "c4Component", "c4Dynamic",
"C4Context", "C4Container", "C4Component", "C4Dynamic", "C4Deployment",
"xychart-beta", "block-beta", "packet-beta", "architecture-beta",
BLOCK_OPENERS = frozenset(["subgraph", "loop", "alt", "opt", "par", "critical", "break"])
RESERVED_IDS = frozenset([
"end", "graph", "flowchart", "style", "class", "subgraph",
"direction", "click", "call", "linkStyle", "classDef",
"stateDiagram": "stateDiagram-v2",
# Diagram types whose labels pass through Mermaid v11's unified text renderer,
# where an inline `fas:`/`fab:` token becomes a real icon. Verified at 11.16.0.
ICON_SUPPORTED_TYPES = frozenset([
"flowchart", "graph", "stateDiagram", "stateDiagram-v2",
"classDiagram", "mindmap", "block-beta", "erDiagram",
# Types where a `fas:` token collides with the grammar and breaks the parse.
ICON_PARSER_BREAK_TYPES = frozenset(["quadrantChart", "requirementDiagram"])
# An inline FontAwesome token: fa:/fas:/fab:/far:/fal:/fak: followed by fa-name.
ICON_TOKEN_RE = re.compile(r"(?<![\w.])fa[bklrs]?:fa-[\w-]+")
# ── extraction ─────────────────────────────────────────────────────────────────
def extract_mermaid_blocks(text: str) -> list[tuple[str, int]]:
"""Return list of (diagram_text, start_line_number) for every ```mermaid block."""
if lines[i].strip() == "```mermaid":
while j < len(lines) and lines[j].strip() != "```":
block = "\n".join(lines[start:j])
results.append((block, start + 1)) # 1-based line number
# ── validators ─────────────────────────────────────────────────────────────────
def check_diagram(diagram: str, offset: int = 1) -> list[str]:
"""Run all checks on one diagram. offset = 1-based line number of first diagram line."""
raw_lines = diagram.split("\n")
lines = [(offset + i, ln) for i, ln in enumerate(raw_lines)]
if not any(ln.strip() for _, ln in lines):
issues.append("ERROR: Diagram is empty.")
# ── diagram type ───────────────────────────────────────────────────────────
first_no, first_ln = next(((n, l) for n, l in lines if l.strip()), (offset, ""))
keyword = first_ln.strip().split()[0] if first_ln.strip() else ""
if keyword not in VALID_TYPES:
f"ERROR (line {first_no}): Unknown diagram type '{keyword}'.\n"
f" Valid types: {', '.join(sorted(VALID_TYPES))}"
return issues # can't continue without knowing the type
if keyword in DEPRECATED:
replacement = DEPRECATED[keyword]
f"WARNING (line {first_no}): '{keyword}' is deprecated. Use '{replacement}' instead."
diagram_type = "stateDiagram-v2" if keyword == "stateDiagram" else keyword
diagram_type = "flowchart"
# ── skip config blocks `%%{...}%%` for line-level checks ──────────────────
is_flowchart = diagram_type in ("flowchart", "graph")
is_sequence = diagram_type == "sequenceDiagram"
is_class = diagram_type == "classDiagram"
is_state = diagram_type in ("stateDiagram", "stateDiagram-v2")
is_gantt = diagram_type == "gantt"
is_pie = diagram_type == "pie"
is_er = diagram_type == "erDiagram"
# ── block opener / end balance ─────────────────────────────────────────────
for lineno, ln in lines[1:]:
if not stripped or stripped.startswith("%%"):
word = stripped.split()[0] if stripped.split() else ""
if word in BLOCK_OPENERS:
opener_positions.append((lineno, word))
if opener_count != closer_count:
openers_str = ", ".join(f"'{w}' (line {n})" for n, w in opener_positions)
f"ERROR: Block mismatch — {opener_count} opener(s) [{openers_str}] "
f"but {closer_count} 'end'(s).\n"
f" Every subgraph / loop / alt / opt / par / critical / break needs a matching 'end'."
# ── unclosed double quotes ─────────────────────────────────────────────────
# Count non-escaped quotes
count = len(re.findall(r'(?<!\\)"', ln))
f"ERROR (line {lineno}): Unclosed double quote.\n"
# ── flowchart-specific checks ──────────────────────────────────────────────
for lineno, ln in lines[1:]:
if not stripped or stripped.startswith("%%"):
# Unquoted colon inside a label bracket [ ]
for m in re.finditer(r'\[([^"\]\[]*)\]', stripped):
if ":" in label and not label.startswith('"'):
f"ERROR (line {lineno}): Unquoted colon in label [{label}].\n"
# Reserved word used as node ID (appears before [ ( {{ >)
for word in RESERVED_IDS:
if re.search(rf'(?<![A-Za-z0-9_]){re.escape(word)}\s*[\[\({{>]', stripped):
f"ERROR (line {lineno}): Reserved word '{word}' used as node ID.\n"
f" Rename it — e.g. '{word}Node' or '{word.capitalize()}'."
# Node ID contains spaces (not quoted).
# Skip `subgraph <id>[Label]` openers — the id is the single token
# after the keyword, not a two-word node id.
if not stripped.startswith("subgraph"):
for m in re.finditer(r'(?<!\w)([A-Za-z][A-Za-z0-9_]* [A-Za-z][A-Za-z0-9_]*)\s*[\[\({{>]', stripped):
f"WARNING (line {lineno}): Node ID '{m.group(1)}' contains a space.\n"
f" Use underscores or camelCase for node IDs; spaces break parsing."
# Bare -> (single dash) instead of -->
if re.search(r'(?<!-)->(?!>)', stripped):
f"WARNING (line {lineno}): Single-dash arrow '->' is not valid in flowchart.\n"
f" Use '-->' (dashed) or '---' (no arrowhead)."
# ── sequence diagram checks ────────────────────────────────────────────────
participants_declared = set()
participants_used = set()
for lineno, ln in lines[1:]:
if not stripped or stripped.startswith("%%"):
# Collect declared participants
m = re.match(r'^(?:participant|actor)\s+(\S+)', stripped)
participants_declared.add(m.group(1))
# Wrong arrow type: flowchart --> used inside sequenceDiagram
if re.search(r'\w\s*-->\s*\w', stripped) and '->>' not in stripped and '-->>' not in stripped:
f"WARNING (line {lineno}): '-->' is flowchart syntax inside a sequenceDiagram.\n"
f" Use '->>' (solid open arrow) or '-->>' (dashed open arrow)."
# Collect participants used in messages
msg = re.match(r'^([A-Za-z0-9_]+)\s*-?->>?\+?\s*([A-Za-z0-9_]+)\s*:', stripped)
participants_used.add(msg.group(1))
participants_used.add(msg.group(2))
# Warn on implicit participants (not necessarily wrong but risky)
undeclared = participants_used - participants_declared
if undeclared and len(participants_declared) > 0:
# Only warn if some are declared (implicit all-or-nothing)
f"WARNING: Participants used but not declared with 'participant': "
f"{', '.join(sorted(undeclared))}.\n"
f" Add 'participant {list(undeclared)[0]}' etc. at the top of the diagram."
# ── class diagram checks ───────────────────────────────────────────────────
for lineno, ln in lines[1:]:
if not stripped or stripped.startswith("%%"):
# Common mistake: Dog |-- Animal (reversed inheritance)
if re.search(r'\w+\s*\|--\s*\w+', stripped) and '<' not in stripped:
f"WARNING (line {lineno}): Inheritance arrow may be reversed.\n"
f" Convention is 'Parent <|-- Child', not 'Child |-- Parent'.\n"
# ── Gantt checks ───────────────────────────────────────────────────────────
ln.strip().startswith("dateFormat") for _, ln in lines[1:]
re.match(r'^\s+\S.*:.*,', ln) for _, ln in lines[1:]
if has_tasks and not has_date_format:
"ERROR: Gantt diagram has tasks but no 'dateFormat' declaration.\n"
" Add 'dateFormat YYYY-MM-DD' (or your format) before the first section."
# ── pie chart checks ────────────────────────────────────────────────────────
for lineno, ln in lines[1:]:
if not stripped or stripped.startswith("%%") or stripped.startswith("title"):
label = stripped.split(":")[0].strip()
if label and not label.startswith('"') and not label.startswith("%%"):
f"ERROR (line {lineno}): Pie slice label must be quoted.\n"
f" Fix: \"{label}\" : {stripped.split(':', 1)[1].strip()}"
# ── ER diagram checks ───────────────────────────────────────────────────────
for lineno, ln in lines[1:]:
if not stripped or stripped.startswith("%%") or "{" in stripped or "}" == stripped:
# Relationship lines: ENTITY1 CARDINALITY--CARDINALITY ENTITY2 : label
rel = re.search(r'\w+\s+([^\s]+--[^\s]+)\s+\w+', stripped)
cardinality_str = rel.group(1)
# Check for common wrong syntax
if "--" not in cardinality_str and "." not in cardinality_str:
f"WARNING (line {lineno}): ER relationship may have invalid cardinality syntax.\n"
f" Expected pattern: ENTITY1 ||--o{{ ENTITY2 : \"label\"\n"
# ── inline-icon placement checks ────────────────────────────────────────────
# Inline `fas:`/`fab:` tokens only become icons in the unified-renderer types.
# Elsewhere they leak as literal text, get dropped, or break the parse.
icon_unsupported = diagram_type not in ICON_SUPPORTED_TYPES
for lineno, ln in lines[1:]:
if not stripped or stripped.startswith("%%"):
if icon_unsupported and ICON_TOKEN_RE.search(stripped):
if diagram_type in ICON_PARSER_BREAK_TYPES:
f"ERROR (line {lineno}): Inline FontAwesome token in a "
f"'{diagram_type}' — the ':' breaks the parser. Remove it; "
f"keep the label plain and monotone."
f"WARNING (line {lineno}): Inline FontAwesome token in a "
f"'{diagram_type}' does not render — it leaks as literal text "
f"or is dropped. Remove it (keep the label plain), or model "
f"the diagram as a flowchart if it truly needs icons."
# Mindmap supports inline `fas:fa-*` but NOT `::icon()` from the
# stylesheet alone — that form needs a registered icon pack.
if diagram_type == "mindmap" and "::icon(" in stripped:
f"WARNING (line {lineno}): mindmap '::icon(...)' renders nothing "
f"without a registered icon pack. Use the inline form instead — "
f"put `fas:fa-name ` at the start of the node label."
# ── mmdc integration (optional) ────────────────────────────────────────────────
def try_mmdc(diagram: str) -> list[str]:
"""If mmdc is installed, write diagram to a temp file and validate via mmdc."""
["mmdc", "--version"], capture_output=True, timeout=5
if result.returncode != 0:
except (FileNotFoundError, subprocess.TimeoutExpired):
with tempfile.NamedTemporaryFile(
mode="w", suffix=".mmd", delete=False
out_path = tmp_path.replace(".mmd", ".svg")
["mmdc", "-i", tmp_path, "-o", out_path],
capture_output=True, text=True, timeout=30
if result.returncode != 0:
stderr = (result.stderr or result.stdout or "").strip()
f"ERROR (mmdc): Mermaid CLI rejected the diagram:\n {stderr}"
except subprocess.TimeoutExpired:
issues.append("WARNING (mmdc): timed out — skipping CLI validation.")
svg = tmp_path.replace(".mmd", ".svg")
# ── main ────────────────────────────────────────────────────────────────────────
def lint_file(path: Path) -> tuple[int, int]:
"""Lint one file and print its report. Returns (errors, warnings)."""
content = path.read_text(encoding="utf-8")
if path.suffix in (".md", ".mdx"):
blocks = extract_mermaid_blocks(content)
print("No ```mermaid blocks found in file.")
for idx, (block, start_line) in enumerate(blocks, 1):
header = f"Diagram {idx} (starts line {start_line})" if len(blocks) > 1 else "Diagram"
issues = check_diagram(block, offset=start_line)
mmdc_issues = try_mmdc(block)
all_issues = issues + mmdc_issues
print(f"✓ {header}: No issues found.")
print(f"\n── {header} ──")
if issue.startswith("ERROR"):
elif issue.startswith("WARNING"):
return total_errors, total_warnings
print("Usage: python3 lint.py <file.md|file.mdx|file.mmd> [more files ...]", file=sys.stderr)
print(f"ERROR: File not found: {path}", file=sys.stderr)
errors, warnings = lint_file(path)
total_warnings += warnings
if total_errors == 0 and total_warnings == 0:
print("All diagrams passed.")
print(f"Summary: {total_errors} error(s), {total_warnings} warning(s)")
sys.exit(1 if total_errors > 0 else 0)
if __name__ == "__main__":