Skip to main content

merman_analysis/
rules.rs

1use crate::{
2    AnalysisDiagnostic, AnalysisStatus, DiagnosticCategory, DiagnosticFix, DiagnosticFixEdit,
3    DiagnosticSeverity, DiagnosticSpan, SourceMap,
4    source_directives::{
5        directive_keyword_spans, frontmatter_config_key_spans, init_directive_config_key_spans,
6    },
7};
8use merman_core::{
9    BLOCK_WIDTH_WARNING_RULE_ID, DiagramWarningFact, FLOWCHART_EXPLICIT_DIRECTION_WARNING_RULE_ID,
10    FLOWCHART_UNKNOWN_STYLE_TARGET_WARNING_RULE_ID, GIT_GRAPH_DUPLICATE_COMMIT_WARNING_RULE_ID,
11};
12use serde::{Deserialize, Serialize};
13use serde_json::Value;
14use std::collections::{BTreeMap, BTreeSet};
15
16pub const PREFER_INIT_DIRECTIVE_RULE_ID: &str = "merman.authoring.config.prefer_init_directive";
17pub const PREFER_FRONTMATTER_CONFIG_RULE_ID: &str =
18    "merman.authoring.config.prefer_frontmatter_config";
19pub const DEPRECATED_FLOWCHART_HTML_LABELS_RULE_ID: &str =
20    "merman.compatibility.config.deprecated_flowchart_html_labels";
21pub const DEPRECATED_EXTERNAL_DIAGRAM_LOADING_RULE_ID: &str =
22    "merman.compatibility.config.deprecated_external_diagram_loading";
23pub const NO_DIAGRAM_RULE_ID: &str = "merman.parse.no_diagram";
24pub const DIAGRAM_PARSE_RULE_ID: &str = "merman.parse.diagram_parse";
25pub const UNSUPPORTED_DIAGRAM_RULE_ID: &str = "merman.compatibility.unsupported_diagram";
26pub const RECOVERED_EDITOR_FACTS_RULE_ID: &str = "merman.parse.recovered_editor_facts";
27pub const RESOURCE_LIMIT_RULE_ID: &str = "merman.resource.source_bytes_exceeded";
28pub const MALFORMED_FRONT_MATTER_RULE_ID: &str = "merman.config.malformed_front_matter";
29pub const INVALID_DIRECTIVE_JSON_RULE_ID: &str = "merman.config.invalid_directive_json";
30pub const INVALID_FRONT_MATTER_YAML_RULE_ID: &str = "merman.config.invalid_front_matter_yaml";
31pub const PANIC_RULE_ID: &str = "merman.internal.panic";
32pub const INTERNAL_RULE_REGISTRY_GAP_RULE_ID: &str = "merman.internal.rule_registry_gap";
33pub const FLOWCHART_FACTS_PROJECTION_RULE_ID: &str = "merman.internal.flowchart_facts_projection";
34pub const BLOCK_WIDTH_RULE_ID: &str = "merman.block.width_exceeds_columns";
35pub const FLOWCHART_EXPLICIT_DIRECTION_RULE_ID: &str =
36    "merman.authoring.flowchart.explicit_direction";
37pub const FLOWCHART_UNKNOWN_STYLE_TARGET_RULE_ID: &str =
38    "merman.semantic.flowchart.unknown_style_target";
39pub const GIT_GRAPH_DUPLICATE_COMMIT_RULE_ID: &str = "merman.git_graph.duplicate_commit_id";
40pub const RULE_CATALOG_RESPONSE_VERSION: u32 = 1;
41
42const DEPRECATED_FLOWCHART_HTML_LABELS_INIT_CONFIG_PATHS: [&[&str]; 1] =
43    [&["flowchart", "htmlLabels"]];
44const DEPRECATED_FLOWCHART_HTML_LABELS_FLOWCHART_INIT_WRAPPER_PATHS: [&[&str]; 2] = [
45    &["config", "htmlLabels"],
46    &["config", "flowchart", "htmlLabels"],
47];
48const DEPRECATED_FLOWCHART_HTML_LABELS_FRONTMATTER_CONFIG_PATHS: [&[&str]; 2] = [
49    &["flowchart", "htmlLabels"],
50    &["config", "flowchart", "htmlLabels"],
51];
52const DEPRECATED_EXTERNAL_DIAGRAM_LOADING_CONFIG_PATHS: [&[&str]; 2] =
53    [&["lazyLoadedDiagrams"], &["loadExternalDiagramsAtStartup"]];
54const DEPRECATED_EXTERNAL_DIAGRAM_LOADING_FRONTMATTER_CONFIG_PATHS: [&[&str]; 2] = [
55    &["config", "lazyLoadedDiagrams"],
56    &["config", "loadExternalDiagramsAtStartup"],
57];
58
59#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize)]
60#[serde(rename_all = "snake_case")]
61pub enum AnalysisRuleProfile {
62    #[default]
63    Core,
64    Recommended,
65    Strict,
66}
67
68impl AnalysisRuleProfile {
69    pub const fn as_str(self) -> &'static str {
70        match self {
71            Self::Core => "core",
72            Self::Recommended => "recommended",
73            Self::Strict => "strict",
74        }
75    }
76
77    const fn includes(self, minimum: Self) -> bool {
78        self as u8 >= minimum as u8
79    }
80}
81
82#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
83#[serde(rename_all = "snake_case")]
84pub enum RuleOrigin {
85    MermaidSyntax,
86    MermaidCompatibility,
87    MermanAuthoring,
88    MermanResourcePolicy,
89    MermanInternal,
90}
91
92impl RuleOrigin {
93    pub const fn as_str(self) -> &'static str {
94        match self {
95            Self::MermaidSyntax => "mermaid_syntax",
96            Self::MermaidCompatibility => "mermaid_compatibility",
97            Self::MermanAuthoring => "merman_authoring",
98            Self::MermanResourcePolicy => "merman_resource_policy",
99            Self::MermanInternal => "merman_internal",
100        }
101    }
102}
103
104#[derive(Debug, Clone, Copy, PartialEq, Eq)]
105pub struct RuleDescriptor {
106    pub id: &'static str,
107    pub description: &'static str,
108    pub evidence: &'static [&'static str],
109    pub default_severity: DiagnosticSeverity,
110    pub category: DiagnosticCategory,
111    pub default_enabled: bool,
112    pub default_profile: AnalysisRuleProfile,
113    pub origin: RuleOrigin,
114    pub fixable: bool,
115}
116
117#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
118pub struct RuleCatalogEntry {
119    pub id: &'static str,
120    pub description: &'static str,
121    pub evidence: &'static [&'static str],
122    pub default_severity: DiagnosticSeverity,
123    pub category: DiagnosticCategory,
124    pub default_enabled: bool,
125    pub default_profile: AnalysisRuleProfile,
126    pub origin: RuleOrigin,
127    pub configurable: bool,
128    pub fixable: bool,
129}
130
131impl RuleCatalogEntry {
132    fn from_descriptor(descriptor: RuleDescriptor) -> Self {
133        Self {
134            id: descriptor.id,
135            description: descriptor.description,
136            evidence: descriptor.evidence,
137            default_severity: descriptor.default_severity,
138            category: descriptor.category,
139            default_enabled: descriptor.default_enabled,
140            default_profile: descriptor.default_profile,
141            origin: descriptor.origin,
142            configurable: is_configurable_rule_descriptor(descriptor),
143            fixable: descriptor.fixable,
144        }
145    }
146}
147
148#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
149pub struct RuleCatalogResponse {
150    pub version: u32,
151    pub rules: Vec<RuleCatalogEntry>,
152}
153
154impl RuleCatalogResponse {
155    pub fn from_rules(rules: Vec<RuleCatalogEntry>) -> Self {
156        Self {
157            version: RULE_CATALOG_RESPONSE_VERSION,
158            rules,
159        }
160    }
161
162    pub fn current() -> Self {
163        Self::from_rules(rule_catalog())
164    }
165
166    pub fn configurable() -> Self {
167        Self::from_rules(configurable_rule_catalog())
168    }
169}
170
171const PREFER_INIT_DIRECTIVE_RULE: RuleDescriptor = RuleDescriptor {
172    id: PREFER_INIT_DIRECTIVE_RULE_ID,
173    description: "Prefer the canonical `init` directive keyword over the accepted `initialize` alias.",
174    evidence: &[
175        "https://github.com/mermaid-js/mermaid/blob/41646dfd43ac83f001b03c70605feb036afae46d/packages/mermaid/src/utils.ts",
176        "docs/adr/0072-lint-rule-governance.md",
177    ],
178    default_severity: DiagnosticSeverity::Hint,
179    category: DiagnosticCategory::Config,
180    default_enabled: false,
181    default_profile: AnalysisRuleProfile::Recommended,
182    origin: RuleOrigin::MermanAuthoring,
183    fixable: true,
184};
185
186const PREFER_FRONTMATTER_CONFIG_RULE: RuleDescriptor = RuleDescriptor {
187    id: PREFER_FRONTMATTER_CONFIG_RULE_ID,
188    description: "Prefer diagram frontmatter `config` over Mermaid init directives.",
189    evidence: &[
190        "https://github.com/mermaid-js/mermaid/blob/41646dfd43ac83f001b03c70605feb036afae46d/packages/mermaid/src/docs/config/directives.md",
191        "https://github.com/mermaid-js/mermaid/blob/41646dfd43ac83f001b03c70605feb036afae46d/packages/mermaid/src/docs/config/configuration.md",
192    ],
193    default_severity: DiagnosticSeverity::Hint,
194    category: DiagnosticCategory::Config,
195    default_enabled: false,
196    default_profile: AnalysisRuleProfile::Recommended,
197    origin: RuleOrigin::MermanAuthoring,
198    fixable: true,
199};
200
201const DEPRECATED_FLOWCHART_HTML_LABELS_RULE: RuleDescriptor = RuleDescriptor {
202    id: DEPRECATED_FLOWCHART_HTML_LABELS_RULE_ID,
203    description: "Report deprecated `flowchart.htmlLabels` config and recommend the root-level `htmlLabels` option.",
204    evidence: &[
205        "https://github.com/mermaid-js/mermaid/blob/41646dfd43ac83f001b03c70605feb036afae46d/packages/mermaid/src/config.ts",
206        "https://github.com/mermaid-js/mermaid/blob/41646dfd43ac83f001b03c70605feb036afae46d/packages/mermaid/src/config.type.ts",
207        "https://github.com/mermaid-js/mermaid/blob/41646dfd43ac83f001b03c70605feb036afae46d/packages/mermaid/src/docs/config/directives.md",
208    ],
209    default_severity: DiagnosticSeverity::Warning,
210    category: DiagnosticCategory::Config,
211    default_enabled: true,
212    default_profile: AnalysisRuleProfile::Core,
213    origin: RuleOrigin::MermaidCompatibility,
214    fixable: false,
215};
216
217const DEPRECATED_EXTERNAL_DIAGRAM_LOADING_RULE: RuleDescriptor = RuleDescriptor {
218    id: DEPRECATED_EXTERNAL_DIAGRAM_LOADING_RULE_ID,
219    description: "Report deprecated external diagram loading config and recommend `registerExternalDiagrams`.",
220    evidence: &[
221        "https://github.com/mermaid-js/mermaid/blob/41646dfd43ac83f001b03c70605feb036afae46d/packages/mermaid/src/config.ts",
222        "https://github.com/mermaid-js/mermaid/blob/41646dfd43ac83f001b03c70605feb036afae46d/packages/mermaid/src/mermaid.ts",
223        "https://github.com/mermaid-js/mermaid/blob/41646dfd43ac83f001b03c70605feb036afae46d/packages/mermaid/src/mermaid.spec.ts",
224    ],
225    default_severity: DiagnosticSeverity::Warning,
226    category: DiagnosticCategory::Config,
227    default_enabled: true,
228    default_profile: AnalysisRuleProfile::Core,
229    origin: RuleOrigin::MermaidCompatibility,
230    fixable: false,
231};
232
233const NO_DIAGRAM_RULE: RuleDescriptor = RuleDescriptor {
234    id: NO_DIAGRAM_RULE_ID,
235    description: "Report input that does not contain a Mermaid diagram.",
236    evidence: &[
237        "https://github.com/mermaid-js/mermaid/blob/41646dfd43ac83f001b03c70605feb036afae46d/packages/mermaid/src/diagram-api/detectType.ts",
238        "https://github.com/mermaid-js/mermaid/blob/41646dfd43ac83f001b03c70605feb036afae46d/packages/mermaid/src/mermaid.spec.ts",
239    ],
240    default_severity: DiagnosticSeverity::Error,
241    category: DiagnosticCategory::Parse,
242    default_enabled: true,
243    default_profile: AnalysisRuleProfile::Core,
244    origin: RuleOrigin::MermaidSyntax,
245    fixable: false,
246};
247
248const DIAGRAM_PARSE_RULE: RuleDescriptor = RuleDescriptor {
249    id: DIAGRAM_PARSE_RULE_ID,
250    description: "Report Mermaid diagram syntax that the parser cannot accept.",
251    evidence: &[
252        "https://github.com/mermaid-js/mermaid/blob/41646dfd43ac83f001b03c70605feb036afae46d/packages/mermaid/src/mermaid.ts",
253        "docs/adr/0070-diagnostics-first-analysis-contract.md",
254    ],
255    default_severity: DiagnosticSeverity::Error,
256    category: DiagnosticCategory::Parse,
257    default_enabled: true,
258    default_profile: AnalysisRuleProfile::Core,
259    origin: RuleOrigin::MermaidSyntax,
260    fixable: false,
261};
262
263const UNSUPPORTED_DIAGRAM_RULE: RuleDescriptor = RuleDescriptor {
264    id: UNSUPPORTED_DIAGRAM_RULE_ID,
265    description: "Report Mermaid diagram types that are recognized but unavailable in this build.",
266    evidence: &[
267        "https://github.com/mermaid-js/mermaid/blob/41646dfd43ac83f001b03c70605feb036afae46d/packages/mermaid/src/diagram-api/detectType.ts",
268        "docs/release/PACKAGE_SURFACES.md",
269    ],
270    default_severity: DiagnosticSeverity::Error,
271    category: DiagnosticCategory::Compatibility,
272    default_enabled: true,
273    default_profile: AnalysisRuleProfile::Core,
274    origin: RuleOrigin::MermaidCompatibility,
275    fixable: false,
276};
277
278const RECOVERED_EDITOR_FACTS_RULE: RuleDescriptor = RuleDescriptor {
279    id: RECOVERED_EDITOR_FACTS_RULE_ID,
280    description: "Report parser recovery diagnostics emitted while producing editor semantic facts.",
281    evidence: &[
282        "https://github.com/mermaid-js/mermaid/blob/41646dfd43ac83f001b03c70605feb036afae46d/packages/mermaid/src/mermaid.ts",
283        "docs/adr/0070-diagnostics-first-analysis-contract.md",
284    ],
285    default_severity: DiagnosticSeverity::Warning,
286    category: DiagnosticCategory::Parse,
287    default_enabled: true,
288    default_profile: AnalysisRuleProfile::Core,
289    origin: RuleOrigin::MermaidSyntax,
290    fixable: false,
291};
292
293const RESOURCE_LIMIT_RULE: RuleDescriptor = RuleDescriptor {
294    id: RESOURCE_LIMIT_RULE_ID,
295    description: "Report Mermaid sources that exceed the configured analysis source byte budget.",
296    evidence: &[
297        "docs/adr/0070-diagnostics-first-analysis-contract.md",
298        "docs/bindings/OPTIONS_JSON.md",
299    ],
300    default_severity: DiagnosticSeverity::Error,
301    category: DiagnosticCategory::Resource,
302    default_enabled: true,
303    default_profile: AnalysisRuleProfile::Core,
304    origin: RuleOrigin::MermanResourcePolicy,
305    fixable: false,
306};
307
308const MALFORMED_FRONT_MATTER_RULE: RuleDescriptor = RuleDescriptor {
309    id: MALFORMED_FRONT_MATTER_RULE_ID,
310    description: "Report malformed YAML front matter blocks before diagram parsing.",
311    evidence: &[
312        "https://github.com/mermaid-js/mermaid/blob/41646dfd43ac83f001b03c70605feb036afae46d/packages/mermaid/src/diagram-api/frontmatter.ts",
313        "https://github.com/mermaid-js/mermaid/blob/41646dfd43ac83f001b03c70605feb036afae46d/packages/mermaid/src/diagram-api/frontmatter.spec.ts",
314    ],
315    default_severity: DiagnosticSeverity::Error,
316    category: DiagnosticCategory::Config,
317    default_enabled: true,
318    default_profile: AnalysisRuleProfile::Core,
319    origin: RuleOrigin::MermaidSyntax,
320    fixable: false,
321};
322
323const INVALID_DIRECTIVE_JSON_RULE: RuleDescriptor = RuleDescriptor {
324    id: INVALID_DIRECTIVE_JSON_RULE_ID,
325    description: "Report Mermaid directive blocks whose JSON payload cannot be parsed.",
326    evidence: &[
327        "https://github.com/mermaid-js/mermaid/blob/41646dfd43ac83f001b03c70605feb036afae46d/packages/mermaid/src/diagram-api/regexes.ts",
328        "https://github.com/mermaid-js/mermaid/blob/41646dfd43ac83f001b03c70605feb036afae46d/packages/mermaid/src/utils.ts",
329    ],
330    default_severity: DiagnosticSeverity::Error,
331    category: DiagnosticCategory::Config,
332    default_enabled: true,
333    default_profile: AnalysisRuleProfile::Core,
334    origin: RuleOrigin::MermaidSyntax,
335    fixable: false,
336};
337
338const INVALID_FRONT_MATTER_YAML_RULE: RuleDescriptor = RuleDescriptor {
339    id: INVALID_FRONT_MATTER_YAML_RULE_ID,
340    description: "Report Mermaid front matter whose YAML payload cannot be parsed.",
341    evidence: &[
342        "https://github.com/mermaid-js/mermaid/blob/41646dfd43ac83f001b03c70605feb036afae46d/packages/mermaid/src/diagram-api/frontmatter.ts",
343        "https://github.com/mermaid-js/mermaid/blob/41646dfd43ac83f001b03c70605feb036afae46d/packages/mermaid/src/diagram-api/frontmatter.spec.ts",
344    ],
345    default_severity: DiagnosticSeverity::Error,
346    category: DiagnosticCategory::Config,
347    default_enabled: true,
348    default_profile: AnalysisRuleProfile::Core,
349    origin: RuleOrigin::MermaidSyntax,
350    fixable: false,
351};
352
353const PANIC_RULE: RuleDescriptor = RuleDescriptor {
354    id: PANIC_RULE_ID,
355    description: "Report an internal panic caught while analyzing Mermaid source.",
356    evidence: &["docs/adr/0070-diagnostics-first-analysis-contract.md"],
357    default_severity: DiagnosticSeverity::Error,
358    category: DiagnosticCategory::Internal,
359    default_enabled: true,
360    default_profile: AnalysisRuleProfile::Core,
361    origin: RuleOrigin::MermanInternal,
362    fixable: false,
363};
364
365const INTERNAL_RULE_REGISTRY_GAP_RULE: RuleDescriptor = RuleDescriptor {
366    id: INTERNAL_RULE_REGISTRY_GAP_RULE_ID,
367    description: "Report an internal rule registry gap while projecting diagnostics.",
368    evidence: &["docs/adr/0072-lint-rule-governance.md"],
369    default_severity: DiagnosticSeverity::Error,
370    category: DiagnosticCategory::Internal,
371    default_enabled: true,
372    default_profile: AnalysisRuleProfile::Core,
373    origin: RuleOrigin::MermanInternal,
374    fixable: false,
375};
376
377const FLOWCHART_FACTS_PROJECTION_RULE: RuleDescriptor = RuleDescriptor {
378    id: FLOWCHART_FACTS_PROJECTION_RULE_ID,
379    description: "Report an internal failure while projecting flowchart parser model facts.",
380    evidence: &["docs/adr/0070-diagnostics-first-analysis-contract.md"],
381    default_severity: DiagnosticSeverity::Error,
382    category: DiagnosticCategory::Internal,
383    default_enabled: true,
384    default_profile: AnalysisRuleProfile::Core,
385    origin: RuleOrigin::MermanInternal,
386    fixable: false,
387};
388
389const BLOCK_WIDTH_RULE: RuleDescriptor = RuleDescriptor {
390    id: BLOCK_WIDTH_RULE_ID,
391    description: "Report block diagram entries that exceed the configured column width.",
392    evidence: &[
393        "https://github.com/mermaid-js/mermaid/blob/41646dfd43ac83f001b03c70605feb036afae46d/packages/mermaid/src/docs/syntax/block.md",
394    ],
395    default_severity: DiagnosticSeverity::Warning,
396    category: DiagnosticCategory::Semantic,
397    default_enabled: true,
398    default_profile: AnalysisRuleProfile::Core,
399    origin: RuleOrigin::MermaidCompatibility,
400    fixable: false,
401};
402const FLOWCHART_EXPLICIT_DIRECTION_RULE: RuleDescriptor = RuleDescriptor {
403    id: FLOWCHART_EXPLICIT_DIRECTION_RULE_ID,
404    description: "Recommend explicit flowchart header directions and offer an insertion quickfix.",
405    evidence: &[
406        "https://github.com/mermaid-js/mermaid/blob/41646dfd43ac83f001b03c70605feb036afae46d/packages/mermaid/src/docs/syntax/flowchart.md",
407        "docs/adr/0072-lint-rule-governance.md",
408    ],
409    default_severity: DiagnosticSeverity::Hint,
410    category: DiagnosticCategory::Semantic,
411    default_enabled: false,
412    default_profile: AnalysisRuleProfile::Recommended,
413    origin: RuleOrigin::MermanAuthoring,
414    fixable: true,
415};
416const FLOWCHART_UNKNOWN_STYLE_TARGET_RULE: RuleDescriptor = RuleDescriptor {
417    id: FLOWCHART_UNKNOWN_STYLE_TARGET_RULE_ID,
418    description: "Report flowchart `style` directives that would auto-create an unknown node target.",
419    evidence: &[
420        "https://github.com/mermaid-js/mermaid/blob/41646dfd43ac83f001b03c70605feb036afae46d/packages/mermaid/src/diagrams/flowchart/flowDb.ts",
421    ],
422    default_severity: DiagnosticSeverity::Warning,
423    category: DiagnosticCategory::Semantic,
424    default_enabled: true,
425    default_profile: AnalysisRuleProfile::Core,
426    origin: RuleOrigin::MermaidCompatibility,
427    fixable: false,
428};
429const GIT_GRAPH_DUPLICATE_COMMIT_RULE: RuleDescriptor = RuleDescriptor {
430    id: GIT_GRAPH_DUPLICATE_COMMIT_RULE_ID,
431    description: "Report duplicate gitGraph commit ids.",
432    evidence: &[
433        "https://github.com/mermaid-js/mermaid/blob/41646dfd43ac83f001b03c70605feb036afae46d/packages/mermaid/src/diagrams/git/gitGraphAst.ts",
434    ],
435    default_severity: DiagnosticSeverity::Warning,
436    category: DiagnosticCategory::Semantic,
437    default_enabled: true,
438    default_profile: AnalysisRuleProfile::Core,
439    origin: RuleOrigin::MermaidCompatibility,
440    fixable: false,
441};
442const RULE_DESCRIPTORS: &[RuleDescriptor] = &[
443    PREFER_INIT_DIRECTIVE_RULE,
444    PREFER_FRONTMATTER_CONFIG_RULE,
445    DEPRECATED_FLOWCHART_HTML_LABELS_RULE,
446    DEPRECATED_EXTERNAL_DIAGRAM_LOADING_RULE,
447    NO_DIAGRAM_RULE,
448    DIAGRAM_PARSE_RULE,
449    UNSUPPORTED_DIAGRAM_RULE,
450    RECOVERED_EDITOR_FACTS_RULE,
451    RESOURCE_LIMIT_RULE,
452    MALFORMED_FRONT_MATTER_RULE,
453    INVALID_DIRECTIVE_JSON_RULE,
454    INVALID_FRONT_MATTER_YAML_RULE,
455    PANIC_RULE,
456    INTERNAL_RULE_REGISTRY_GAP_RULE,
457    FLOWCHART_FACTS_PROJECTION_RULE,
458    BLOCK_WIDTH_RULE,
459    FLOWCHART_EXPLICIT_DIRECTION_RULE,
460    FLOWCHART_UNKNOWN_STYLE_TARGET_RULE,
461    GIT_GRAPH_DUPLICATE_COMMIT_RULE,
462];
463
464pub fn rule_descriptors() -> &'static [RuleDescriptor] {
465    RULE_DESCRIPTORS
466}
467
468pub fn rule_catalog() -> Vec<RuleCatalogEntry> {
469    RULE_DESCRIPTORS
470        .iter()
471        .copied()
472        .map(RuleCatalogEntry::from_descriptor)
473        .collect()
474}
475
476pub fn configurable_rule_catalog() -> Vec<RuleCatalogEntry> {
477    configurable_rule_descriptors()
478        .map(RuleCatalogEntry::from_descriptor)
479        .collect()
480}
481
482pub fn rule_catalog_response() -> RuleCatalogResponse {
483    RuleCatalogResponse::current()
484}
485
486pub fn configurable_rule_catalog_response() -> RuleCatalogResponse {
487    RuleCatalogResponse::configurable()
488}
489
490pub fn rule_catalog_response_json_bytes() -> Result<Vec<u8>, serde_json::Error> {
491    serde_json::to_vec(&rule_catalog_response())
492}
493
494pub fn configurable_rule_catalog_response_json_bytes() -> Result<Vec<u8>, serde_json::Error> {
495    serde_json::to_vec(&configurable_rule_catalog_response())
496}
497
498pub fn configurable_rule_descriptors() -> impl Iterator<Item = RuleDescriptor> {
499    RULE_DESCRIPTORS
500        .iter()
501        .copied()
502        .filter(|descriptor| is_configurable_rule_descriptor(*descriptor))
503}
504
505pub fn configurable_rule_descriptor(rule_id: &str) -> Option<RuleDescriptor> {
506    configurable_rule_descriptors().find(|descriptor| descriptor.id == rule_id)
507}
508
509pub fn rule_descriptor(rule_id: &str) -> Option<RuleDescriptor> {
510    RULE_DESCRIPTORS
511        .iter()
512        .copied()
513        .find(|descriptor| descriptor.id == rule_id)
514}
515
516fn is_configurable_rule_descriptor(descriptor: RuleDescriptor) -> bool {
517    !matches!(
518        descriptor.category,
519        DiagnosticCategory::Internal | DiagnosticCategory::Resource
520    )
521}
522
523#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
524pub struct AnalysisRuleConfig {
525    #[serde(default)]
526    profile: AnalysisRuleProfile,
527    #[serde(default)]
528    enabled_rules: BTreeSet<String>,
529    #[serde(default)]
530    disabled_rules: BTreeSet<String>,
531    #[serde(default)]
532    severity_overrides: BTreeMap<String, DiagnosticSeverity>,
533}
534
535impl AnalysisRuleConfig {
536    pub fn with_profile(mut self, profile: AnalysisRuleProfile) -> Self {
537        self.profile = profile;
538        self
539    }
540
541    pub fn profile(&self) -> AnalysisRuleProfile {
542        self.profile
543    }
544
545    pub fn with_rule_enabled(mut self, rule_id: impl Into<String>) -> Self {
546        self.enable_rule(rule_id);
547        self
548    }
549
550    pub fn with_rule_disabled(mut self, rule_id: impl Into<String>) -> Self {
551        self.disable_rule(rule_id);
552        self
553    }
554
555    pub fn with_rule_severity(
556        mut self,
557        rule_id: impl Into<String>,
558        severity: DiagnosticSeverity,
559    ) -> Self {
560        self.set_rule_severity(rule_id, severity);
561        self
562    }
563
564    pub fn set_profile(&mut self, profile: AnalysisRuleProfile) {
565        self.profile = profile;
566    }
567
568    pub fn enable_rule(&mut self, rule_id: impl Into<String>) {
569        self.enabled_rules.insert(rule_id.into());
570    }
571
572    pub fn disable_rule(&mut self, rule_id: impl Into<String>) {
573        self.disabled_rules.insert(rule_id.into());
574    }
575
576    pub fn set_rule_severity(&mut self, rule_id: impl Into<String>, severity: DiagnosticSeverity) {
577        self.severity_overrides.insert(rule_id.into(), severity);
578    }
579
580    pub fn is_rule_enabled(&self, descriptor: RuleDescriptor) -> bool {
581        if self.disabled_rules.contains(descriptor.id) {
582            return false;
583        }
584        if self.enabled_rules.contains(descriptor.id) {
585            return true;
586        }
587        descriptor.default_enabled || self.profile.includes(descriptor.default_profile)
588    }
589
590    pub fn severity_for(&self, descriptor: RuleDescriptor) -> DiagnosticSeverity {
591        self.severity_overrides
592            .get(descriptor.id)
593            .copied()
594            .unwrap_or(descriptor.default_severity)
595    }
596}
597
598pub(crate) fn source_lint_diagnostics(
599    source: &str,
600    source_map: &SourceMap,
601    rule_config: &AnalysisRuleConfig,
602) -> Vec<AnalysisDiagnostic> {
603    let mut diagnostics = init_directive_alias_diagnostics(source, source_map, rule_config);
604    diagnostics.extend(prefer_frontmatter_config_diagnostics(
605        source,
606        source_map,
607        rule_config,
608    ));
609    diagnostics.extend(deprecated_flowchart_html_labels_diagnostics(
610        source,
611        source_map,
612        rule_config,
613        &DEPRECATED_FLOWCHART_HTML_LABELS_INIT_CONFIG_PATHS,
614        &DEPRECATED_FLOWCHART_HTML_LABELS_FRONTMATTER_CONFIG_PATHS,
615    ));
616    diagnostics.extend(deprecated_external_diagram_loading_diagnostics(
617        source,
618        source_map,
619        rule_config,
620    ));
621    diagnostics
622}
623
624pub(crate) fn parsed_source_lint_diagnostics(
625    source: &str,
626    source_map: &SourceMap,
627    rule_config: &AnalysisRuleConfig,
628    diagram_type: &str,
629) -> Vec<AnalysisDiagnostic> {
630    if merman_core::diagram_type_family_kind(diagram_type) != Some("flowchart") {
631        return Vec::new();
632    }
633
634    deprecated_flowchart_html_labels_diagnostics(
635        source,
636        source_map,
637        rule_config,
638        &DEPRECATED_FLOWCHART_HTML_LABELS_FLOWCHART_INIT_WRAPPER_PATHS,
639        &[],
640    )
641}
642
643pub(crate) fn semantic_warning_diagnostics(
644    diagram_type: &str,
645    model: &Value,
646    source_map: &SourceMap,
647    rule_config: &AnalysisRuleConfig,
648) -> Vec<AnalysisDiagnostic> {
649    let span = source_map.whole_source_span().ok();
650    let Some(warning_facts) = model
651        .get("warningFacts")
652        .and_then(|value| serde_json::from_value::<Vec<DiagramWarningFact>>(value.clone()).ok())
653    else {
654        return Vec::new();
655    };
656
657    semantic_warning_fact_diagnostics(diagram_type, warning_facts, span, source_map, rule_config)
658}
659
660fn semantic_warning_fact_diagnostics(
661    diagram_type: &str,
662    warning_facts: Vec<DiagramWarningFact>,
663    fallback_span: Option<DiagnosticSpan>,
664    source_map: &SourceMap,
665    rule_config: &AnalysisRuleConfig,
666) -> Vec<AnalysisDiagnostic> {
667    let mut diagnostics = Vec::with_capacity(warning_facts.len());
668
669    for fact in warning_facts {
670        match warning_fact_rule_descriptor(&fact.rule_id) {
671            Some(descriptor) if rule_config.is_rule_enabled(descriptor) => {
672                diagnostics.push(warning_for_fact(
673                    diagram_type,
674                    fact,
675                    fallback_span.clone(),
676                    source_map,
677                    descriptor,
678                    rule_config,
679                ))
680            }
681            Some(_) => {}
682            None => diagnostics.push(
683                internal_rule_registry_gap_diagnostic(
684                    format!(
685                        "unknown warning fact rule id `{}`: {}",
686                        fact.rule_id, fact.message
687                    ),
688                    fallback_span.clone(),
689                )
690                .with_diagram_type(diagram_type),
691            ),
692        }
693    }
694
695    diagnostics
696}
697
698fn warning_for_fact(
699    diagram_type: &str,
700    fact: DiagramWarningFact,
701    fallback_span: Option<DiagnosticSpan>,
702    source_map: &SourceMap,
703    descriptor: RuleDescriptor,
704    rule_config: &AnalysisRuleConfig,
705) -> AnalysisDiagnostic {
706    let span = warning_fact_span(&fact, source_map, fallback_span);
707    let fix = warning_fact_fix(&fact, descriptor, source_map);
708    let mut diagnostic = AnalysisDiagnostic::new(
709        descriptor.id,
710        rule_config.severity_for(descriptor),
711        descriptor.category,
712        fact.message,
713    )
714    .with_diagram_type(diagram_type);
715
716    if let Some(span) = span {
717        diagnostic = diagnostic.with_span(span);
718    }
719
720    if let Some(fix) = fix {
721        diagnostic = diagnostic.with_fix(fix);
722    }
723
724    diagnostic
725}
726
727fn warning_fact_span(
728    fact: &DiagramWarningFact,
729    source_map: &SourceMap,
730    fallback_span: Option<DiagnosticSpan>,
731) -> Option<DiagnosticSpan> {
732    fact.span
733        .and_then(|span| source_map.span(span.start, span.end).ok())
734        .or(fallback_span)
735}
736
737fn warning_fact_fix(
738    fact: &DiagramWarningFact,
739    descriptor: RuleDescriptor,
740    source_map: &SourceMap,
741) -> Option<DiagnosticFix> {
742    let fix_span = fact.fix_span.or(fact.span)?;
743    let fix_span = source_map.span(fix_span.start, fix_span.end).ok()?;
744    match descriptor.id {
745        FLOWCHART_EXPLICIT_DIRECTION_RULE_ID => Some(
746            DiagnosticFix::new(
747                "Insert `TB` into the flowchart header",
748                vec![DiagnosticFixEdit::new(fix_span, " TB")],
749            )
750            .preferred(),
751        ),
752        _ => None,
753    }
754}
755
756fn warning_fact_rule_descriptor(rule_id: &str) -> Option<RuleDescriptor> {
757    match rule_id {
758        BLOCK_WIDTH_WARNING_RULE_ID => Some(BLOCK_WIDTH_RULE),
759        FLOWCHART_EXPLICIT_DIRECTION_WARNING_RULE_ID => Some(FLOWCHART_EXPLICIT_DIRECTION_RULE),
760        FLOWCHART_UNKNOWN_STYLE_TARGET_WARNING_RULE_ID => Some(FLOWCHART_UNKNOWN_STYLE_TARGET_RULE),
761        GIT_GRAPH_DUPLICATE_COMMIT_WARNING_RULE_ID => Some(GIT_GRAPH_DUPLICATE_COMMIT_RULE),
762        _ => None,
763    }
764}
765
766pub(crate) fn internal_rule_registry_gap_diagnostic(
767    message: impl Into<String>,
768    span: Option<DiagnosticSpan>,
769) -> AnalysisDiagnostic {
770    let mut diagnostic = AnalysisDiagnostic::error(
771        INTERNAL_RULE_REGISTRY_GAP_RULE_ID,
772        DiagnosticCategory::Internal,
773        message,
774    )
775    .with_code(
776        AnalysisStatus::InternalError.code(),
777        AnalysisStatus::InternalError.code_name(),
778    );
779
780    if let Some(span) = span {
781        diagnostic = diagnostic.with_span(span);
782    }
783
784    diagnostic
785}
786
787fn init_directive_alias_diagnostics(
788    source: &str,
789    source_map: &SourceMap,
790    rule_config: &AnalysisRuleConfig,
791) -> Vec<AnalysisDiagnostic> {
792    if !rule_config.is_rule_enabled(PREFER_INIT_DIRECTIVE_RULE) {
793        return Vec::new();
794    }
795    if rule_config.is_rule_enabled(PREFER_FRONTMATTER_CONFIG_RULE) {
796        return Vec::new();
797    }
798    let severity = rule_config.severity_for(PREFER_INIT_DIRECTIVE_RULE);
799
800    directive_keyword_spans(source)
801        .into_iter()
802        .filter_map(|keyword| {
803            (source.get(keyword.start..keyword.end) == Some("initialize"))
804                .then_some(keyword)
805        })
806        .filter_map(|keyword| {
807            let span = source_map.span(keyword.start, keyword.end).ok()?;
808            Some(
809                AnalysisDiagnostic::new(
810                    PREFER_INIT_DIRECTIVE_RULE.id,
811                    severity,
812                    PREFER_INIT_DIRECTIVE_RULE.category,
813                    "prefer `init` directive keyword over the `initialize` alias",
814                )
815                .with_span(span.clone())
816                .with_help("`initialize` is accepted as an alias; `init` is the canonical Mermaid directive keyword.")
817                .with_fix(
818                    DiagnosticFix::new(
819                        "Replace `initialize` with `init`",
820                        vec![DiagnosticFixEdit::new(span, "init")],
821                    )
822                    .preferred(),
823                ),
824            )
825        })
826        .collect()
827}
828
829fn prefer_frontmatter_config_diagnostics(
830    source: &str,
831    source_map: &SourceMap,
832    rule_config: &AnalysisRuleConfig,
833) -> Vec<AnalysisDiagnostic> {
834    if !rule_config.is_rule_enabled(PREFER_FRONTMATTER_CONFIG_RULE) {
835        return Vec::new();
836    }
837    let severity = rule_config.severity_for(PREFER_FRONTMATTER_CONFIG_RULE);
838    let fix = crate::source_config_rewrite::init_directives_to_frontmatter_fix(source, source_map);
839
840    directive_keyword_spans(source)
841        .into_iter()
842        .filter_map(|keyword| {
843            matches!(source.get(keyword.start..keyword.end), Some("init" | "initialize"))
844                .then_some(keyword)
845        })
846        .filter_map(|keyword| {
847            let span = source_map.span(keyword.start, keyword.end).ok()?;
848            let mut diagnostic = AnalysisDiagnostic::new(
849                PREFER_FRONTMATTER_CONFIG_RULE.id,
850                severity,
851                PREFER_FRONTMATTER_CONFIG_RULE.category,
852                "prefer frontmatter `config` over Mermaid init directives",
853            )
854            .with_span(span)
855            .with_help(
856                "Mermaid deprecated directives from v10.5.0; diagram authors should move configuration into the diagram frontmatter `config` block.",
857            );
858            if let Some(fix) = fix.clone() {
859                diagnostic = diagnostic.with_fix(fix);
860            }
861            Some(diagnostic)
862        })
863        .collect()
864}
865
866fn deprecated_flowchart_html_labels_diagnostics(
867    source: &str,
868    source_map: &SourceMap,
869    rule_config: &AnalysisRuleConfig,
870    init_matching_paths: &[&[&str]],
871    frontmatter_matching_paths: &[&[&str]],
872) -> Vec<AnalysisDiagnostic> {
873    config_key_diagnostics(
874        source,
875        source_map,
876        rule_config,
877        DEPRECATED_FLOWCHART_HTML_LABELS_RULE,
878        init_matching_paths,
879        frontmatter_matching_paths,
880        "`flowchart.htmlLabels` is deprecated; use root-level `htmlLabels` instead",
881        "Mermaid keeps `flowchart.htmlLabels` as a compatibility fallback, but root-level `htmlLabels` takes precedence.",
882    )
883}
884
885fn deprecated_external_diagram_loading_diagnostics(
886    source: &str,
887    source_map: &SourceMap,
888    rule_config: &AnalysisRuleConfig,
889) -> Vec<AnalysisDiagnostic> {
890    config_key_diagnostics(
891        source,
892        source_map,
893        rule_config,
894        DEPRECATED_EXTERNAL_DIAGRAM_LOADING_RULE,
895        &DEPRECATED_EXTERNAL_DIAGRAM_LOADING_CONFIG_PATHS,
896        &DEPRECATED_EXTERNAL_DIAGRAM_LOADING_FRONTMATTER_CONFIG_PATHS,
897        "deprecated external diagram loading config; use `registerExternalDiagrams` instead",
898        "Mermaid warns that `lazyLoadedDiagrams` and `loadExternalDiagramsAtStartup` are deprecated in favor of the `registerExternalDiagrams` API.",
899    )
900}
901
902fn config_key_diagnostics(
903    source: &str,
904    source_map: &SourceMap,
905    rule_config: &AnalysisRuleConfig,
906    descriptor: RuleDescriptor,
907    init_matching_paths: &[&[&str]],
908    frontmatter_matching_paths: &[&[&str]],
909    message: &'static str,
910    help: &'static str,
911) -> Vec<AnalysisDiagnostic> {
912    if !rule_config.is_rule_enabled(descriptor) {
913        return Vec::new();
914    }
915    let severity = rule_config.severity_for(descriptor);
916
917    let mut spans = init_directive_config_key_spans(source, init_matching_paths);
918    spans.extend(frontmatter_config_key_spans(
919        source,
920        frontmatter_matching_paths,
921    ));
922
923    spans
924        .into_iter()
925        .filter_map(|span| {
926            let span = source_map.span(span.start, span.end).ok()?;
927            Some(
928                AnalysisDiagnostic::new(descriptor.id, severity, descriptor.category, message)
929                    .with_span(span)
930                    .with_help(help),
931            )
932        })
933        .collect()
934}
935
936#[cfg(test)]
937mod tests;