Skip to main content

meta_language/grammar/
validate.rs

1//! Semantic validation for authored grammar IR values.
2
3use std::collections::{BTreeMap, BTreeSet};
4use std::error::Error;
5use std::fmt;
6use std::ops::Range;
7
8use super::{Grammar, GrammarExpr, GrammarRule};
9
10/// Where a grammar diagnostic applies.
11#[derive(Clone, Debug, PartialEq, Eq)]
12pub struct RuleSpan {
13    /// Rule receiving the diagnostic.
14    pub rule: String,
15    /// Byte range in grammar surface source when available.
16    pub span: Option<Range<usize>>,
17}
18
19/// Diagnostic severity.
20#[derive(Clone, Copy, Debug, PartialEq, Eq)]
21pub enum Severity {
22    /// The grammar is semantically invalid or unsafe to execute.
23    Error,
24    /// The grammar is accepted, but the finding is likely an authoring mistake.
25    Warning,
26}
27
28/// Classes of authoring defects detected by [`validate`].
29#[derive(Clone, Debug, PartialEq, Eq)]
30pub enum DiagnosticKind {
31    /// A non-terminal reference whose name no rule defines.
32    UndefinedNonTerminal {
33        /// Referenced rule name that is missing.
34        name: String,
35        /// Rule containing the missing reference.
36        referenced_in: String,
37    },
38    /// A rule can reach itself before consuming input.
39    LeftRecursion {
40        /// Rule chain proving the recursion, ending with the first rule again.
41        cycle: Vec<String>,
42    },
43    /// A rule is not reachable from the grammar start symbol.
44    UnreachableRule {
45        /// Unreachable rule name.
46        name: String,
47    },
48    /// A nullable repetition or suspicious nullable rule body was found.
49    NullableRepetition {
50        /// Rule containing the nullable construct.
51        rule: String,
52        /// Human-readable detail about the nullable construct.
53        detail: String,
54    },
55    /// More than one rule uses the same name.
56    DuplicateRule {
57        /// Duplicated rule name.
58        name: String,
59    },
60    /// A labelled capture has no semantic consumer in the grammar IR.
61    UnusedCapture {
62        /// Rule containing the labelled capture.
63        rule: String,
64        /// Capture label.
65        label: String,
66    },
67}
68
69/// One grammar validation finding.
70#[derive(Clone, Debug, PartialEq, Eq)]
71pub struct GrammarDiagnostic {
72    /// Structured class of the finding.
73    pub kind: DiagnosticKind,
74    /// Whether callers should treat the finding as blocking.
75    pub severity: Severity,
76    /// Friendly, fix-suggesting diagnostic text.
77    pub message: String,
78    /// Source rule location for the finding.
79    pub location: RuleSpan,
80}
81
82impl GrammarDiagnostic {
83    /// Returns true when this diagnostic should fail validation.
84    #[must_use]
85    pub fn is_error(&self) -> bool {
86        self.severity == Severity::Error
87    }
88}
89
90impl fmt::Display for GrammarDiagnostic {
91    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
92        formatter.write_str(&self.message)
93    }
94}
95
96impl Error for GrammarDiagnostic {}
97
98impl Grammar {
99    /// Runs semantic grammar validation.
100    #[must_use]
101    pub fn validate(&self) -> Vec<GrammarDiagnostic> {
102        validate(self)
103    }
104}
105
106/// Runs every grammar validation checker and returns deterministic diagnostics.
107#[must_use]
108pub fn validate(grammar: &Grammar) -> Vec<GrammarDiagnostic> {
109    let defined_names = defined_rule_names(grammar);
110    let nullability = compute_nullability(grammar);
111
112    let mut diagnostics = Vec::new();
113    diagnostics.extend(check_duplicate_rules(grammar));
114    diagnostics.extend(check_undefined_nonterminals(grammar, &defined_names));
115    diagnostics.extend(check_left_recursion(grammar, &nullability));
116    diagnostics.extend(check_unreachable_rules(grammar));
117    diagnostics.extend(check_nullable_repetition(grammar, &nullability));
118    diagnostics.extend(check_unused_captures(grammar));
119
120    sort_diagnostics(grammar, &mut diagnostics);
121    diagnostics
122}
123
124fn check_duplicate_rules(grammar: &Grammar) -> Vec<GrammarDiagnostic> {
125    let mut counts = BTreeMap::<String, usize>::new();
126    for rule in grammar.rules() {
127        *counts.entry(rule.name.clone()).or_default() += 1;
128    }
129
130    counts
131        .into_iter()
132        .filter(|(_, count)| *count > 1)
133        .map(|(name, _)| {
134            let message = format!(
135                "rule `{name}` is defined more than once; merge alternatives into one rule or rename the duplicate."
136            );
137            GrammarDiagnostic {
138                kind: DiagnosticKind::DuplicateRule { name: name.clone() },
139                severity: Severity::Error,
140                message,
141                location: rule_location(name),
142            }
143        })
144        .collect()
145}
146
147fn check_undefined_nonterminals(
148    grammar: &Grammar,
149    defined_names: &[String],
150) -> Vec<GrammarDiagnostic> {
151    let defined = defined_names
152        .iter()
153        .map(String::as_str)
154        .collect::<BTreeSet<_>>();
155    let mut diagnostics = Vec::new();
156
157    for rule in grammar.rules() {
158        for name in collect_nonterminals(rule.expr()) {
159            if defined.contains(name.as_str()) {
160                continue;
161            }
162
163            let suggestion = nearest_rule_name(&name, defined_names);
164            let message = suggestion.map_or_else(
165                || {
166                    format!(
167                        "rule `{}` references undefined non-terminal `{name}`; define it or fix the spelling.",
168                        rule.name()
169                    )
170                },
171                |candidate| {
172                    format!(
173                        "rule `{}` references undefined non-terminal `{name}`; did you mean `{candidate}`? Define it or fix the spelling.",
174                        rule.name()
175                    )
176                },
177            );
178            diagnostics.push(GrammarDiagnostic {
179                kind: DiagnosticKind::UndefinedNonTerminal {
180                    name,
181                    referenced_in: rule.name.clone(),
182                },
183                severity: Severity::Error,
184                message,
185                location: rule_location(rule.name()),
186            });
187        }
188    }
189
190    diagnostics
191}
192
193fn check_left_recursion(
194    grammar: &Grammar,
195    nullability: &BTreeMap<String, bool>,
196) -> Vec<GrammarDiagnostic> {
197    let graph = left_reference_graph(grammar, nullability);
198    let mut diagnostics = Vec::new();
199    let mut seen_cycles = BTreeSet::new();
200
201    for rule in grammar.rules() {
202        let mut path = vec![rule.name.clone()];
203        let mut seen_rules = BTreeSet::from([rule.name.clone()]);
204        collect_left_cycles(
205            rule.name(),
206            rule.name(),
207            &graph,
208            &mut seen_rules,
209            &mut path,
210            &mut seen_cycles,
211            &mut diagnostics,
212        );
213    }
214
215    diagnostics
216}
217
218fn collect_left_cycles(
219    target: &str,
220    current: &str,
221    graph: &BTreeMap<String, BTreeSet<String>>,
222    seen_rules: &mut BTreeSet<String>,
223    path: &mut Vec<String>,
224    seen_cycles: &mut BTreeSet<String>,
225    diagnostics: &mut Vec<GrammarDiagnostic>,
226) {
227    let Some(next_rules) = graph.get(current) else {
228        return;
229    };
230
231    for next in next_rules {
232        if next == target {
233            let mut cycle = path.clone();
234            cycle.push(target.to_string());
235            if seen_cycles.insert(canonical_cycle_key(&cycle)) {
236                let cycle_text = cycle.join(" -> ");
237                diagnostics.push(GrammarDiagnostic {
238                    kind: DiagnosticKind::LeftRecursion {
239                        cycle: cycle.clone(),
240                    },
241                    severity: Severity::Error,
242                    message: format!(
243                        "rule `{target}` is left-recursive (`{cycle_text}`); a recursive-descent/PEG parser may not terminate. Rewrite using repetition or factor the common prefix."
244                    ),
245                    location: rule_location(target),
246                });
247            }
248        } else if graph.contains_key(next) && seen_rules.insert(next.clone()) {
249            path.push(next.clone());
250            collect_left_cycles(
251                target,
252                next,
253                graph,
254                seen_rules,
255                path,
256                seen_cycles,
257                diagnostics,
258            );
259            path.pop();
260            seen_rules.remove(next);
261        }
262    }
263}
264
265fn check_unreachable_rules(grammar: &Grammar) -> Vec<GrammarDiagnostic> {
266    let Some(start_rule) = grammar.start_rule() else {
267        return Vec::new();
268    };
269
270    let defined = grammar
271        .rules()
272        .iter()
273        .map(GrammarRule::name)
274        .collect::<BTreeSet<_>>();
275    let mut reachable = BTreeSet::new();
276    let mut stack = vec![start_rule.name.clone()];
277
278    while let Some(name) = stack.pop() {
279        if !reachable.insert(name.clone()) {
280            continue;
281        }
282
283        if let Some(rule) = grammar.rule(&name) {
284            for reference in collect_nonterminals(rule.expr()) {
285                if defined.contains(reference.as_str()) {
286                    stack.push(reference);
287                }
288            }
289        }
290    }
291
292    grammar
293        .rules()
294        .iter()
295        .filter(|rule| !reachable.contains(rule.name()))
296        .map(|rule| GrammarDiagnostic {
297            kind: DiagnosticKind::UnreachableRule {
298                name: rule.name.clone(),
299            },
300            severity: Severity::Warning,
301            message: format!(
302                "rule `{}` is not reachable from start rule `{}`; remove it or reference it from a reachable rule.",
303                rule.name(),
304                start_rule.name()
305            ),
306            location: rule_location(rule.name()),
307        })
308        .collect()
309}
310
311fn check_nullable_repetition(
312    grammar: &Grammar,
313    nullability: &BTreeMap<String, bool>,
314) -> Vec<GrammarDiagnostic> {
315    let suspicious_nullable = compute_suspicious_nullability(grammar, nullability);
316    let mut diagnostics = Vec::new();
317
318    for rule in grammar.rules() {
319        if suspicious_nullable
320            .get(rule.name())
321            .copied()
322            .unwrap_or(false)
323        {
324            diagnostics.push(GrammarDiagnostic {
325                kind: DiagnosticKind::NullableRepetition {
326                    rule: rule.name.clone(),
327                    detail: "rule body is nullable".to_string(),
328                },
329                severity: Severity::Warning,
330                message: format!(
331                    "rule `{}` can match empty; add a required terminal/non-terminal or document the rule as intentional.",
332                    rule.name()
333                ),
334                location: rule_location(rule.name()),
335            });
336        }
337        collect_nullable_repetitions(rule.name(), rule.expr(), nullability, &mut diagnostics);
338    }
339
340    diagnostics
341}
342
343fn collect_nullable_repetitions(
344    rule_name: &str,
345    expr: &GrammarExpr,
346    nullability: &BTreeMap<String, bool>,
347    diagnostics: &mut Vec<GrammarDiagnostic>,
348) {
349    match expr {
350        GrammarExpr::ZeroOrMore(inner) => {
351            push_nullable_repetition(
352                rule_name,
353                "zero-or-more repetition",
354                inner,
355                nullability,
356                diagnostics,
357            );
358            collect_nullable_repetitions(rule_name, inner, nullability, diagnostics);
359        }
360        GrammarExpr::OneOrMore(inner) => {
361            push_nullable_repetition(
362                rule_name,
363                "one-or-more repetition",
364                inner,
365                nullability,
366                diagnostics,
367            );
368            collect_nullable_repetitions(rule_name, inner, nullability, diagnostics);
369        }
370        GrammarExpr::Repeat { expr: inner, .. } => {
371            push_nullable_repetition(
372                rule_name,
373                "counted repetition",
374                inner,
375                nullability,
376                diagnostics,
377            );
378            collect_nullable_repetitions(rule_name, inner, nullability, diagnostics);
379        }
380        GrammarExpr::Choice { alternatives, .. } => {
381            for alternative in alternatives {
382                collect_nullable_repetitions(rule_name, alternative, nullability, diagnostics);
383            }
384        }
385        GrammarExpr::Sequence(items) => {
386            for item in items {
387                collect_nullable_repetitions(rule_name, item, nullability, diagnostics);
388            }
389        }
390        GrammarExpr::Optional(inner)
391        | GrammarExpr::And(inner)
392        | GrammarExpr::Not(inner)
393        | GrammarExpr::Capture { expr: inner, .. } => {
394            collect_nullable_repetitions(rule_name, inner, nullability, diagnostics);
395        }
396        GrammarExpr::Empty
397        | GrammarExpr::Terminal(_)
398        | GrammarExpr::TerminalInsensitive(_)
399        | GrammarExpr::CharRange(_, _)
400        | GrammarExpr::CharClass { .. }
401        | GrammarExpr::AnyChar
402        | GrammarExpr::NonTerminal(_) => {}
403    }
404}
405
406fn push_nullable_repetition(
407    rule_name: &str,
408    repetition: &str,
409    inner: &GrammarExpr,
410    nullability: &BTreeMap<String, bool>,
411    diagnostics: &mut Vec<GrammarDiagnostic>,
412) {
413    if !expr_is_nullable(inner, nullability) {
414        return;
415    }
416
417    let detail = format!("{repetition} has nullable inner expression `{inner}`");
418    diagnostics.push(GrammarDiagnostic {
419        kind: DiagnosticKind::NullableRepetition {
420            rule: rule_name.to_string(),
421            detail: detail.clone(),
422        },
423        severity: Severity::Warning,
424        message: format!(
425            "rule `{rule_name}` uses a {detail}; make the repeated expression consume input or move the optional part outside the repetition."
426        ),
427        location: rule_location(rule_name),
428    });
429}
430
431fn check_unused_captures(grammar: &Grammar) -> Vec<GrammarDiagnostic> {
432    let mut diagnostics = Vec::new();
433    for rule in grammar.rules() {
434        collect_unused_captures(rule.name(), rule.expr(), &mut diagnostics);
435    }
436    diagnostics
437}
438
439fn collect_unused_captures(
440    rule_name: &str,
441    expr: &GrammarExpr,
442    diagnostics: &mut Vec<GrammarDiagnostic>,
443) {
444    match expr {
445        GrammarExpr::Capture {
446            label: Some(label),
447            expr,
448        } => {
449            diagnostics.push(GrammarDiagnostic {
450                kind: DiagnosticKind::UnusedCapture {
451                    rule: rule_name.to_string(),
452                    label: label.clone(),
453                },
454                severity: Severity::Warning,
455                message: format!(
456                    "capture label `{label}` in rule `{rule_name}` is not used by grammar semantics; remove the label or wire it to a consumer."
457                ),
458                location: rule_location(rule_name),
459            });
460            collect_unused_captures(rule_name, expr, diagnostics);
461        }
462        GrammarExpr::Capture { label: None, expr }
463        | GrammarExpr::Optional(expr)
464        | GrammarExpr::ZeroOrMore(expr)
465        | GrammarExpr::OneOrMore(expr)
466        | GrammarExpr::And(expr)
467        | GrammarExpr::Not(expr)
468        | GrammarExpr::Repeat { expr, .. } => collect_unused_captures(rule_name, expr, diagnostics),
469        GrammarExpr::Choice { alternatives, .. } => {
470            for alternative in alternatives {
471                collect_unused_captures(rule_name, alternative, diagnostics);
472            }
473        }
474        GrammarExpr::Sequence(items) => {
475            for item in items {
476                collect_unused_captures(rule_name, item, diagnostics);
477            }
478        }
479        GrammarExpr::Empty
480        | GrammarExpr::Terminal(_)
481        | GrammarExpr::TerminalInsensitive(_)
482        | GrammarExpr::CharRange(_, _)
483        | GrammarExpr::CharClass { .. }
484        | GrammarExpr::AnyChar
485        | GrammarExpr::NonTerminal(_) => {}
486    }
487}
488
489fn left_reference_graph(
490    grammar: &Grammar,
491    nullability: &BTreeMap<String, bool>,
492) -> BTreeMap<String, BTreeSet<String>> {
493    let defined = grammar
494        .rules()
495        .iter()
496        .map(GrammarRule::name)
497        .collect::<BTreeSet<_>>();
498    let mut graph = BTreeMap::<String, BTreeSet<String>>::new();
499
500    for rule in grammar.rules() {
501        let mut references = BTreeSet::new();
502        collect_left_references(rule.expr(), nullability, &mut references);
503        graph.entry(rule.name.clone()).or_default().extend(
504            references
505                .into_iter()
506                .filter(|name| defined.contains(name.as_str())),
507        );
508    }
509
510    graph
511}
512
513fn collect_left_references(
514    expr: &GrammarExpr,
515    nullability: &BTreeMap<String, bool>,
516    references: &mut BTreeSet<String>,
517) {
518    match expr {
519        GrammarExpr::NonTerminal(name) => {
520            references.insert(name.clone());
521        }
522        GrammarExpr::Choice { alternatives, .. } => {
523            for alternative in alternatives {
524                collect_left_references(alternative, nullability, references);
525            }
526        }
527        GrammarExpr::Sequence(items) => {
528            for item in items {
529                collect_left_references(item, nullability, references);
530                if !expr_is_nullable(item, nullability) {
531                    break;
532                }
533            }
534        }
535        GrammarExpr::Optional(expr)
536        | GrammarExpr::ZeroOrMore(expr)
537        | GrammarExpr::OneOrMore(expr)
538        | GrammarExpr::And(expr)
539        | GrammarExpr::Not(expr)
540        | GrammarExpr::Capture { expr, .. }
541        | GrammarExpr::Repeat { expr, .. } => {
542            collect_left_references(expr, nullability, references);
543        }
544        GrammarExpr::Empty
545        | GrammarExpr::Terminal(_)
546        | GrammarExpr::TerminalInsensitive(_)
547        | GrammarExpr::CharRange(_, _)
548        | GrammarExpr::CharClass { .. }
549        | GrammarExpr::AnyChar => {}
550    }
551}
552
553fn compute_nullability(grammar: &Grammar) -> BTreeMap<String, bool> {
554    let mut nullability = grammar
555        .rules()
556        .iter()
557        .map(|rule| (rule.name.clone(), false))
558        .collect::<BTreeMap<_, _>>();
559
560    loop {
561        let mut changed = false;
562        for rule in grammar.rules() {
563            if !expr_is_nullable(rule.expr(), &nullability) {
564                continue;
565            }
566
567            let entry = nullability.entry(rule.name.clone()).or_insert(false);
568            if !*entry {
569                *entry = true;
570                changed = true;
571            }
572        }
573
574        if !changed {
575            return nullability;
576        }
577    }
578}
579
580fn expr_is_nullable(expr: &GrammarExpr, nullability: &BTreeMap<String, bool>) -> bool {
581    match expr {
582        GrammarExpr::Terminal(value) | GrammarExpr::TerminalInsensitive(value) => value.is_empty(),
583        GrammarExpr::CharRange(_, _) | GrammarExpr::CharClass { .. } | GrammarExpr::AnyChar => {
584            false
585        }
586        GrammarExpr::NonTerminal(name) => nullability.get(name).copied().unwrap_or(false),
587        GrammarExpr::Choice { alternatives, .. } => alternatives
588            .iter()
589            .any(|alternative| expr_is_nullable(alternative, nullability)),
590        GrammarExpr::Sequence(items) => {
591            items.iter().all(|item| expr_is_nullable(item, nullability))
592        }
593        GrammarExpr::Empty
594        | GrammarExpr::Optional(_)
595        | GrammarExpr::ZeroOrMore(_)
596        | GrammarExpr::And(_)
597        | GrammarExpr::Not(_) => true,
598        GrammarExpr::OneOrMore(expr) | GrammarExpr::Capture { expr, .. } => {
599            expr_is_nullable(expr, nullability)
600        }
601        GrammarExpr::Repeat { expr, min, .. } => *min == 0 || expr_is_nullable(expr, nullability),
602    }
603}
604
605fn compute_suspicious_nullability(
606    grammar: &Grammar,
607    nullability: &BTreeMap<String, bool>,
608) -> BTreeMap<String, bool> {
609    let mut suspicious = grammar
610        .rules()
611        .iter()
612        .map(|rule| (rule.name.clone(), false))
613        .collect::<BTreeMap<_, _>>();
614
615    loop {
616        let mut changed = false;
617        for rule in grammar.rules() {
618            if !expr_is_suspicious_nullable(rule.expr(), nullability, &suspicious) {
619                continue;
620            }
621
622            let entry = suspicious.entry(rule.name.clone()).or_insert(false);
623            if !*entry {
624                *entry = true;
625                changed = true;
626            }
627        }
628
629        if !changed {
630            return suspicious;
631        }
632    }
633}
634
635fn expr_is_suspicious_nullable(
636    expr: &GrammarExpr,
637    nullability: &BTreeMap<String, bool>,
638    suspicious: &BTreeMap<String, bool>,
639) -> bool {
640    match expr {
641        GrammarExpr::Terminal(value) | GrammarExpr::TerminalInsensitive(value) => value.is_empty(),
642        GrammarExpr::CharRange(_, _) | GrammarExpr::CharClass { .. } | GrammarExpr::AnyChar => {
643            false
644        }
645        GrammarExpr::NonTerminal(name) => suspicious.get(name).copied().unwrap_or(false),
646        GrammarExpr::Choice { alternatives, .. } => alternatives
647            .iter()
648            .any(|alternative| expr_is_suspicious_nullable(alternative, nullability, suspicious)),
649        GrammarExpr::Sequence(items) => {
650            items.is_empty()
651                || (items.iter().all(|item| expr_is_nullable(item, nullability))
652                    && items
653                        .iter()
654                        .any(|item| expr_is_suspicious_nullable(item, nullability, suspicious)))
655        }
656        GrammarExpr::Empty
657        | GrammarExpr::Optional(_)
658        | GrammarExpr::And(_)
659        | GrammarExpr::Not(_) => true,
660        GrammarExpr::ZeroOrMore(expr) | GrammarExpr::OneOrMore(expr) => {
661            expr_is_nullable(expr, nullability)
662        }
663        GrammarExpr::Repeat { expr, .. } => expr_is_nullable(expr, nullability),
664        GrammarExpr::Capture { expr, .. } => {
665            expr_is_suspicious_nullable(expr, nullability, suspicious)
666        }
667    }
668}
669
670fn collect_nonterminals(expr: &GrammarExpr) -> BTreeSet<String> {
671    let mut names = BTreeSet::new();
672    collect_nonterminals_into(expr, &mut names);
673    names
674}
675
676fn collect_nonterminals_into(expr: &GrammarExpr, names: &mut BTreeSet<String>) {
677    match expr {
678        GrammarExpr::NonTerminal(name) => {
679            names.insert(name.clone());
680        }
681        GrammarExpr::Choice { alternatives, .. } => {
682            for alternative in alternatives {
683                collect_nonterminals_into(alternative, names);
684            }
685        }
686        GrammarExpr::Sequence(items) => {
687            for item in items {
688                collect_nonterminals_into(item, names);
689            }
690        }
691        GrammarExpr::Optional(expr)
692        | GrammarExpr::ZeroOrMore(expr)
693        | GrammarExpr::OneOrMore(expr)
694        | GrammarExpr::And(expr)
695        | GrammarExpr::Not(expr)
696        | GrammarExpr::Capture { expr, .. }
697        | GrammarExpr::Repeat { expr, .. } => collect_nonterminals_into(expr, names),
698        GrammarExpr::Empty
699        | GrammarExpr::Terminal(_)
700        | GrammarExpr::TerminalInsensitive(_)
701        | GrammarExpr::CharRange(_, _)
702        | GrammarExpr::CharClass { .. }
703        | GrammarExpr::AnyChar => {}
704    }
705}
706
707fn defined_rule_names(grammar: &Grammar) -> Vec<String> {
708    let mut names = Vec::new();
709    let mut seen = BTreeSet::new();
710    for rule in grammar.rules() {
711        if seen.insert(rule.name.clone()) {
712            names.push(rule.name.clone());
713        }
714    }
715    names
716}
717
718fn nearest_rule_name<'a>(name: &str, candidates: &'a [String]) -> Option<&'a str> {
719    let mut best = None;
720
721    for candidate in candidates {
722        let distance = levenshtein(name, candidate);
723        if distance > 2 {
724            continue;
725        }
726
727        let should_replace = best.map_or(true, |(best_name, best_distance)| {
728            distance < best_distance
729                || (distance == best_distance && candidate.as_str() < best_name)
730        });
731        if should_replace {
732            best = Some((candidate.as_str(), distance));
733        }
734    }
735
736    best.map(|(candidate, _)| candidate)
737}
738
739fn levenshtein(left: &str, right: &str) -> usize {
740    let left_chars = left.chars().collect::<Vec<_>>();
741    let right_chars = right.chars().collect::<Vec<_>>();
742    let mut previous = (0..=right_chars.len()).collect::<Vec<_>>();
743    let mut current = vec![0; right_chars.len() + 1];
744
745    for (left_index, left_char) in left_chars.iter().enumerate() {
746        current[0] = left_index + 1;
747        for (right_index, right_char) in right_chars.iter().enumerate() {
748            let substitution_cost = usize::from(left_char != right_char);
749            current[right_index + 1] = (previous[right_index + 1] + 1)
750                .min(current[right_index] + 1)
751                .min(previous[right_index] + substitution_cost);
752        }
753        previous.clone_from(&current);
754    }
755
756    previous[right_chars.len()]
757}
758
759fn canonical_cycle_key(cycle: &[String]) -> String {
760    let nodes = &cycle[..cycle.len().saturating_sub(1)];
761    if nodes.is_empty() {
762        return String::new();
763    }
764
765    let mut best = None;
766    for start in 0..nodes.len() {
767        let rotation = (0..nodes.len())
768            .map(|offset| nodes[(start + offset) % nodes.len()].as_str())
769            .collect::<Vec<_>>()
770            .join("\0");
771        if best
772            .as_ref()
773            .map_or(true, |candidate| rotation < *candidate)
774        {
775            best = Some(rotation);
776        }
777    }
778    best.unwrap_or_default()
779}
780
781fn sort_diagnostics(grammar: &Grammar, diagnostics: &mut [GrammarDiagnostic]) {
782    let indices = grammar
783        .rules()
784        .iter()
785        .enumerate()
786        .rev()
787        .map(|(index, rule)| (rule.name.clone(), index))
788        .collect::<BTreeMap<_, _>>();
789
790    diagnostics.sort_by(|left, right| {
791        let left_span = left
792            .location
793            .span
794            .as_ref()
795            .map_or(usize::MAX, |span| span.start);
796        let right_span = right
797            .location
798            .span
799            .as_ref()
800            .map_or(usize::MAX, |span| span.start);
801        let left_index = indices
802            .get(&left.location.rule)
803            .copied()
804            .unwrap_or(usize::MAX);
805        let right_index = indices
806            .get(&right.location.rule)
807            .copied()
808            .unwrap_or(usize::MAX);
809
810        left_span
811            .cmp(&right_span)
812            .then_with(|| left_index.cmp(&right_index))
813            .then_with(|| left.location.rule.cmp(&right.location.rule))
814            .then_with(|| severity_rank(left.severity).cmp(&severity_rank(right.severity)))
815            .then_with(|| kind_rank(&left.kind).cmp(&kind_rank(&right.kind)))
816            .then_with(|| left.message.cmp(&right.message))
817    });
818}
819
820const fn severity_rank(severity: Severity) -> usize {
821    match severity {
822        Severity::Error => 0,
823        Severity::Warning => 1,
824    }
825}
826
827const fn kind_rank(kind: &DiagnosticKind) -> usize {
828    match kind {
829        DiagnosticKind::DuplicateRule { .. } => 0,
830        DiagnosticKind::UndefinedNonTerminal { .. } => 1,
831        DiagnosticKind::LeftRecursion { .. } => 2,
832        DiagnosticKind::UnreachableRule { .. } => 3,
833        DiagnosticKind::NullableRepetition { .. } => 4,
834        DiagnosticKind::UnusedCapture { .. } => 5,
835    }
836}
837
838fn rule_location(rule: impl Into<String>) -> RuleSpan {
839    RuleSpan {
840        rule: rule.into(),
841        span: None,
842    }
843}