Skip to main content

merman_core/
editor.rs

1use std::collections::BTreeSet;
2
3use serde::{Deserialize, Serialize};
4
5#[cfg(any(feature = "diagram-packet", feature = "diagram-radar"))]
6use crate::error::Error;
7pub use crate::generated::editor_rename_policy::EditorRenamePolicy;
8use crate::{OperationControl, OperationControlResult};
9
10#[cfg(any(
11    feature = "diagram-class",
12    feature = "diagram-er",
13    feature = "diagram-flowchart",
14    feature = "diagram-swimlane",
15    feature = "diagram-sequence",
16    feature = "diagram-state"
17))]
18use crate::error::{ParseDiagnostic, ParseDiagnosticSpanKind, ParseErrorSourceSpan};
19
20/// Public shape names supported by the compiled Flowchart grammar.
21///
22/// Editor consumers use the core feature union, including when another dependency enables the
23/// shared Flowchart/Swimlane grammar independently of the editor's local feature selection.
24/// Returns an empty iterator when that grammar is not compiled.
25pub fn flowchart_shape_names() -> impl Iterator<Item = &'static str> {
26    #[cfg(any(
27        feature = "diagram-flowchart",
28        feature = "diagram-swimlane",
29        feature = "diagram-agentflow"
30    ))]
31    {
32        crate::diagrams::flowchart::flowchart_public_shape_names()
33    }
34    #[cfg(not(any(
35        feature = "diagram-flowchart",
36        feature = "diagram-swimlane",
37        feature = "diagram-agentflow"
38    )))]
39    {
40        std::iter::empty()
41    }
42}
43
44/// Byte span attached to an editor-visible semantic fact.
45#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
46pub struct SourceSpan {
47    pub start: usize,
48    pub end: usize,
49}
50
51pub(crate) fn line_content_end(source: &str, end: usize) -> usize {
52    let end = end.min(source.len());
53    end.checked_sub(1)
54        .filter(|index| source.as_bytes().get(*index) == Some(&b'\r'))
55        .unwrap_or(end)
56}
57
58#[allow(
59    dead_code,
60    reason = "Shared parser facilities have different consumers in each family selection."
61)]
62pub(crate) fn has_ascii_separator(source: &str, start: usize, end: usize) -> bool {
63    source.get(start..end).is_some_and(|slice| {
64        !slice.is_empty() && slice.as_bytes().iter().all(u8::is_ascii_whitespace)
65    })
66}
67
68#[allow(
69    dead_code,
70    reason = "Shared parser facilities have different consumers in each family selection."
71)]
72pub(crate) fn trailing_ascii_whitespace_slot(
73    source: &str,
74    start: usize,
75    end: usize,
76) -> Option<SourceSpan> {
77    let end = line_content_end(source, end);
78    let slice = source.get(start..end)?;
79    (!slice.is_empty() && slice.as_bytes().last().is_some_and(u8::is_ascii_whitespace))
80        .then_some(SourceSpan::new(end, end))
81}
82
83#[allow(
84    dead_code,
85    reason = "Shared parser facilities have different consumers in each family selection."
86)]
87pub(crate) fn source_value_span(source: &str, span: SourceSpan, value: &str) -> Option<SourceSpan> {
88    let slice = source.get(span.start..span.end)?;
89    let relative_start = slice.find(value)?;
90    Some(SourceSpan::new(
91        span.start + relative_start,
92        span.start + relative_start + value.len(),
93    ))
94}
95
96impl SourceSpan {
97    pub fn new(start: usize, end: usize) -> Self {
98        Self { start, end }
99    }
100}
101
102/// Protocol-independent symbol classification for editor-facing consumers.
103#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)]
104pub enum EditorSemanticKind {
105    Class,
106    Event,
107    Function,
108    Module,
109    Namespace,
110    Object,
111    Package,
112    Property,
113    String,
114    Struct,
115    Variable,
116}
117
118/// Typed family semantics consumed by editor projections.
119///
120/// Parser families own this classification. Downstream editor layers must not recover it from a
121/// diagram type name or other display string.
122#[derive(Debug, Clone, Copy, PartialEq, Eq)]
123pub struct EditorFamilySemantics {
124    outline_kind: EditorSemanticKind,
125}
126
127impl Default for EditorFamilySemantics {
128    fn default() -> Self {
129        Self {
130            outline_kind: EditorSemanticKind::Variable,
131        }
132    }
133}
134
135impl EditorFamilySemantics {
136    pub(crate) const fn new(outline_kind: EditorSemanticKind) -> Self {
137        Self { outline_kind }
138    }
139
140    pub const fn outline_kind(self) -> EditorSemanticKind {
141        self.outline_kind
142    }
143}
144
145/// How downstream editor indexes should project a parser-produced symbol.
146#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
147pub enum EditorSemanticRole {
148    /// Addressable diagram entity: appears in completion, navigation, and outline surfaces.
149    #[default]
150    Entity,
151    /// A parser-declared CSS/class definition. It participates in class completion and outline
152    /// projection, but is not a diagram-node entity or a reference/rename target.
153    ClassDefinition,
154    /// A source occurrence that resolves to an addressable entity. References participate in
155    /// navigation and rename, but never become completion or outline declarations themselves.
156    Reference,
157    /// Structural symbol that belongs in outline/hover, but is not a graph-node completion item.
158    Outline,
159    /// Span-rich parser payload for lint or future semantic consumers; not projected into LSP
160    /// outline/completion/navigation by the migration index.
161    Payload,
162}
163
164impl EditorRenamePolicy {
165    pub fn is_renameable(self) -> bool {
166        !matches!(self, Self::None)
167    }
168
169    pub fn accepts(self, candidate: &str) -> bool {
170        match self {
171            Self::None => false,
172            Self::Identifier => {
173                !candidate.is_empty()
174                    && candidate
175                        .chars()
176                        .all(|ch| ch.is_alphanumeric() || matches!(ch, '_' | '-'))
177            }
178            Self::QualifiedIdentifier => {
179                !candidate.is_empty() && candidate.split('.').all(is_ascii_identifier)
180            }
181            Self::EventModelingId => is_ascii_identifier(candidate),
182            Self::EventModelingFrameId => {
183                (1..=3).contains(&candidate.len())
184                    && candidate.bytes().all(|byte| byte.is_ascii_digit())
185            }
186            #[cfg(not(any(
187                feature = "diagram-flowchart",
188                feature = "diagram-swimlane",
189                feature = "diagram-agentflow"
190            )))]
191            Self::FlowchartNodeId => false,
192            #[cfg(any(
193                feature = "diagram-flowchart",
194                feature = "diagram-swimlane",
195                feature = "diagram-agentflow"
196            ))]
197            Self::FlowchartNodeId => crate::diagrams::flowchart::is_valid_editor_node_id(candidate),
198            #[cfg(not(feature = "diagram-git-graph"))]
199            Self::GitGraphReference => false,
200            #[cfg(feature = "diagram-git-graph")]
201            Self::GitGraphReference => {
202                crate::diagrams::git_graph::is_valid_editor_reference(candidate)
203            }
204            #[cfg(not(feature = "diagram-architecture"))]
205            Self::ArchitectureIdentifier => false,
206            #[cfg(feature = "diagram-architecture")]
207            Self::ArchitectureIdentifier => {
208                crate::diagrams::architecture::is_valid_editor_identifier(candidate)
209            }
210            #[cfg(not(feature = "diagram-railroad"))]
211            Self::RailroadIrRule => false,
212            #[cfg(feature = "diagram-railroad")]
213            Self::RailroadIrRule => {
214                crate::diagrams::railroad::is_valid_editor_ir_rule_identifier(candidate)
215            }
216            #[cfg(not(feature = "diagram-railroad"))]
217            Self::RailroadEbnfRule => false,
218            #[cfg(feature = "diagram-railroad")]
219            Self::RailroadEbnfRule => {
220                crate::diagrams::railroad::is_valid_editor_ebnf_rule_identifier(candidate)
221            }
222            #[cfg(not(feature = "diagram-railroad"))]
223            Self::RailroadPegRule => false,
224            #[cfg(feature = "diagram-railroad")]
225            Self::RailroadPegRule => {
226                crate::diagrams::railroad::is_valid_editor_peg_rule_identifier(candidate)
227            }
228            #[cfg(not(feature = "diagram-railroad"))]
229            Self::RailroadAbnfRule => false,
230            #[cfg(feature = "diagram-railroad")]
231            Self::RailroadAbnfRule => {
232                crate::diagrams::railroad::is_valid_editor_abnf_rule_identifier(candidate)
233            }
234            #[cfg(not(feature = "diagram-agentflow"))]
235            Self::AgentflowNodeId => false,
236            #[cfg(feature = "diagram-agentflow")]
237            Self::AgentflowNodeId => crate::diagrams::agentflow::is_valid_editor_node_id(candidate),
238            #[cfg(not(feature = "diagram-usecase"))]
239            Self::UsecaseIdentifier => false,
240            #[cfg(feature = "diagram-usecase")]
241            Self::UsecaseIdentifier => {
242                crate::diagrams::usecase::is_valid_editor_identifier(candidate)
243            }
244        }
245    }
246}
247
248fn is_ascii_identifier(value: &str) -> bool {
249    let mut bytes = value.bytes();
250    bytes
251        .next()
252        .is_some_and(|byte| byte == b'_' || byte.is_ascii_alphabetic())
253        && bytes.all(|byte| byte == b'_' || byte.is_ascii_alphanumeric())
254}
255
256impl EditorSemanticRole {
257    pub fn contributes_completion(self) -> bool {
258        matches!(self, Self::Entity | Self::ClassDefinition)
259    }
260
261    pub fn contributes_references(self) -> bool {
262        matches!(self, Self::Entity | Self::Reference)
263    }
264
265    pub fn contributes_outline(self) -> bool {
266        matches!(self, Self::Entity | Self::ClassDefinition | Self::Outline)
267    }
268
269    pub const fn is_class_definition(self) -> bool {
270        matches!(self, Self::ClassDefinition)
271    }
272}
273
274/// A parser-produced symbol occurrence.
275#[derive(Debug, Clone, PartialEq, Eq)]
276#[non_exhaustive]
277pub struct EditorSemanticSymbol {
278    pub name: String,
279    pub detail: Option<String>,
280    pub kind: EditorSemanticKind,
281    pub role: EditorSemanticRole,
282    pub rename_policy: EditorRenamePolicy,
283    pub span: SourceSpan,
284    pub selection: SourceSpan,
285}
286
287impl EditorSemanticSymbol {
288    pub fn new(
289        name: impl Into<String>,
290        detail: Option<String>,
291        kind: EditorSemanticKind,
292        span: SourceSpan,
293        selection: SourceSpan,
294    ) -> Self {
295        Self::with_role(
296            name,
297            detail,
298            kind,
299            EditorSemanticRole::Entity,
300            span,
301            selection,
302        )
303    }
304
305    pub fn outline(
306        name: impl Into<String>,
307        detail: Option<String>,
308        kind: EditorSemanticKind,
309        span: SourceSpan,
310        selection: SourceSpan,
311    ) -> Self {
312        Self::with_role(
313            name,
314            detail,
315            kind,
316            EditorSemanticRole::Outline,
317            span,
318            selection,
319        )
320    }
321
322    pub fn class_definition(
323        name: impl Into<String>,
324        detail: Option<String>,
325        kind: EditorSemanticKind,
326        span: SourceSpan,
327        selection: SourceSpan,
328    ) -> Self {
329        Self::with_role(
330            name,
331            detail,
332            kind,
333            EditorSemanticRole::ClassDefinition,
334            span,
335            selection,
336        )
337    }
338
339    pub fn reference(
340        name: impl Into<String>,
341        detail: Option<String>,
342        kind: EditorSemanticKind,
343        span: SourceSpan,
344        selection: SourceSpan,
345    ) -> Self {
346        Self::with_role(
347            name,
348            detail,
349            kind,
350            EditorSemanticRole::Reference,
351            span,
352            selection,
353        )
354    }
355
356    pub fn payload(
357        name: impl Into<String>,
358        detail: Option<String>,
359        kind: EditorSemanticKind,
360        span: SourceSpan,
361        selection: SourceSpan,
362    ) -> Self {
363        Self::with_role(
364            name,
365            detail,
366            kind,
367            EditorSemanticRole::Payload,
368            span,
369            selection,
370        )
371    }
372
373    pub fn with_role(
374        name: impl Into<String>,
375        detail: Option<String>,
376        kind: EditorSemanticKind,
377        role: EditorSemanticRole,
378        span: SourceSpan,
379        selection: SourceSpan,
380    ) -> Self {
381        let rename_policy = if matches!(
382            role,
383            EditorSemanticRole::Entity | EditorSemanticRole::Reference
384        ) {
385            EditorRenamePolicy::Identifier
386        } else {
387            EditorRenamePolicy::None
388        };
389        Self {
390            name: name.into(),
391            detail,
392            kind,
393            role,
394            rename_policy,
395            span,
396            selection,
397        }
398    }
399
400    pub fn with_rename_policy(mut self, rename_policy: EditorRenamePolicy) -> Self {
401        self.rename_policy = rename_policy;
402        self
403    }
404}
405
406/// Parser-backed diagnostic emitted while producing editor-visible semantic facts.
407#[derive(Debug, Clone, PartialEq, Eq)]
408pub struct EditorSemanticDiagnostic {
409    pub message: String,
410    pub span: Option<SourceSpan>,
411    pub kind: EditorSemanticDiagnosticKind,
412}
413
414#[derive(Debug, Clone, Copy, PartialEq, Eq)]
415pub enum EditorSemanticDiagnosticKind {
416    ParserRecovery,
417    ParserWarning,
418}
419
420impl EditorSemanticDiagnostic {
421    pub fn new(message: impl Into<String>, span: Option<SourceSpan>) -> Self {
422        Self {
423            message: message.into(),
424            span,
425            kind: EditorSemanticDiagnosticKind::ParserWarning,
426        }
427    }
428
429    pub fn parser_recovery(message: impl Into<String>, span: Option<SourceSpan>) -> Self {
430        Self {
431            message: message.into(),
432            span,
433            kind: EditorSemanticDiagnosticKind::ParserRecovery,
434        }
435    }
436}
437
438/// Parser-known syntax category that is expected at a source span.
439#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)]
440pub enum EditorExpectedSyntaxKind {
441    Directive,
442    Frontmatter,
443    IdList,
444    NodeIdentifier,
445    ClassName,
446    FlowchartOperator,
447    ShapeValue,
448    ShapeTrigger,
449    FlowchartDirectionValue,
450    CardinalDirectionValue,
451    BlockDirectionValue,
452    StyleValue,
453    InteractionAction,
454    Payload,
455}
456
457/// Parser-produced cursor context hint for completion and other editor features.
458#[derive(Debug, Clone, Copy, PartialEq, Eq)]
459pub struct EditorExpectedSyntax {
460    pub kind: EditorExpectedSyntaxKind,
461    pub span: SourceSpan,
462}
463
464impl EditorExpectedSyntax {
465    pub fn new(kind: EditorExpectedSyntaxKind, span: SourceSpan) -> Self {
466        Self { kind, span }
467    }
468}
469
470/// Whether editor-facing facts came from a complete family parse or a recoverable partial parse.
471#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
472pub enum EditorSemanticCompleteness {
473    #[default]
474    Complete,
475    Recovered,
476}
477
478/// Parser-produced facts used by lint, completion, and LSP without exposing a public AST.
479#[derive(Debug, Clone, Default, PartialEq, Eq)]
480#[non_exhaustive]
481pub struct EditorSemanticFacts {
482    pub completeness: EditorSemanticCompleteness,
483    pub family_semantics: EditorFamilySemantics,
484    pub symbols: Vec<EditorSemanticSymbol>,
485    pub directive_prefixes: Vec<String>,
486    pub diagnostics: Vec<EditorSemanticDiagnostic>,
487    pub expected_syntax: Vec<EditorExpectedSyntax>,
488}
489
490impl EditorSemanticFacts {
491    pub fn new() -> Self {
492        Self::default()
493    }
494
495    pub fn push_symbol(&mut self, symbol: EditorSemanticSymbol) {
496        self.symbols.push(symbol);
497    }
498
499    pub fn mark_recovered(&mut self) {
500        self.completeness = EditorSemanticCompleteness::Recovered;
501    }
502
503    pub fn mark_recovered_with_diagnostic(
504        &mut self,
505        message: impl Into<String>,
506        span: Option<SourceSpan>,
507    ) {
508        self.mark_recovered();
509        self.push_diagnostic(message, span);
510    }
511
512    pub fn mark_recovered_from_parse_error(
513        &mut self,
514        message: impl Into<String>,
515        span: Option<SourceSpan>,
516    ) {
517        self.mark_recovered();
518        self.diagnostics
519            .push(EditorSemanticDiagnostic::parser_recovery(message, span));
520    }
521
522    pub fn push_diagnostic(&mut self, message: impl Into<String>, span: Option<SourceSpan>) {
523        self.diagnostics
524            .push(EditorSemanticDiagnostic::new(message, span));
525    }
526
527    pub fn push_directive_prefix(&mut self, prefix: impl Into<String>) {
528        let prefix = prefix.into();
529        if !self.directive_prefixes.contains(&prefix) {
530            self.directive_prefixes.push(prefix);
531        }
532    }
533
534    pub fn push_expected_syntax(&mut self, expected: EditorExpectedSyntax) {
535        self.expected_syntax.push(expected);
536    }
537
538    pub(crate) fn finalize_expected_syntax_controlled(
539        &mut self,
540        control: &OperationControl,
541    ) -> OperationControlResult<()> {
542        let mut seen = BTreeSet::new();
543        let mut unique = Vec::with_capacity(self.expected_syntax.len());
544        for (index, expected) in std::mem::take(&mut self.expected_syntax)
545            .into_iter()
546            .enumerate()
547        {
548            if index.is_multiple_of(128) {
549                control.checkpoint()?;
550            }
551            if seen.insert((expected.kind, expected.span.start, expected.span.end)) {
552                unique.push(expected);
553            }
554        }
555        self.expected_syntax = unique;
556        control.checkpoint()
557    }
558}
559
560#[allow(
561    dead_code,
562    reason = "Shared parser facilities have different consumers in each family selection."
563)]
564pub(crate) fn editor_keyword_value_span(
565    source: &str,
566    statement_start: usize,
567    statement_end: usize,
568    keyword: &str,
569) -> Option<SourceSpan> {
570    let raw = source.get(statement_start..statement_end)?;
571    let trimmed = raw.trim_start();
572    let leading = raw.len().saturating_sub(trimmed.len());
573    let keyword_source = trimmed.get(..keyword.len())?;
574    if !keyword_source.eq_ignore_ascii_case(keyword) {
575        return None;
576    }
577    let after_keyword = trimmed.get(keyword.len()..)?;
578    if after_keyword
579        .chars()
580        .next()
581        .is_some_and(|ch| !ch.is_whitespace())
582    {
583        return None;
584    }
585    let whitespace = after_keyword
586        .chars()
587        .take_while(|ch| ch.is_whitespace())
588        .map(char::len_utf8)
589        .sum::<usize>();
590    let value_start = statement_start + leading + keyword.len() + whitespace;
591    let value_len = after_keyword[whitespace..]
592        .chars()
593        .take_while(|ch| !ch.is_whitespace())
594        .map(char::len_utf8)
595        .sum::<usize>();
596    Some(SourceSpan::new(value_start, value_start + value_len))
597}
598
599#[cfg(any(feature = "diagram-packet", feature = "diagram-radar"))]
600pub(crate) fn editor_recovery_fallback_span(source: &str) -> SourceSpan {
601    let mut line_start = 0;
602    for segment in source.split_inclusive('\n') {
603        let line = segment.strip_suffix('\n').unwrap_or(segment);
604        let line = line.strip_suffix('\r').unwrap_or(line);
605        let trimmed = line.trim();
606        if !trimmed.is_empty() {
607            let start = line_start + line.find(trimmed).unwrap_or_default();
608            return SourceSpan::new(start, start + trimmed.len());
609        }
610        line_start += segment.len();
611    }
612    SourceSpan::new(source.len(), source.len())
613}
614
615#[cfg(any(feature = "diagram-packet", feature = "diagram-radar"))]
616pub(crate) fn ensure_editor_recovery_from_error(
617    mut facts: EditorSemanticFacts,
618    error: &Error,
619    fallback_span: SourceSpan,
620) -> EditorSemanticFacts {
621    let (message, span) = match error {
622        Error::DiagramParse { diagnostic, .. } => (
623            diagnostic.message().to_string(),
624            diagnostic.span().unwrap_or(fallback_span),
625        ),
626        other => (other.to_string(), fallback_span),
627    };
628    let already_reported = facts.diagnostics.iter().any(|diagnostic| {
629        diagnostic.kind == EditorSemanticDiagnosticKind::ParserRecovery
630            && diagnostic.message == message
631            && diagnostic.span == Some(span)
632    });
633    if already_reported {
634        facts.mark_recovered();
635        return facts;
636    }
637
638    facts.mark_recovered_from_parse_error(message, Some(span));
639    facts
640}
641
642#[cfg(any(
643    feature = "diagram-class",
644    feature = "diagram-er",
645    feature = "diagram-flowchart",
646    feature = "diagram-swimlane",
647    feature = "diagram-sequence",
648    feature = "diagram-state"
649))]
650pub(crate) fn lalrpop_recovery_span<T, E>(
651    error: &lalrpop_util::ParseError<usize, T, E>,
652    fallback_offset: usize,
653) -> SourceSpan {
654    match error {
655        lalrpop_util::ParseError::InvalidToken { location } => {
656            SourceSpan::new(*location, *location)
657        }
658        lalrpop_util::ParseError::UnrecognizedEof { location, .. } => {
659            SourceSpan::new(*location, *location)
660        }
661        lalrpop_util::ParseError::UnrecognizedToken { token, .. }
662        | lalrpop_util::ParseError::ExtraToken { token } => SourceSpan::new(token.0, token.2),
663        lalrpop_util::ParseError::User { .. } => SourceSpan::new(fallback_offset, fallback_offset),
664    }
665}
666
667#[cfg(any(
668    feature = "diagram-class",
669    feature = "diagram-er",
670    feature = "diagram-flowchart",
671    feature = "diagram-swimlane",
672    feature = "diagram-sequence",
673    feature = "diagram-state"
674))]
675pub(crate) fn lalrpop_parse_diagnostic<T, E>(
676    error: &lalrpop_util::ParseError<usize, T, E>,
677    fallback_offset: usize,
678) -> ParseDiagnostic
679where
680    T: std::fmt::Debug,
681    E: std::fmt::Display + ParseErrorSourceSpan,
682{
683    let message = format_lalrpop_parse_error(error);
684    match error {
685        lalrpop_util::ParseError::InvalidToken { location }
686        | lalrpop_util::ParseError::UnrecognizedEof { location, .. } => {
687            ParseDiagnostic::new(message).with_span(
688                SourceSpan::new(*location, *location),
689                ParseDiagnosticSpanKind::InsertionPoint,
690            )
691        }
692        lalrpop_util::ParseError::UnrecognizedToken { token, .. }
693        | lalrpop_util::ParseError::ExtraToken { token } => ParseDiagnostic::new(message)
694            .with_span(
695                SourceSpan::new(token.0, token.2),
696                ParseDiagnosticSpanKind::Exact,
697            ),
698        lalrpop_util::ParseError::User { error } => {
699            if let Some(span) = error.source_span() {
700                ParseDiagnostic::new(message).with_span(span, ParseDiagnosticSpanKind::Exact)
701            } else {
702                ParseDiagnostic::new(message).with_span(
703                    SourceSpan::new(fallback_offset, fallback_offset),
704                    ParseDiagnosticSpanKind::Fallback,
705                )
706            }
707        }
708    }
709}
710
711#[cfg(any(
712    feature = "diagram-class",
713    feature = "diagram-er",
714    feature = "diagram-flowchart",
715    feature = "diagram-swimlane",
716    feature = "diagram-sequence",
717    feature = "diagram-state"
718))]
719pub(crate) fn format_lalrpop_parse_error<T, E>(
720    error: &lalrpop_util::ParseError<usize, T, E>,
721) -> String
722where
723    T: std::fmt::Debug,
724    E: std::fmt::Display,
725{
726    match error {
727        lalrpop_util::ParseError::InvalidToken { .. } => "unexpected token".to_string(),
728        lalrpop_util::ParseError::UnrecognizedEof { expected, .. } => {
729            let expected = format_expected_tokens(expected);
730            if expected.is_empty() {
731                "unexpected end of input".to_string()
732            } else {
733                format!("unexpected end of input; expected {expected}")
734            }
735        }
736        lalrpop_util::ParseError::UnrecognizedToken { token, expected } => {
737            let expected = format_expected_tokens(expected);
738            let found = format_found_token(&token.1);
739            if expected.is_empty() {
740                format!("unexpected {found}")
741            } else {
742                format!("unexpected {found}; expected {expected}")
743            }
744        }
745        lalrpop_util::ParseError::ExtraToken { token } => {
746            format!("unexpected extra {}", format_found_token(&token.1))
747        }
748        lalrpop_util::ParseError::User { error } => error.to_string(),
749    }
750}
751
752#[cfg(any(
753    feature = "diagram-class",
754    feature = "diagram-er",
755    feature = "diagram-flowchart",
756    feature = "diagram-swimlane",
757    feature = "diagram-sequence",
758    feature = "diagram-state"
759))]
760fn format_expected_tokens(expected: &[String]) -> String {
761    expected
762        .iter()
763        .map(|token| humanize_expected_token(token))
764        .collect::<Vec<_>>()
765        .join(", ")
766}
767
768#[cfg(any(
769    feature = "diagram-class",
770    feature = "diagram-er",
771    feature = "diagram-flowchart",
772    feature = "diagram-swimlane",
773    feature = "diagram-sequence",
774    feature = "diagram-state"
775))]
776fn humanize_expected_token(token: &str) -> String {
777    match token {
778        "Id" => "node identifier".to_string(),
779        "EdgeLabel" => "edge label".to_string(),
780        "Direction" => "diagram direction".to_string(),
781        "AlphaNumToken" => "identifier".to_string(),
782        "Text" | "NoteText" | "Descr" | "RestOfLine" => "text".to_string(),
783        "StringLit" => "string literal".to_string(),
784        other => humanize_token_name(other),
785    }
786}
787
788#[cfg(any(
789    feature = "diagram-class",
790    feature = "diagram-er",
791    feature = "diagram-flowchart",
792    feature = "diagram-swimlane",
793    feature = "diagram-sequence",
794    feature = "diagram-state"
795))]
796fn format_found_token<T>(token: &T) -> String
797where
798    T: std::fmt::Debug,
799{
800    let debug = format!("{token:?}");
801    let variant = debug
802        .split_once('(')
803        .map(|(name, _)| name)
804        .unwrap_or(debug.as_str());
805
806    match variant {
807        "Sep" | "Newline" => "statement separator".to_string(),
808        "StyleSep" => "style separator".to_string(),
809        "Amp" => "`&`".to_string(),
810        "Comma" => "`,`".to_string(),
811        "Plus" => "`+`".to_string(),
812        "Minus" => "`-`".to_string(),
813        "Arrow" => "edge operator".to_string(),
814        "SignalType" => "message operator".to_string(),
815        "Id" | "Actor" | "StyledId" => "identifier".to_string(),
816        "Direction" | "DirectionStmt" => "diagram direction".to_string(),
817        "EdgeLabel" | "Text" | "NoteText" | "Descr" | "RestOfLine" => "text".to_string(),
818        "NodeLabel" | "StateDescr" | "CompositState" => "node label".to_string(),
819        "StringLit" => "string literal".to_string(),
820        "Num" => "number".to_string(),
821        other => humanize_token_name(other),
822    }
823}
824
825#[cfg(any(
826    feature = "diagram-class",
827    feature = "diagram-er",
828    feature = "diagram-flowchart",
829    feature = "diagram-swimlane",
830    feature = "diagram-sequence",
831    feature = "diagram-state"
832))]
833fn humanize_token_name(token: &str) -> String {
834    let token = token.strip_prefix("Kw").unwrap_or(token);
835    let mut out = String::new();
836    let mut previous_is_lowercase = false;
837
838    for ch in token.chars() {
839        if ch == '_' || ch == '-' {
840            if !out.ends_with(' ') {
841                out.push(' ');
842            }
843            previous_is_lowercase = false;
844            continue;
845        }
846
847        if ch.is_ascii_uppercase() && previous_is_lowercase && !out.ends_with(' ') {
848            out.push(' ');
849        }
850
851        if ch.is_ascii_digit() && !out.ends_with(' ') && !out.is_empty() {
852            out.push(' ');
853        }
854
855        out.push(ch.to_ascii_lowercase());
856        previous_is_lowercase = ch.is_ascii_lowercase();
857    }
858
859    out.trim().to_string()
860}
861
862#[cfg(test)]
863mod tests {
864    #[cfg(any(
865        feature = "diagram-class",
866        feature = "diagram-er",
867        feature = "diagram-flowchart",
868        feature = "diagram-swimlane",
869        feature = "diagram-sequence",
870        feature = "diagram-state"
871    ))]
872    use super::lalrpop_parse_diagnostic;
873    use super::{EditorRenamePolicy, EditorSemanticKind, EditorSemanticRole, EditorSemanticSymbol};
874    #[cfg(any(
875        feature = "diagram-class",
876        feature = "diagram-er",
877        feature = "diagram-flowchart",
878        feature = "diagram-swimlane",
879        feature = "diagram-sequence",
880        feature = "diagram-state"
881    ))]
882    use crate::ParseDiagnosticSpanKind;
883
884    #[test]
885    #[cfg(all(
886        feature = "diagram-flowchart",
887        feature = "diagram-git-graph",
888        feature = "diagram-architecture",
889        feature = "diagram-railroad"
890    ))]
891    fn rename_policies_follow_family_identifier_grammars() {
892        assert!(EditorRenamePolicy::FlowchartNodeId.accepts("foo.bar"));
893        assert!(EditorRenamePolicy::FlowchartNodeId.accepts("foo-bar"));
894        assert!(!EditorRenamePolicy::FlowchartNodeId.accepts("foo--bar"));
895        assert!(!EditorRenamePolicy::FlowchartNodeId.accepts("foo.->bar"));
896        assert!(!EditorRenamePolicy::FlowchartNodeId.accepts("end.foo"));
897        assert!(!EditorRenamePolicy::FlowchartNodeId.accepts("graph.foo"));
898        assert!(!EditorRenamePolicy::FlowchartNodeId.accepts("subgraph.foo"));
899
900        assert!(EditorRenamePolicy::GitGraphReference.accepts("release/v1.2"));
901        assert!(EditorRenamePolicy::GitGraphReference.accepts("release-"));
902        assert!(EditorRenamePolicy::GitGraphReference.accepts("_private"));
903        assert!(!EditorRenamePolicy::GitGraphReference.accepts("release/"));
904        assert!(!EditorRenamePolicy::GitGraphReference.accepts("release branch"));
905
906        assert!(EditorRenamePolicy::ArchitectureIdentifier.accepts("rowspan"));
907        assert!(EditorRenamePolicy::ArchitectureIdentifier.accepts("service-2"));
908        assert!(!EditorRenamePolicy::ArchitectureIdentifier.accepts("service-"));
909        assert!(!EditorRenamePolicy::ArchitectureIdentifier.accepts("align"));
910
911        assert!(EditorRenamePolicy::RailroadIrRule.accepts("rule_name"));
912        assert!(!EditorRenamePolicy::RailroadIrRule.accepts("1rule"));
913        assert!(!EditorRenamePolicy::RailroadIrRule.accepts("terminal"));
914        assert!(EditorRenamePolicy::RailroadEbnfRule.accepts("terminal"));
915        assert!(EditorRenamePolicy::RailroadPegRule.accepts("terminal"));
916        assert!(EditorRenamePolicy::RailroadAbnfRule.accepts("rule-name"));
917        assert!(!EditorRenamePolicy::RailroadAbnfRule.accepts("rule_name"));
918    }
919
920    #[test]
921    #[cfg(feature = "diagram-agentflow")]
922    fn agentflow_rename_policy_uses_its_own_keyword_boundaries() {
923        for candidate in ["flow", "connector", "global", "flow-guide", "global注文"] {
924            assert!(
925                !EditorRenamePolicy::AgentflowNodeId.accepts(candidate),
926                "{candidate}"
927            );
928        }
929        for candidate in ["flow_user", "flowUser", "Connector", "friend-end"] {
930            assert!(
931                EditorRenamePolicy::AgentflowNodeId.accepts(candidate),
932                "{candidate}"
933            );
934        }
935    }
936
937    #[test]
938    #[cfg(feature = "diagram-usecase")]
939    fn usecase_rename_policy_rejects_statement_only_keywords() {
940        for candidate in [
941            "package",
942            "PACKAGE",
943            "rectangle",
944            "allowmixing",
945            "newpage",
946            "skinparam",
947        ] {
948            assert!(
949                !EditorRenamePolicy::UsecaseIdentifier.accepts(candidate),
950                "{candidate}"
951            );
952        }
953        for candidate in ["package_user", "packageUser", "1User"] {
954            assert!(
955                EditorRenamePolicy::UsecaseIdentifier.accepts(candidate),
956                "{candidate}"
957            );
958        }
959    }
960
961    #[test]
962    fn generated_rename_policy_ids_match_serde_exactly() {
963        assert_eq!(
964            EditorRenamePolicy::ALL.map(EditorRenamePolicy::as_str),
965            EditorRenamePolicy::IDS
966        );
967        assert_eq!(
968            EditorRenamePolicy::default(),
969            EditorRenamePolicy::Identifier
970        );
971
972        for (policy, id) in EditorRenamePolicy::ALL
973            .into_iter()
974            .zip(EditorRenamePolicy::IDS)
975        {
976            assert_eq!(serde_json::to_string(&policy).unwrap(), format!("\"{id}\""));
977            assert_eq!(
978                serde_json::from_str::<EditorRenamePolicy>(&format!("\"{id}\""))
979                    .expect("generated rename policy id must deserialize"),
980                policy
981            );
982        }
983        assert!(serde_json::from_str::<EditorRenamePolicy>("\"unknown\"").is_err());
984    }
985
986    #[test]
987    fn class_definition_role_is_typed_completion_without_reference_identity() {
988        let role = EditorSemanticRole::ClassDefinition;
989        assert!(role.contributes_completion());
990        assert!(!role.contributes_references());
991        assert!(role.contributes_outline());
992        assert!(role.is_class_definition());
993
994        let span = crate::SourceSpan::new(0, 3);
995        let symbol = EditorSemanticSymbol::class_definition(
996            "hot",
997            Some("display text may change".to_string()),
998            EditorSemanticKind::Class,
999            span,
1000            span,
1001        );
1002        assert_eq!(symbol.role, role);
1003        assert_eq!(symbol.rename_policy, EditorRenamePolicy::None);
1004    }
1005
1006    #[test]
1007    fn reference_role_is_navigation_only() {
1008        let role = EditorSemanticRole::Reference;
1009        assert!(!role.contributes_completion());
1010        assert!(role.contributes_references());
1011        assert!(!role.contributes_outline());
1012        assert!(!role.is_class_definition());
1013
1014        let span = crate::SourceSpan::new(0, 3);
1015        let symbol = EditorSemanticSymbol::reference(
1016            "ref",
1017            Some("display text may change".to_string()),
1018            EditorSemanticKind::Class,
1019            span,
1020            span,
1021        );
1022        assert_eq!(symbol.role, role);
1023        assert_eq!(symbol.rename_policy, EditorRenamePolicy::Identifier);
1024    }
1025
1026    #[test]
1027    #[cfg(any(
1028        feature = "diagram-class",
1029        feature = "diagram-er",
1030        feature = "diagram-flowchart",
1031        feature = "diagram-swimlane",
1032        feature = "diagram-sequence",
1033        feature = "diagram-state"
1034    ))]
1035    fn lalrpop_parse_diagnostic_preserves_unrecognized_token_span() {
1036        let error = lalrpop_util::ParseError::<usize, &str, String>::UnrecognizedToken {
1037            token: (3, "bad", 6),
1038            expected: vec!["ID".to_string()],
1039        };
1040
1041        let diagnostic = lalrpop_parse_diagnostic(&error, 10);
1042
1043        let span = diagnostic.span().expect("diagnostic span");
1044        assert_eq!(span.start, 3);
1045        assert_eq!(span.end, 6);
1046        assert_eq!(diagnostic.span_kind(), ParseDiagnosticSpanKind::Exact);
1047        assert!(diagnostic.message().contains("\"bad\""));
1048    }
1049
1050    #[test]
1051    #[cfg(any(
1052        feature = "diagram-class",
1053        feature = "diagram-er",
1054        feature = "diagram-flowchart",
1055        feature = "diagram-swimlane",
1056        feature = "diagram-sequence",
1057        feature = "diagram-state"
1058    ))]
1059    fn lalrpop_parse_diagnostic_preserves_eof_insertion_point() {
1060        let error = lalrpop_util::ParseError::<usize, &str, String>::UnrecognizedEof {
1061            location: 12,
1062            expected: vec!["]".to_string()],
1063        };
1064
1065        let diagnostic = lalrpop_parse_diagnostic(&error, 99);
1066
1067        let span = diagnostic.span().expect("diagnostic span");
1068        assert_eq!(span.start, 12);
1069        assert_eq!(span.end, 12);
1070        assert_eq!(
1071            diagnostic.span_kind(),
1072            ParseDiagnosticSpanKind::InsertionPoint
1073        );
1074        assert!(diagnostic.message().contains("unexpected end of input"));
1075    }
1076
1077    #[test]
1078    #[cfg(any(
1079        feature = "diagram-class",
1080        feature = "diagram-er",
1081        feature = "diagram-flowchart",
1082        feature = "diagram-swimlane",
1083        feature = "diagram-sequence",
1084        feature = "diagram-state"
1085    ))]
1086    fn lalrpop_parse_diagnostic_marks_user_errors_as_fallback() {
1087        let error = lalrpop_util::ParseError::<usize, &str, String>::User {
1088            error: "custom parse failure".to_string(),
1089        };
1090
1091        let diagnostic = lalrpop_parse_diagnostic(&error, 8);
1092
1093        let span = diagnostic.span().expect("diagnostic span");
1094        assert_eq!(span.start, 8);
1095        assert_eq!(span.end, 8);
1096        assert_eq!(diagnostic.span_kind(), ParseDiagnosticSpanKind::Fallback);
1097        assert_eq!(diagnostic.message(), "custom parse failure");
1098    }
1099}