Skip to main content

merman_editor_core/
completion.rs

1use crate::context::CompletionContext;
2use crate::snapshot::{DocumentSnapshot, FenceSnapshot};
3use crate::types::{Position, Range};
4use merman_analysis::FenceTextIndexSource;
5use merman_core::{diagram_header_facts_for_profile, selected_baseline_registry_profile};
6use serde::{Deserialize, Serialize};
7
8const COMMON_TEMPLATE_DETAIL: &str = "diagram template";
9
10pub fn completion_for_snapshot(snapshot: &DocumentSnapshot, position: Position) -> CompletionList {
11    let Some(context) = CompletionContext::from_snapshot(snapshot, position) else {
12        return CompletionList {
13            is_incomplete: false,
14            fact_source: None,
15            items: Vec::new(),
16        };
17    };
18
19    let mut items = Vec::new();
20
21    if context.offer_diagram_headers() {
22        items.extend(diagram_header_items(context.prefix_range()));
23        items.extend(template_items(context.prefix_range()));
24    } else if context.offer_template_items() {
25        items.extend(template_items(context.prefix_range()));
26    }
27
28    if context.offer_operator_items() {
29        items.extend(operator_items(context.operator_range()));
30    }
31
32    if context.offer_frontmatter_items() {
33        items.extend(frontmatter_items(context.frontmatter_text_edit_range()));
34    }
35
36    if context.offer_direction_items() {
37        items.extend(direction_items(&context));
38    }
39
40    if context.offer_directive_items() {
41        items.extend(directive_items(&context));
42    }
43
44    if context.offer_shape_items() {
45        items.extend(shape_items(&context));
46    }
47
48    if context.offer_class_name_items() {
49        items.extend(class_name_items(
50            context.fence(),
51            context.document_uri(),
52            context.class_name_text_edit_range(),
53        ));
54    }
55
56    if context.offer_style_snippet_items() {
57        items.extend(style_snippet_items(context.style_text_edit_range()));
58    }
59
60    if context.offer_interaction_snippet_items() {
61        items.extend(interaction_snippet_items(
62            context.interaction_text_edit_range(),
63        ));
64    }
65
66    if context.offer_node_items() {
67        items.extend(node_items(
68            context.fence(),
69            context.document_uri(),
70            context.node_text_edit_range(),
71        ));
72    }
73
74    CompletionList {
75        is_incomplete: false,
76        fact_source: Some(context.fact_source()),
77        items,
78    }
79}
80
81fn diagram_header_items(range: Option<Range>) -> Vec<CompletionItem> {
82    diagram_header_facts_for_profile(selected_baseline_registry_profile())
83        .iter()
84        .map(|fact| {
85            keyword_completion(
86                fact.label,
87                fact.detail,
88                range,
89                None,
90                CompletionDataKind::DiagramHeader,
91            )
92        })
93        .collect()
94}
95
96fn operator_items(range: Option<Range>) -> Vec<CompletionItem> {
97    vec![
98        keyword_completion(
99            "-->",
100            "edge operator",
101            range,
102            None,
103            CompletionDataKind::Operator,
104        ),
105        keyword_completion(
106            "---",
107            "edge operator",
108            range,
109            None,
110            CompletionDataKind::Operator,
111        ),
112        keyword_completion(
113            "-.->",
114            "edge operator",
115            range,
116            None,
117            CompletionDataKind::Operator,
118        ),
119        keyword_completion(
120            "==>",
121            "edge operator",
122            range,
123            None,
124            CompletionDataKind::Operator,
125        ),
126        snippet_completion(
127            "-->|label|",
128            "labeled edge operator",
129            range,
130            "-->|${1:label}|",
131            CompletionDataKind::Operator,
132        ),
133        keyword_completion(
134            "<|--",
135            "inheritance operator",
136            range,
137            None,
138            CompletionDataKind::Operator,
139        ),
140        keyword_completion(
141            "*--",
142            "composition operator",
143            range,
144            None,
145            CompletionDataKind::Operator,
146        ),
147        keyword_completion(
148            "o--",
149            "aggregation operator",
150            range,
151            None,
152            CompletionDataKind::Operator,
153        ),
154    ]
155}
156
157fn directive_items(context: &CompletionContext<'_>) -> Vec<CompletionItem> {
158    let range = context.prefix_range();
159    let has_directives = context
160        .fence()
161        .text_index
162        .directive_prefixes()
163        .any(|prefix| is_directive_helper_prefix(prefix.as_str()));
164    let directive_label = if has_directives {
165        "directive helper"
166    } else {
167        "comment"
168    };
169    vec![
170        snippet_completion(
171            ":::className",
172            directive_label,
173            range,
174            ":::${1:className}",
175            CompletionDataKind::Directive,
176        ),
177        snippet_completion(
178            "::icon(name)",
179            "node icon directive",
180            range,
181            "::icon(${1:logos:github-icon})",
182            CompletionDataKind::Directive,
183        ),
184        snippet_completion(
185            "%% comment",
186            "comment",
187            range,
188            "%% ${1:comment}",
189            CompletionDataKind::Directive,
190        ),
191    ]
192}
193
194fn is_directive_helper_prefix(prefix: &str) -> bool {
195    matches!(
196        prefix,
197        "classDef"
198            | "class"
199            | "style"
200            | "cssClass"
201            | "linkStyle"
202            | "click"
203            | "link"
204            | "callback"
205            | ":::"
206    )
207}
208
209const FLOW_DIRECTION_VALUES: [(&str, &str); 4] = [
210    ("TB", "top to bottom"),
211    ("BT", "bottom to top"),
212    ("LR", "left to right"),
213    ("RL", "right to left"),
214];
215
216const BLOCK_DIRECTION_VALUES: [(&str, &str); 6] = [
217    ("right", "right"),
218    ("left", "left"),
219    ("up", "up"),
220    ("down", "down"),
221    ("x", "horizontal"),
222    ("y", "vertical"),
223];
224
225fn direction_items(context: &CompletionContext<'_>) -> Vec<CompletionItem> {
226    if let Some(range) = context.direction_value_range() {
227        let values: &[(&str, &str)] = if context.is_block_diagram() {
228            &BLOCK_DIRECTION_VALUES
229        } else {
230            &FLOW_DIRECTION_VALUES
231        };
232
233        return values
234            .iter()
235            .map(|(value, detail)| {
236                keyword_completion(
237                    value,
238                    detail,
239                    Some(range),
240                    None,
241                    CompletionDataKind::Direction,
242                )
243            })
244            .collect();
245    }
246
247    FLOW_DIRECTION_VALUES
248        .iter()
249        .map(|(value, detail)| {
250            keyword_completion(
251                &format!("direction {value}"),
252                detail,
253                context.prefix_range(),
254                None,
255                CompletionDataKind::Direction,
256            )
257        })
258        .collect()
259}
260
261fn shape_items(context: &CompletionContext<'_>) -> Vec<CompletionItem> {
262    merman_core::diagrams::flowchart::flowchart_public_shape_names()
263        .map(|shape| shape_completion(shape, &format!("{shape} shape"), context))
264        .collect()
265}
266
267fn node_items(
268    fence: &FenceSnapshot,
269    document_uri: &str,
270    range: Option<Range>,
271) -> Vec<CompletionItem> {
272    fence
273        .text_index
274        .node_ids()
275        .into_iter()
276        .map(|id| CompletionItem {
277            label: id.clone(),
278            kind: CompletionItemKind::Variable,
279            detail: Some("node identifier".to_string()),
280            data: Some(CompletionResolveData {
281                kind: CompletionDataKind::NodeIdentifier,
282                label: id.clone(),
283            }),
284            insert_text: Some(id.clone()),
285            insert_text_format: CompletionInsertTextFormat::PlainText,
286            text_edit: range.map(|range| CompletionTextEdit {
287                range,
288                new_text: id.clone(),
289            }),
290            label_details: Some(CompletionItemLabelDetails {
291                description: Some(document_uri.to_string()),
292                detail: Some(format!("fence {}", fence.index + 1)),
293            }),
294        })
295        .collect()
296}
297
298fn class_name_items(
299    fence: &FenceSnapshot,
300    document_uri: &str,
301    range: Option<Range>,
302) -> Vec<CompletionItem> {
303    fence
304        .text_index
305        .class_names()
306        .map(|name| CompletionItem {
307            label: name.clone(),
308            kind: CompletionItemKind::Class,
309            detail: Some("class name".to_string()),
310            data: Some(CompletionResolveData {
311                kind: CompletionDataKind::ClassName,
312                label: name.clone(),
313            }),
314            insert_text: Some(name.clone()),
315            insert_text_format: CompletionInsertTextFormat::PlainText,
316            text_edit: range.map(|range| CompletionTextEdit {
317                range,
318                new_text: name.clone(),
319            }),
320            label_details: Some(CompletionItemLabelDetails {
321                description: Some(document_uri.to_string()),
322                detail: Some(format!("fence {}", fence.index + 1)),
323            }),
324        })
325        .collect()
326}
327
328fn style_snippet_items(range: Option<Range>) -> Vec<CompletionItem> {
329    vec![
330        snippet_completion(
331            "fill/stroke style",
332            "style properties",
333            range,
334            "fill:${1:#eef},stroke:${2:#447},stroke-width:${3:1px}",
335            CompletionDataKind::Style,
336        ),
337        snippet_completion(
338            "text style",
339            "style properties",
340            range,
341            "color:${1:#222},font-size:${2:14px},font-weight:${3|normal,bold|}",
342            CompletionDataKind::Style,
343        ),
344        snippet_completion(
345            "dashed stroke style",
346            "style properties",
347            range,
348            "stroke-dasharray:${1:5 5},stroke:${2:#447},stroke-width:${3:2px}",
349            CompletionDataKind::Style,
350        ),
351    ]
352}
353
354fn interaction_snippet_items(range: Option<Range>) -> Vec<CompletionItem> {
355    vec![
356        snippet_completion(
357            "href link action",
358            "interaction action",
359            range,
360            "href \"${1:https://example.com}\" \"${2:Tooltip}\" ${3|_blank,_self|}",
361            CompletionDataKind::Interaction,
362        ),
363        snippet_completion(
364            "callback action",
365            "interaction action",
366            range,
367            "call ${1:callback}(${2:arg})",
368            CompletionDataKind::Interaction,
369        ),
370    ]
371}
372
373fn frontmatter_items(range: Option<Range>) -> Vec<CompletionItem> {
374    vec![
375        snippet_completion(
376            "config:",
377            "frontmatter config",
378            range,
379            "config:\n  ${1:theme}: ${2:default}",
380            CompletionDataKind::Frontmatter,
381        ),
382        snippet_completion(
383            "theme:",
384            "frontmatter config",
385            range,
386            "theme: ${1|default,dark,forest,neutral,base|}",
387            CompletionDataKind::Frontmatter,
388        ),
389        snippet_completion(
390            "themeCSS: |",
391            "frontmatter config",
392            range,
393            "themeCSS: |\n  ${1:.node rect { filter: drop-shadow(1px 1px 1px #999); }}",
394            CompletionDataKind::Frontmatter,
395        ),
396        snippet_completion(
397            "themeVariables:",
398            "frontmatter config",
399            range,
400            "themeVariables:\n  ${1:primaryColor}: ${2:#f4f4f4}",
401            CompletionDataKind::Frontmatter,
402        ),
403    ]
404}
405
406fn keyword_completion(
407    label: &str,
408    detail: &str,
409    range: Option<Range>,
410    replacement: Option<&str>,
411    data_kind: CompletionDataKind,
412) -> CompletionItem {
413    CompletionItem {
414        label: label.to_string(),
415        kind: CompletionItemKind::Keyword,
416        detail: Some(detail.to_string()),
417        data: Some(CompletionResolveData {
418            kind: data_kind,
419            label: label.to_string(),
420        }),
421        insert_text: Some(label.to_string()),
422        insert_text_format: CompletionInsertTextFormat::PlainText,
423        text_edit: range.map(|range| CompletionTextEdit {
424            range,
425            new_text: replacement.unwrap_or(label).to_string(),
426        }),
427        label_details: None,
428    }
429}
430
431fn snippet_completion(
432    label: &str,
433    detail: &str,
434    range: Option<Range>,
435    snippet: &str,
436    data_kind: CompletionDataKind,
437) -> CompletionItem {
438    CompletionItem {
439        label: label.to_string(),
440        kind: CompletionItemKind::Snippet,
441        detail: Some(detail.to_string()),
442        data: Some(CompletionResolveData {
443            kind: data_kind,
444            label: label.to_string(),
445        }),
446        insert_text: Some(snippet.to_string()),
447        insert_text_format: CompletionInsertTextFormat::Snippet,
448        text_edit: range.map(|range| CompletionTextEdit {
449            range,
450            new_text: snippet.to_string(),
451        }),
452        label_details: None,
453    }
454}
455
456fn template_items(range: Option<Range>) -> Vec<CompletionItem> {
457    vec![
458        snippet_completion(
459            "flowchart template",
460            COMMON_TEMPLATE_DETAIL,
461            range,
462            "flowchart ${1|TD,TB,BT,LR,RL|}\n  ${2:A}[${3:Start}] --> ${4:B}[${5:Next}]",
463            CompletionDataKind::Template,
464        ),
465        snippet_completion(
466            "sequence template",
467            COMMON_TEMPLATE_DETAIL,
468            range,
469            "sequenceDiagram\n  participant ${1:A} as ${2:Alice}\n  participant ${3:B} as ${4:Bob}\n  ${1:A}->>${3:B}: ${5:Message}",
470            CompletionDataKind::Template,
471        ),
472        snippet_completion(
473            "icon node template",
474            COMMON_TEMPLATE_DETAIL,
475            range,
476            "${1:A}@{ icon: \"${2:logos:github-icon}\", form: \"${3|square,rounded,circle|}\", label: \"${4:Label}\" }",
477            CompletionDataKind::Template,
478        ),
479        snippet_completion(
480            "accessibility template",
481            COMMON_TEMPLATE_DETAIL,
482            range,
483            "accTitle: ${1:Diagram title}\naccDescr: ${2:Diagram description}",
484            CompletionDataKind::Template,
485        ),
486        snippet_completion(
487            "frontmatter config template",
488            COMMON_TEMPLATE_DETAIL,
489            range,
490            "---\nconfig:\n  theme: ${1|default,dark,forest,neutral,base|}\n---\n${2:flowchart TD}\n  ${3:A} --> ${4:B}",
491            CompletionDataKind::Template,
492        ),
493        snippet_completion(
494            "themeCSS frontmatter template",
495            COMMON_TEMPLATE_DETAIL,
496            range,
497            "---\nconfig:\n  themeCSS: |\n    ${1:.node rect { filter: drop-shadow(1px 1px 1px #999); }}\n---\n${2:flowchart TD}\n  ${3:A} --> ${4:B}",
498            CompletionDataKind::Template,
499        ),
500    ]
501}
502
503fn shape_completion(value: &str, detail: &str, context: &CompletionContext<'_>) -> CompletionItem {
504    let label = format!("@{{ shape: {value} }}");
505    if let Some(edit) = context.shape_value_edit(value) {
506        keyword_completion(
507            &label,
508            detail,
509            Some(edit.range),
510            Some(&edit.replacement),
511            CompletionDataKind::Shape,
512        )
513    } else {
514        keyword_completion(
515            &label,
516            detail,
517            context.shape_trigger_range(),
518            Some(&label),
519            CompletionDataKind::Shape,
520        )
521    }
522}
523
524pub fn completion_documentation(data: &CompletionResolveData) -> String {
525    match data.kind {
526        CompletionDataKind::DiagramHeader => format!(
527            "Starts a Mermaid `{}` diagram. Use it as the first statement in a plain Mermaid file or fenced Mermaid block.",
528            data.label
529        ),
530        CompletionDataKind::Operator => format!(
531            "Inserts the Mermaid `{}` relationship operator between diagram identifiers.",
532            data.label
533        ),
534        CompletionDataKind::Direction => format!(
535            "Sets flow direction with `{}`. Direction statements are valid inside flowchart subgraphs and supported flowchart contexts.",
536            data.label
537        ),
538        CompletionDataKind::Directive => format!(
539            "Inserts `{}` as a Mermaid directive or comment helper for the current fence.",
540            data.label
541        ),
542        CompletionDataKind::Shape => format!(
543            "Inserts Mermaid flowchart shape object syntax for `{}`.",
544            data.label
545        ),
546        CompletionDataKind::ClassName => format!(
547            "Reuses the `{}` class name already defined in the current Mermaid fence.",
548            data.label
549        ),
550        CompletionDataKind::NodeIdentifier => format!(
551            "Reuses the `{}` identifier already present in the current Mermaid fence.",
552            data.label
553        ),
554        CompletionDataKind::Style => {
555            format!("Inserts Mermaid style properties for `{}`.", data.label)
556        }
557        CompletionDataKind::Interaction => format!(
558            "Inserts a Mermaid click/link/callback action for `{}`.",
559            data.label
560        ),
561        CompletionDataKind::Frontmatter => format!(
562            "Inserts Mermaid frontmatter configuration for `{}`.",
563            data.label
564        ),
565        CompletionDataKind::Template => format!(
566            "Inserts the `{}` Mermaid authoring template with editable placeholders.",
567            data.label
568        ),
569    }
570}
571
572#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
573pub struct CompletionList {
574    pub is_incomplete: bool,
575    pub fact_source: Option<FenceTextIndexSource>,
576    pub items: Vec<CompletionItem>,
577}
578
579#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
580pub struct CompletionItem {
581    pub label: String,
582    pub kind: CompletionItemKind,
583    pub detail: Option<String>,
584    pub data: Option<CompletionResolveData>,
585    pub insert_text: Option<String>,
586    pub insert_text_format: CompletionInsertTextFormat,
587    pub text_edit: Option<CompletionTextEdit>,
588    pub label_details: Option<CompletionItemLabelDetails>,
589}
590
591#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
592#[serde(rename_all = "snake_case")]
593pub enum CompletionItemKind {
594    Keyword,
595    Variable,
596    Class,
597    Snippet,
598}
599
600#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
601#[serde(rename_all = "snake_case")]
602pub enum CompletionInsertTextFormat {
603    PlainText,
604    Snippet,
605}
606
607#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
608pub struct CompletionTextEdit {
609    pub range: Range,
610    pub new_text: String,
611}
612
613#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
614pub struct CompletionItemLabelDetails {
615    pub description: Option<String>,
616    pub detail: Option<String>,
617}
618
619#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
620#[serde(rename_all = "snake_case")]
621pub enum CompletionDataKind {
622    DiagramHeader,
623    Operator,
624    Direction,
625    Directive,
626    Shape,
627    ClassName,
628    NodeIdentifier,
629    Style,
630    Interaction,
631    Frontmatter,
632    Template,
633}
634
635#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
636pub struct CompletionResolveData {
637    pub kind: CompletionDataKind,
638    pub label: String,
639}
640
641#[cfg(test)]
642mod tests {
643    use super::{CompletionDataKind, completion_for_snapshot};
644    use crate::snapshot::{DocumentSnapshot, FenceSnapshot};
645    use crate::types::{DocumentKind, DocumentUri, Position};
646    use crate::workspace::DocumentWorkspace;
647    use merman_analysis::{FenceTextIndex, FenceTextIndexSource, SharedTextSlice, SourceMap};
648    use merman_core::{
649        EditorExpectedSyntax, EditorExpectedSyntaxKind, EditorSemanticFacts, EditorSemanticKind,
650        EditorSemanticSymbol, EditorSpanCoordinateSpace, SourceSpan,
651    };
652    use std::sync::Arc;
653
654    #[test]
655    fn markdown_outside_mermaid_fence_returns_no_completion() {
656        let mut workspace = DocumentWorkspace::new();
657        let snapshot = workspace.upsert(
658            "file:///tmp/readme.md",
659            1,
660            "# Notes\n\nplain prose\n".to_string(),
661            DocumentKind::Markdown,
662        );
663
664        let completion = completion_for_snapshot(&snapshot, Position::new(2, 3));
665
666        assert!(completion.items.is_empty());
667    }
668
669    #[test]
670    fn source_start_offers_headers_and_templates() {
671        let mut workspace = DocumentWorkspace::new();
672        let snapshot = workspace.upsert(
673            "file:///tmp/example.mmd",
674            1,
675            "flow".to_string(),
676            DocumentKind::Diagram,
677        );
678
679        let completion = completion_for_snapshot(&snapshot, Position::new(0, 4));
680
681        assert!(
682            completion
683                .items
684                .iter()
685                .any(|item| item.data.as_ref().is_some_and(|data| {
686                    data.kind == CompletionDataKind::DiagramHeader
687                        && data.label.starts_with("flowchart")
688                }))
689        );
690        assert!(
691            completion
692                .items
693                .iter()
694                .any(|item| item.data.as_ref().is_some_and(|data| {
695                    data.kind == CompletionDataKind::Template && data.label == "flowchart template"
696                }))
697        );
698    }
699
700    #[test]
701    fn unsupported_diagram_body_context_returns_no_completion() {
702        let mut workspace = DocumentWorkspace::new();
703        let snapshot = workspace.upsert(
704            "file:///tmp/example.mmd",
705            1,
706            "flowchart TD\nunsupported".to_string(),
707            DocumentKind::Diagram,
708        );
709
710        let completion = completion_for_snapshot(&snapshot, Position::new(1, "unsupported".len()));
711
712        assert!(completion.items.is_empty());
713    }
714
715    #[test]
716    fn parser_payload_context_returns_no_completion() {
717        let mut facts = EditorSemanticFacts::new();
718        facts.push_expected_syntax(EditorExpectedSyntax::new(
719            EditorExpectedSyntaxKind::Payload,
720            SourceSpan::new(28, 33),
721        ));
722        let snapshot = snapshot_with_facts(
723            "sequenceDiagram\nAlice->Bob: Hello",
724            Some("sequence"),
725            facts,
726        );
727
728        let completion = completion_for_snapshot(&snapshot, Position::new(1, 18));
729
730        assert!(completion.items.is_empty());
731    }
732
733    #[test]
734    fn degraded_parser_flowchart_payload_suppresses_class_items() {
735        let mut facts = EditorSemanticFacts::new();
736        facts.span_coordinate_space = EditorSpanCoordinateSpace::ParserInput;
737        facts.push_symbol(EditorSemanticSymbol::new(
738            "hot",
739            Some("flowchart class".to_string()),
740            EditorSemanticKind::Class,
741            SourceSpan::new(24, 27),
742            SourceSpan::new(24, 27),
743        ));
744        let line = "A[\"docs :::h";
745        let snapshot = snapshot_with_facts(
746            &format!("flowchart TD\nclassDef hot fill:#f00\n{line}"),
747            Some("flowchart-v2"),
748            facts,
749        );
750
751        let completion = completion_for_snapshot(&snapshot, Position::new(2, line.len()));
752
753        assert_eq!(
754            completion.fact_source,
755            Some(FenceTextIndexSource::ParserCompleteDegradedSpans)
756        );
757        assert!(
758            completion.items.is_empty(),
759            "degraded payload text must not offer class completions: {:?}",
760            completion
761                .items
762                .iter()
763                .map(|item| item.label.as_str())
764                .collect::<Vec<_>>()
765        );
766    }
767
768    #[test]
769    fn parser_expected_node_slot_reuses_known_entity_ids() {
770        let mut facts = EditorSemanticFacts::new();
771        facts.push_symbol(EditorSemanticSymbol::new(
772            "A",
773            Some("flowchart node".to_string()),
774            EditorSemanticKind::Module,
775            SourceSpan::new(13, 14),
776            SourceSpan::new(13, 14),
777        ));
778        facts.push_expected_syntax(EditorExpectedSyntax::new(
779            EditorExpectedSyntaxKind::NodeIdentifier,
780            SourceSpan::new(18, 18),
781        ));
782        let snapshot = snapshot_with_facts("flowchart TD\nA--> ", Some("flowchart-v2"), facts);
783
784        let completion = completion_for_snapshot(&snapshot, Position::new(1, 5));
785
786        assert_eq!(
787            completion
788                .items
789                .iter()
790                .filter(|item| item
791                    .data
792                    .as_ref()
793                    .is_some_and(|data| { data.kind == CompletionDataKind::NodeIdentifier }))
794                .map(|item| item.label.as_str())
795                .collect::<Vec<_>>(),
796            vec!["A"]
797        );
798    }
799
800    #[test]
801    fn parser_expected_flowchart_direction_edits_only_direction_value() {
802        let mut facts = EditorSemanticFacts::new();
803        let text = "flowchart TD\nsubgraph group\ndirection LR\nend\n";
804        let value_start = text.find("LR").unwrap();
805        facts.push_expected_syntax(EditorExpectedSyntax::new(
806            EditorExpectedSyntaxKind::DirectionValue,
807            SourceSpan::new(value_start, value_start + "LR".len()),
808        ));
809        let snapshot = snapshot_with_facts(text, Some("flowchart-v2"), facts);
810
811        let completion = completion_for_snapshot(&snapshot, Position::new(2, "direction L".len()));
812
813        let labels = completion
814            .items
815            .iter()
816            .filter(|item| {
817                item.data
818                    .as_ref()
819                    .is_some_and(|data| data.kind == CompletionDataKind::Direction)
820            })
821            .map(|item| item.label.as_str())
822            .collect::<Vec<_>>();
823        assert_eq!(labels, vec!["TB", "BT", "LR", "RL"]);
824
825        let item = completion
826            .items
827            .iter()
828            .find(|item| item.label == "LR")
829            .expect("LR direction completion");
830        let edit = item.text_edit.as_ref().expect("direction text edit");
831        assert_eq!(edit.new_text, "LR");
832        assert_eq!(edit.range.start.line, 2);
833        assert_eq!(edit.range.start.character, "direction ".len());
834        assert_eq!(edit.range.end.line, 2);
835        assert_eq!(edit.range.end.character, "direction LR".len());
836    }
837
838    #[test]
839    fn parser_expected_block_arrow_direction_uses_block_values() {
840        let mut facts = EditorSemanticFacts::new();
841        let text = "block\n  blockArrow<[\"&nbsp;\"]>(right)\n";
842        let value_start = text.find("right").unwrap();
843        facts.push_expected_syntax(EditorExpectedSyntax::new(
844            EditorExpectedSyntaxKind::DirectionValue,
845            SourceSpan::new(value_start, value_start + "right".len()),
846        ));
847        let snapshot = snapshot_with_facts(text, Some("block"), facts);
848
849        let completion = completion_for_snapshot(
850            &snapshot,
851            Position::new(1, "  blockArrow<[\"&nbsp;\"]>(r".len()),
852        );
853
854        let labels = completion
855            .items
856            .iter()
857            .filter(|item| {
858                item.data
859                    .as_ref()
860                    .is_some_and(|data| data.kind == CompletionDataKind::Direction)
861            })
862            .map(|item| item.label.as_str())
863            .collect::<Vec<_>>();
864        assert_eq!(labels, vec!["right", "left", "up", "down", "x", "y"]);
865        assert!(!labels.contains(&"LR"));
866        assert!(!labels.contains(&"direction LR"));
867
868        let item = completion
869            .items
870            .iter()
871            .find(|item| item.label == "right")
872            .expect("right direction completion");
873        let edit = item.text_edit.as_ref().expect("direction text edit");
874        assert_eq!(edit.new_text, "right");
875        assert_eq!(edit.range.start.line, 1);
876        assert_eq!(
877            edit.range.start.character,
878            "  blockArrow<[\"&nbsp;\"]>(".len()
879        );
880        assert_eq!(edit.range.end.line, 1);
881        assert_eq!(
882            edit.range.end.character,
883            "  blockArrow<[\"&nbsp;\"]>(right".len()
884        );
885    }
886
887    #[test]
888    fn degraded_parser_flowchart_target_reuses_known_entity_ids() {
889        let mut facts = EditorSemanticFacts::new();
890        facts.span_coordinate_space = EditorSpanCoordinateSpace::ParserInput;
891        facts.push_symbol(EditorSemanticSymbol::new(
892            "A",
893            Some("flowchart node".to_string()),
894            EditorSemanticKind::Module,
895            SourceSpan::new(13, 14),
896            SourceSpan::new(13, 14),
897        ));
898        facts.push_symbol(EditorSemanticSymbol::new(
899            "B",
900            Some("flowchart node".to_string()),
901            EditorSemanticKind::Module,
902            SourceSpan::new(18, 19),
903            SourceSpan::new(18, 19),
904        ));
905        let snapshot =
906            snapshot_with_facts("flowchart TD\nA-->B\nC-->", Some("flowchart-v2"), facts);
907
908        let completion = completion_for_snapshot(&snapshot, Position::new(2, 4));
909
910        assert_eq!(
911            completion.fact_source,
912            Some(FenceTextIndexSource::ParserCompleteDegradedSpans)
913        );
914        assert_eq!(
915            completion
916                .items
917                .iter()
918                .filter(|item| item
919                    .data
920                    .as_ref()
921                    .is_some_and(|data| data.kind == CompletionDataKind::NodeIdentifier))
922                .map(|item| item.label.as_str())
923                .collect::<Vec<_>>(),
924            vec!["A", "B"]
925        );
926    }
927
928    #[test]
929    fn degraded_parser_flowchart_extended_links_reuse_known_entity_ids() {
930        let cases = [
931            "C--->",
932            "C~~~",
933            "C<==>",
934            "C-- label -->",
935            "C x-- label --x",
936            "C o== label ==o",
937            "C x-. label .-x",
938            "C <-- label -->",
939        ];
940
941        for line in cases {
942            let mut facts = EditorSemanticFacts::new();
943            facts.span_coordinate_space = EditorSpanCoordinateSpace::ParserInput;
944            facts.push_symbol(EditorSemanticSymbol::new(
945                "A",
946                Some("flowchart node".to_string()),
947                EditorSemanticKind::Module,
948                SourceSpan::new(13, 14),
949                SourceSpan::new(13, 14),
950            ));
951            facts.push_symbol(EditorSemanticSymbol::new(
952                "B",
953                Some("flowchart node".to_string()),
954                EditorSemanticKind::Module,
955                SourceSpan::new(18, 19),
956                SourceSpan::new(18, 19),
957            ));
958            let snapshot = snapshot_with_facts(
959                &format!("flowchart TD\nA-->B\n{line}"),
960                Some("flowchart-v2"),
961                facts,
962            );
963
964            let completion = completion_for_snapshot(&snapshot, Position::new(2, line.len()));
965
966            assert_eq!(
967                completion.fact_source,
968                Some(FenceTextIndexSource::ParserCompleteDegradedSpans)
969            );
970            assert!(
971                completion.items.iter().any(|item| item.label == "B"),
972                "expected known node completions after {line:?}, got {:?}",
973                completion
974                    .items
975                    .iter()
976                    .map(|item| item.label.as_str())
977                    .collect::<Vec<_>>()
978            );
979        }
980    }
981
982    #[test]
983    fn degraded_parser_flowchart_payload_suppresses_operator_items() {
984        let mut facts = EditorSemanticFacts::new();
985        facts.span_coordinate_space = EditorSpanCoordinateSpace::ParserInput;
986        facts.push_symbol(EditorSemanticSymbol::new(
987            "A",
988            Some("flowchart node".to_string()),
989            EditorSemanticKind::Module,
990            SourceSpan::new(13, 14),
991            SourceSpan::new(13, 14),
992        ));
993        let snapshot = snapshot_with_facts("flowchart TD\nA[\"Start", Some("flowchart-v2"), facts);
994
995        let completion = completion_for_snapshot(&snapshot, Position::new(1, 5));
996
997        assert_eq!(
998            completion.fact_source,
999            Some(FenceTextIndexSource::ParserCompleteDegradedSpans)
1000        );
1001        assert!(
1002            completion.items.is_empty(),
1003            "degraded parser payload context must not offer body completions: {:?}",
1004            completion
1005                .items
1006                .iter()
1007                .map(|item| item.label.as_str())
1008                .collect::<Vec<_>>()
1009        );
1010    }
1011
1012    #[test]
1013    fn degraded_parser_flowchart_new_notation_label_payload_returns_no_completion() {
1014        for line in ["C-- label [", "C x-- label [", "C x-. label ["] {
1015            let mut facts = EditorSemanticFacts::new();
1016            facts.span_coordinate_space = EditorSpanCoordinateSpace::ParserInput;
1017            facts.push_symbol(EditorSemanticSymbol::new(
1018                "A",
1019                Some("flowchart node".to_string()),
1020                EditorSemanticKind::Module,
1021                SourceSpan::new(13, 14),
1022                SourceSpan::new(13, 14),
1023            ));
1024            let snapshot = snapshot_with_facts(
1025                &format!("flowchart TD\nA-->B\n{line}"),
1026                Some("flowchart-v2"),
1027                facts,
1028            );
1029
1030            let completion = completion_for_snapshot(&snapshot, Position::new(2, line.len()));
1031
1032            assert_eq!(
1033                completion.fact_source,
1034                Some(FenceTextIndexSource::ParserCompleteDegradedSpans)
1035            );
1036            assert!(
1037                completion.items.is_empty(),
1038                "degraded parser new-notation label payload must not offer completions for {line:?}: {:?}",
1039                completion
1040                    .items
1041                    .iter()
1042                    .map(|item| item.label.as_str())
1043                    .collect::<Vec<_>>()
1044            );
1045        }
1046    }
1047
1048    #[test]
1049    fn degraded_parser_flowchart_new_notation_mismatched_markers_return_no_completion() {
1050        for line in ["C x-- label -->", "C o== label ==x", "C <-. label .-o"] {
1051            let mut facts = EditorSemanticFacts::new();
1052            facts.span_coordinate_space = EditorSpanCoordinateSpace::ParserInput;
1053            facts.push_symbol(EditorSemanticSymbol::new(
1054                "A",
1055                Some("flowchart node".to_string()),
1056                EditorSemanticKind::Module,
1057                SourceSpan::new(13, 14),
1058                SourceSpan::new(13, 14),
1059            ));
1060            let snapshot = snapshot_with_facts(
1061                &format!("flowchart TD\nA-->B\n{line}"),
1062                Some("flowchart-v2"),
1063                facts,
1064            );
1065
1066            let completion = completion_for_snapshot(&snapshot, Position::new(2, line.len()));
1067
1068            assert_eq!(
1069                completion.fact_source,
1070                Some(FenceTextIndexSource::ParserCompleteDegradedSpans)
1071            );
1072            assert!(
1073                completion.items.is_empty(),
1074                "mismatched flowchart markers must not complete targets for {line:?}: {:?}",
1075                completion
1076                    .items
1077                    .iter()
1078                    .map(|item| item.label.as_str())
1079                    .collect::<Vec<_>>()
1080            );
1081        }
1082    }
1083
1084    #[test]
1085    fn degraded_parser_flowchart_spaced_pipe_label_payload_returns_no_completion() {
1086        let mut facts = EditorSemanticFacts::new();
1087        facts.span_coordinate_space = EditorSpanCoordinateSpace::ParserInput;
1088        facts.push_symbol(EditorSemanticSymbol::new(
1089            "A",
1090            Some("flowchart node".to_string()),
1091            EditorSemanticKind::Module,
1092            SourceSpan::new(13, 14),
1093            SourceSpan::new(13, 14),
1094        ));
1095        let line = "C --> |go --";
1096        let snapshot = snapshot_with_facts(
1097            &format!("flowchart TD\nA-->B\n{line}"),
1098            Some("flowchart-v2"),
1099            facts,
1100        );
1101
1102        let completion = completion_for_snapshot(&snapshot, Position::new(2, line.len()));
1103
1104        assert_eq!(
1105            completion.fact_source,
1106            Some(FenceTextIndexSource::ParserCompleteDegradedSpans)
1107        );
1108        assert!(
1109            completion.items.is_empty(),
1110            "degraded parser pipe-label payload must not offer completions: {:?}",
1111            completion
1112                .items
1113                .iter()
1114                .map(|item| item.label.as_str())
1115                .collect::<Vec<_>>()
1116        );
1117    }
1118
1119    #[test]
1120    fn degraded_parser_flowchart_spaced_pipe_label_target_reuses_known_entity_ids() {
1121        let mut facts = EditorSemanticFacts::new();
1122        facts.span_coordinate_space = EditorSpanCoordinateSpace::ParserInput;
1123        facts.push_symbol(EditorSemanticSymbol::new(
1124            "A",
1125            Some("flowchart node".to_string()),
1126            EditorSemanticKind::Module,
1127            SourceSpan::new(13, 14),
1128            SourceSpan::new(13, 14),
1129        ));
1130        facts.push_symbol(EditorSemanticSymbol::new(
1131            "B",
1132            Some("flowchart node".to_string()),
1133            EditorSemanticKind::Module,
1134            SourceSpan::new(18, 19),
1135            SourceSpan::new(18, 19),
1136        ));
1137        let line = "C --> |go|   ";
1138        let snapshot = snapshot_with_facts(
1139            &format!("flowchart TD\nA-->B\n{line}"),
1140            Some("flowchart-v2"),
1141            facts,
1142        );
1143
1144        let completion = completion_for_snapshot(&snapshot, Position::new(2, line.len()));
1145
1146        assert_eq!(
1147            completion.fact_source,
1148            Some(FenceTextIndexSource::ParserCompleteDegradedSpans)
1149        );
1150        let item = completion
1151            .items
1152            .iter()
1153            .find(|item| item.label == "B")
1154            .expect("known node id completion");
1155        let edit = item.text_edit.as_ref().expect("completion text edit");
1156        assert_eq!(edit.new_text, "B");
1157        assert_eq!(edit.range.start.line, 2);
1158        assert_eq!(edit.range.start.character, "C --> |go|".len());
1159        assert_eq!(edit.range.end.line, 2);
1160        assert_eq!(edit.range.end.character, line.len());
1161    }
1162
1163    #[test]
1164    fn degraded_parser_flowchart_payload_shape_trigger_returns_no_completion() {
1165        let mut facts = EditorSemanticFacts::new();
1166        facts.span_coordinate_space = EditorSpanCoordinateSpace::ParserInput;
1167        let line = "A[\"Start [";
1168        let snapshot = snapshot_with_facts(
1169            &format!("flowchart TD\n{line}"),
1170            Some("flowchart-v2"),
1171            facts,
1172        );
1173
1174        let completion = completion_for_snapshot(&snapshot, Position::new(1, line.len()));
1175
1176        assert_eq!(
1177            completion.fact_source,
1178            Some(FenceTextIndexSource::ParserCompleteDegradedSpans)
1179        );
1180        assert!(
1181            completion.items.is_empty(),
1182            "degraded parser payload shape trigger must not offer completions: {:?}",
1183            completion
1184                .items
1185                .iter()
1186                .map(|item| item.label.as_str())
1187                .collect::<Vec<_>>()
1188        );
1189    }
1190
1191    #[test]
1192    fn degraded_parser_flowchart_top_level_shape_trigger_still_completes_shapes() {
1193        let mut facts = EditorSemanticFacts::new();
1194        facts.span_coordinate_space = EditorSpanCoordinateSpace::ParserInput;
1195        let line = "A((";
1196        let snapshot = snapshot_with_facts(
1197            &format!("flowchart TD\n{line}"),
1198            Some("flowchart-v2"),
1199            facts,
1200        );
1201
1202        let completion = completion_for_snapshot(&snapshot, Position::new(1, line.len()));
1203
1204        assert_eq!(
1205            completion.fact_source,
1206            Some(FenceTextIndexSource::ParserCompleteDegradedSpans)
1207        );
1208        assert!(
1209            completion.items.iter().any(|item| item
1210                .data
1211                .as_ref()
1212                .is_some_and(|data| data.kind == CompletionDataKind::Shape)),
1213            "degraded parser top-level shape trigger should offer shape completions: {:?}",
1214            completion
1215                .items
1216                .iter()
1217                .map(|item| item.label.as_str())
1218                .collect::<Vec<_>>()
1219        );
1220    }
1221
1222    #[test]
1223    fn degraded_parser_flowchart_shape_object_later_field_returns_no_completion() {
1224        let mut facts = EditorSemanticFacts::new();
1225        facts.span_coordinate_space = EditorSpanCoordinateSpace::ParserInput;
1226        let line = "A@{ shape: rect, label: rou }";
1227        let cursor = line.find("rou").unwrap() + "rou".len();
1228        let snapshot = snapshot_with_facts(
1229            &format!("flowchart TD\n{line}"),
1230            Some("flowchart-v2"),
1231            facts,
1232        );
1233
1234        let completion = completion_for_snapshot(&snapshot, Position::new(1, cursor));
1235
1236        assert_eq!(
1237            completion.fact_source,
1238            Some(FenceTextIndexSource::ParserCompleteDegradedSpans)
1239        );
1240        assert!(
1241            completion.items.iter().all(|item| item
1242                .data
1243                .as_ref()
1244                .is_none_or(|data| data.kind != CompletionDataKind::Shape)),
1245            "shape completion must not cross shape-data fields: {:?}",
1246            completion
1247                .items
1248                .iter()
1249                .map(|item| item.label.as_str())
1250                .collect::<Vec<_>>()
1251        );
1252    }
1253
1254    #[test]
1255    fn degraded_parser_flowchart_payload_shape_object_text_returns_no_completion() {
1256        let mut facts = EditorSemanticFacts::new();
1257        facts.span_coordinate_space = EditorSpanCoordinateSpace::ParserInput;
1258        let line = "A[\"docs @{ shape: rou";
1259        let snapshot = snapshot_with_facts(
1260            &format!("flowchart TD\n{line}"),
1261            Some("flowchart-v2"),
1262            facts,
1263        );
1264
1265        let completion = completion_for_snapshot(&snapshot, Position::new(1, line.len()));
1266
1267        assert_eq!(
1268            completion.fact_source,
1269            Some(FenceTextIndexSource::ParserCompleteDegradedSpans)
1270        );
1271        assert!(
1272            completion.items.is_empty(),
1273            "degraded parser payload shape-object text must not offer completions: {:?}",
1274            completion
1275                .items
1276                .iter()
1277                .map(|item| item.label.as_str())
1278                .collect::<Vec<_>>()
1279        );
1280    }
1281
1282    #[test]
1283    fn degraded_parser_flowchart_payload_shape_object_text_after_top_level_marker_returns_no_completion()
1284     {
1285        let mut facts = EditorSemanticFacts::new();
1286        facts.span_coordinate_space = EditorSpanCoordinateSpace::ParserInput;
1287        let line = "A@{ icon: \"home\" } B[\"docs @{ shape: rou";
1288        let snapshot = snapshot_with_facts(
1289            &format!("flowchart TD\n{line}"),
1290            Some("flowchart-v2"),
1291            facts,
1292        );
1293
1294        let completion = completion_for_snapshot(&snapshot, Position::new(1, line.len()));
1295
1296        assert_eq!(
1297            completion.fact_source,
1298            Some(FenceTextIndexSource::ParserCompleteDegradedSpans)
1299        );
1300        assert!(
1301            completion.items.is_empty(),
1302            "degraded parser payload shape-object text after top-level marker must not offer completions: {:?}",
1303            completion
1304                .items
1305                .iter()
1306                .map(|item| item.label.as_str())
1307                .collect::<Vec<_>>()
1308        );
1309    }
1310
1311    #[test]
1312    fn degraded_parser_flowchart_shape_object_value_still_completes_shapes() {
1313        let mut facts = EditorSemanticFacts::new();
1314        facts.span_coordinate_space = EditorSpanCoordinateSpace::ParserInput;
1315        let line = "A@{ shape: rou";
1316        let snapshot = snapshot_with_facts(
1317            &format!("flowchart TD\n{line}"),
1318            Some("flowchart-v2"),
1319            facts,
1320        );
1321
1322        let completion = completion_for_snapshot(&snapshot, Position::new(1, line.len()));
1323
1324        assert_eq!(
1325            completion.fact_source,
1326            Some(FenceTextIndexSource::ParserCompleteDegradedSpans)
1327        );
1328        assert!(
1329            completion.items.iter().any(|item| item
1330                .data
1331                .as_ref()
1332                .is_some_and(|data| data.kind == CompletionDataKind::Shape)),
1333            "degraded parser shape object value should offer shape completions: {:?}",
1334            completion
1335                .items
1336                .iter()
1337                .map(|item| item.label.as_str())
1338                .collect::<Vec<_>>()
1339        );
1340    }
1341
1342    fn snapshot_with_facts(
1343        text: &str,
1344        diagram_type: Option<&str>,
1345        facts: EditorSemanticFacts,
1346    ) -> DocumentSnapshot {
1347        let shared_text = Arc::<str>::from(text);
1348        DocumentSnapshot {
1349            uri: DocumentUri::from("file:///tmp/example.mmd"),
1350            version: 1,
1351            kind: DocumentKind::Diagram,
1352            source: merman_analysis::SourceDescriptor::diagram()
1353                .with_path("file:///tmp/example.mmd"),
1354            text: Arc::clone(&shared_text),
1355            source_map: SourceMap::new(Arc::clone(&shared_text)),
1356            fences: vec![FenceSnapshot {
1357                source_id: "document".to_string(),
1358                index: 0,
1359                source: merman_analysis::SourceDescriptor::diagram()
1360                    .with_path("file:///tmp/example.mmd"),
1361                start: 0,
1362                body_start: 0,
1363                body_end: text.len(),
1364                end: text.len(),
1365                text: SharedTextSlice::whole(Arc::clone(&shared_text)),
1366                fence_delimiter: None,
1367                diagram_type: diagram_type.map(str::to_string),
1368                text_index: FenceTextIndex::from_core_facts(facts),
1369            }],
1370        }
1371    }
1372}