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<[\" \"]>(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<[\" \"]>(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<[\" \"]>(".len()
879 );
880 assert_eq!(edit.range.end.line, 1);
881 assert_eq!(
882 edit.range.end.character,
883 " blockArrow<[\" \"]>(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}