Skip to main content

merman_analysis/
editor.rs

1use std::collections::{BTreeMap, BTreeSet};
2
3use serde::{Deserialize, Serialize};
4
5mod core_facts;
6mod text_scan;
7
8#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
9pub struct ByteSpan {
10    pub start: usize,
11    pub end: usize,
12}
13
14impl ByteSpan {
15    pub fn contains(self, offset: usize) -> bool {
16        if self.start == self.end {
17            offset == self.start
18        } else {
19            offset >= self.start && offset < self.end
20        }
21    }
22
23    pub fn contains_inclusive_end(self, offset: usize) -> bool {
24        offset >= self.start && offset <= self.end
25    }
26}
27
28#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize)]
29#[serde(rename_all = "snake_case")]
30pub enum EditorSymbolKind {
31    Class,
32    Event,
33    Function,
34    Module,
35    Namespace,
36    Object,
37    Package,
38    Property,
39    String,
40    Struct,
41    Variable,
42}
43
44#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
45pub struct FenceLineItem {
46    pub name: String,
47    pub detail: Option<String>,
48    pub kind: EditorSymbolKind,
49    pub span: ByteSpan,
50    pub selection: ByteSpan,
51}
52
53#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
54#[serde(rename_all = "snake_case")]
55pub enum FenceSemanticRole {
56    Entity,
57    Outline,
58    Payload,
59}
60
61#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
62pub struct FenceSemanticItem {
63    pub name: String,
64    pub detail: Option<String>,
65    pub kind: EditorSymbolKind,
66    pub role: FenceSemanticRole,
67    pub span: ByteSpan,
68    pub selection: ByteSpan,
69}
70
71impl FenceSemanticItem {
72    fn to_line_item(&self) -> FenceLineItem {
73        FenceLineItem {
74            name: self.name.clone(),
75            detail: self.detail.clone(),
76            kind: self.kind,
77            span: self.span,
78            selection: self.selection,
79        }
80    }
81}
82
83#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize)]
84pub struct FenceReferenceGroup {
85    pub name: String,
86    pub kind: EditorSymbolKind,
87}
88
89impl FenceReferenceGroup {
90    pub fn new(name: impl Into<String>, kind: EditorSymbolKind) -> Self {
91        Self {
92            name: name.into(),
93            kind,
94        }
95    }
96
97    pub fn from_semantic_item(item: &FenceSemanticItem) -> Self {
98        Self::new(item.name.clone(), item.kind)
99    }
100}
101
102#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)]
103#[serde(rename_all = "snake_case")]
104pub enum FenceTextIndexSource {
105    /// Legacy text scan used only when parser facts are unavailable.
106    #[default]
107    TextScan,
108    /// Parser-backed facts from a complete family parse.
109    ParserComplete,
110    /// Parser-backed complete facts whose spans remain in parser-input coordinates.
111    ParserCompleteDegradedSpans,
112    /// Parser-backed facts from a recoverable partial parse.
113    ParserRecovered,
114    /// Parser-backed recovered facts whose spans remain in parser-input coordinates.
115    ParserRecoveredDegradedSpans,
116}
117
118#[derive(Debug, Clone, Copy, PartialEq, Eq)]
119pub struct ShapeObjectValuePrefix {
120    pub value_start: usize,
121    pub has_separator_space: bool,
122}
123
124impl FenceTextIndexSource {
125    pub fn is_parser_backed(self) -> bool {
126        matches!(
127            self,
128            Self::ParserComplete
129                | Self::ParserCompleteDegradedSpans
130                | Self::ParserRecovered
131                | Self::ParserRecoveredDegradedSpans
132        )
133    }
134
135    pub fn is_text_scan(self) -> bool {
136        matches!(self, Self::TextScan)
137    }
138
139    pub fn is_recovered(self) -> bool {
140        matches!(
141            self,
142            Self::ParserRecovered | Self::ParserRecoveredDegradedSpans
143        )
144    }
145
146    pub fn has_source_mapped_spans(self) -> bool {
147        !matches!(
148            self,
149            Self::ParserCompleteDegradedSpans | Self::ParserRecoveredDegradedSpans
150        )
151    }
152}
153
154#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)]
155pub enum FenceCursorCompletionKind {
156    DiagramHeader,
157    Operator,
158    Directive,
159    Direction,
160    Shape,
161    NodeIdentifier,
162}
163
164#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
165#[serde(rename_all = "snake_case")]
166pub enum FenceExpectedSyntaxKind {
167    IdList,
168    NodeIdentifier,
169    Shape,
170    ShapeTrigger,
171    Direction,
172    Payload,
173}
174
175#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
176pub struct FenceExpectedSyntax {
177    pub kind: FenceExpectedSyntaxKind,
178    pub span: ByteSpan,
179}
180
181#[derive(Debug, Clone, PartialEq, Eq)]
182pub struct FenceCursorContext {
183    prefix: String,
184    prefix_start: usize,
185    cursor: usize,
186    source: FenceTextIndexSource,
187    source_start: bool,
188    directive_prefix: Option<&'static str>,
189    comment_or_directive_line: bool,
190    expected_syntax: Option<FenceExpectedSyntaxKind>,
191    expected_syntax_span: Option<ByteSpan>,
192    completion_kinds: Vec<FenceCursorCompletionKind>,
193}
194
195impl FenceCursorContext {
196    pub fn prefix(&self) -> &str {
197        &self.prefix
198    }
199
200    pub fn prefix_start(&self) -> usize {
201        self.prefix_start
202    }
203
204    pub fn cursor(&self) -> usize {
205        self.cursor
206    }
207
208    pub fn source(&self) -> FenceTextIndexSource {
209        self.source
210    }
211
212    pub fn has_parser_backed_facts(&self) -> bool {
213        self.source.is_parser_backed()
214    }
215
216    pub fn is_source_start(&self) -> bool {
217        self.source_start
218    }
219
220    pub fn directive_prefix(&self) -> Option<&'static str> {
221        self.directive_prefix
222    }
223
224    pub fn is_comment_or_directive_line(&self) -> bool {
225        self.comment_or_directive_line
226    }
227
228    pub fn expected_syntax(&self) -> Option<FenceExpectedSyntaxKind> {
229        self.expected_syntax
230    }
231
232    pub fn expected_syntax_span(&self) -> Option<ByteSpan> {
233        self.expected_syntax_span
234    }
235
236    pub fn completion_kinds(&self) -> &[FenceCursorCompletionKind] {
237        &self.completion_kinds
238    }
239
240    pub fn offers(&self, kind: FenceCursorCompletionKind) -> bool {
241        self.completion_kinds.contains(&kind)
242    }
243}
244
245#[derive(Debug, Clone, Default)]
246pub struct FenceTextIndex {
247    node_ids: BTreeSet<String>,
248    class_names: BTreeSet<String>,
249    directive_prefixes: BTreeSet<String>,
250    references: BTreeMap<FenceReferenceGroup, Vec<ByteSpan>>,
251    outline_items: Vec<FenceLineItem>,
252    semantic_items: Vec<FenceSemanticItem>,
253    expected_syntax: Vec<FenceExpectedSyntax>,
254    source: FenceTextIndexSource,
255}
256
257impl FenceTextIndex {
258    pub fn from_text(text: &str, diagram_type: Option<&str>) -> Self {
259        let mut index = Self::default();
260        let mut relative_start = 0usize;
261
262        for line in text.split_inclusive('\n') {
263            let line_end = relative_start + line.len();
264            let line_no_newline = line.strip_suffix('\n').unwrap_or(line);
265            let trimmed = line_no_newline.trim_start();
266            let leading = line_no_newline.len().saturating_sub(trimmed.len());
267            let abs_start = relative_start + leading;
268            let abs_end = line_end;
269
270            index.record_line(diagram_type, line_no_newline, trimmed, abs_start, abs_end);
271            relative_start = line_end;
272        }
273
274        if !text.ends_with('\n') && relative_start < text.len() {
275            let line_no_newline = &text[relative_start..];
276            let trimmed = line_no_newline.trim_start();
277            let leading = line_no_newline.len().saturating_sub(trimmed.len());
278            index.record_line(
279                diagram_type,
280                line_no_newline,
281                trimmed,
282                relative_start + leading,
283                text.len(),
284            );
285        }
286
287        index.outline_items.sort_by(|left, right| {
288            (
289                left.span.start,
290                left.span.end,
291                left.name.as_str(),
292                left.selection.start,
293                left.selection.end,
294            )
295                .cmp(&(
296                    right.span.start,
297                    right.span.end,
298                    right.name.as_str(),
299                    right.selection.start,
300                    right.selection.end,
301                ))
302        });
303        index.outline_items.dedup_by(|left, right| {
304            left.span.start == right.span.start
305                && left.span.end == right.span.end
306                && left.name == right.name
307        });
308
309        index
310    }
311
312    pub fn from_core_facts(facts: merman_core::EditorSemanticFacts) -> Self {
313        core_facts::from_core_facts(facts)
314    }
315
316    pub fn merge_text_scan_node_ids(&mut self, text: &str, diagram_type: Option<&str>) {
317        let text_index = Self::from_text(text, diagram_type);
318        self.node_ids.extend(text_index.node_ids);
319    }
320
321    pub fn node_ids(&self) -> impl Iterator<Item = &String> {
322        self.node_ids.iter()
323    }
324
325    pub fn class_names(&self) -> impl Iterator<Item = &String> {
326        self.class_names.iter()
327    }
328
329    pub fn directive_prefixes(&self) -> impl Iterator<Item = &String> {
330        self.directive_prefixes.iter()
331    }
332
333    pub fn has_directive_prefix(&self, prefix: &str) -> bool {
334        self.directive_prefixes.contains(prefix)
335    }
336
337    pub fn first_reference_span(&self, name: &str) -> Option<ByteSpan> {
338        self.references
339            .iter()
340            .find(|(group, _)| group.name == name)
341            .map(|(_, spans)| spans)
342            .and_then(|spans| spans.first().copied())
343    }
344
345    pub fn reference_spans(&self, name: &str) -> &[ByteSpan] {
346        self.references
347            .iter()
348            .find(|(group, _)| group.name == name)
349            .map(|(_, spans)| spans.as_slice())
350            .unwrap_or(&[])
351    }
352
353    pub fn first_reference_span_for_item(&self, item: &FenceSemanticItem) -> Option<ByteSpan> {
354        self.first_reference_span_in_group(&FenceReferenceGroup::from_semantic_item(item))
355    }
356
357    pub fn reference_spans_for_item(&self, item: &FenceSemanticItem) -> &[ByteSpan] {
358        self.reference_spans_in_group(&FenceReferenceGroup::from_semantic_item(item))
359    }
360
361    pub fn first_reference_span_in_group(&self, group: &FenceReferenceGroup) -> Option<ByteSpan> {
362        self.references
363            .get(group)
364            .and_then(|spans| spans.first().copied())
365    }
366
367    pub fn reference_spans_in_group(&self, group: &FenceReferenceGroup) -> &[ByteSpan] {
368        self.references.get(group).map(Vec::as_slice).unwrap_or(&[])
369    }
370
371    pub fn references(&self) -> impl Iterator<Item = (&FenceReferenceGroup, &[ByteSpan])> {
372        self.references
373            .iter()
374            .map(|(group, spans)| (group, spans.as_slice()))
375    }
376
377    pub fn symbol_at_offset(&self, offset: usize) -> Option<(String, ByteSpan)> {
378        self.references.iter().find_map(|(group, spans)| {
379            spans
380                .iter()
381                .copied()
382                .find(|span| span.contains(offset))
383                .map(|span| (group.name.clone(), span))
384        })
385    }
386
387    pub fn semantic_item_at_offset(&self, offset: usize) -> Option<&FenceSemanticItem> {
388        self.semantic_items
389            .iter()
390            .filter(|item| item.span.contains(offset))
391            .min_by(|left, right| {
392                let left_len = left.span.end.saturating_sub(left.span.start);
393                let right_len = right.span.end.saturating_sub(right.span.start);
394                (
395                    left_len,
396                    left.selection.start,
397                    left.selection.end,
398                    left.name.as_str(),
399                )
400                    .cmp(&(
401                        right_len,
402                        right.selection.start,
403                        right.selection.end,
404                        right.name.as_str(),
405                    ))
406            })
407    }
408
409    pub fn entity_item_at_offset(&self, offset: usize) -> Option<&FenceSemanticItem> {
410        self.semantic_item_at_offset(offset)
411            .filter(|item| item.role == FenceSemanticRole::Entity)
412    }
413
414    pub fn outline_items(&self) -> &[FenceLineItem] {
415        &self.outline_items
416    }
417
418    pub fn semantic_items(&self) -> &[FenceSemanticItem] {
419        &self.semantic_items
420    }
421
422    pub fn expected_syntax(&self) -> &[FenceExpectedSyntax] {
423        &self.expected_syntax
424    }
425
426    pub fn source(&self) -> FenceTextIndexSource {
427        self.source
428    }
429
430    pub fn cursor_context(&self, text: &str, cursor_offset: usize) -> FenceCursorContext {
431        let cursor = clamp_to_char_boundary(text, cursor_offset);
432        let (prefix_start, prefix) = current_line_prefix(text, cursor);
433        let directive_prefix = directive_prefix(&prefix);
434        let comment_or_directive_line =
435            prefix.trim_start().starts_with("%%") || directive_prefix.is_some();
436        let mut completion_kinds = Vec::new();
437        let source_start = is_source_start_context(text, prefix_start);
438        let expected_syntax = self.expected_syntax_at_offset(cursor).copied();
439        let expected_syntax_kind = expected_syntax.map(|expected| expected.kind);
440        let expected_syntax_span = expected_syntax.map(|expected| expected.span);
441
442        if let Some(expected_syntax) = expected_syntax_kind {
443            apply_expected_syntax_to_completion(expected_syntax, &mut completion_kinds);
444        } else {
445            if offer_diagram_headers(source_start, &prefix) {
446                completion_kinds.push(FenceCursorCompletionKind::DiagramHeader);
447            }
448
449            if self.source.is_parser_backed() {
450                if offer_operator_items(&prefix) {
451                    completion_kinds.push(FenceCursorCompletionKind::Operator);
452                }
453                if offer_direction_items(&prefix) {
454                    completion_kinds.push(FenceCursorCompletionKind::Direction);
455                }
456                if offer_directive_items(&prefix, directive_prefix) {
457                    completion_kinds.push(FenceCursorCompletionKind::Directive);
458                }
459                if offer_shape_items(&prefix) {
460                    completion_kinds.push(FenceCursorCompletionKind::Shape);
461                }
462            }
463        }
464
465        FenceCursorContext {
466            prefix,
467            prefix_start,
468            cursor,
469            source: self.source,
470            source_start,
471            directive_prefix,
472            comment_or_directive_line,
473            expected_syntax: expected_syntax_kind,
474            expected_syntax_span,
475            completion_kinds,
476        }
477    }
478
479    fn expected_syntax_at_offset(&self, offset: usize) -> Option<&FenceExpectedSyntax> {
480        self.expected_syntax
481            .iter()
482            .filter(|expected| expected.span.contains_inclusive_end(offset))
483            .min_by(|left, right| {
484                let left_len = left.span.end.saturating_sub(left.span.start);
485                let right_len = right.span.end.saturating_sub(right.span.start);
486                (left_len, left.span.start, left.span.end).cmp(&(
487                    right_len,
488                    right.span.start,
489                    right.span.end,
490                ))
491            })
492    }
493
494    fn record_line(
495        &mut self,
496        diagram_type: Option<&str>,
497        line_no_newline: &str,
498        trimmed: &str,
499        abs_start: usize,
500        abs_end: usize,
501    ) {
502        let directive_prefix = directive_prefix(line_no_newline);
503        if let Some(prefix) = directive_prefix {
504            self.directive_prefixes.insert(prefix.to_string());
505            if is_payload_only_text_scan_prefix(prefix) {
506                return;
507            }
508        }
509
510        if directive_prefix.is_none_or(|prefix| !is_classify_only_text_scan_prefix(prefix)) {
511            text_scan::collect_node_ids(diagram_type, line_no_newline, &mut self.node_ids);
512        }
513
514        if let Some(item) = text_scan::classify_line_item(diagram_type, trimmed, abs_start, abs_end)
515        {
516            if is_class_definition_detail(item.detail.as_deref()) {
517                self.class_names.insert(item.name.clone());
518            }
519            self.references
520                .entry(FenceReferenceGroup::new(item.name.clone(), item.kind))
521                .or_default()
522                .push(item.selection);
523            self.outline_items.push(item);
524        }
525    }
526}
527
528fn clamp_to_char_boundary(text: &str, offset: usize) -> usize {
529    let mut cursor = offset.min(text.len());
530    while cursor > 0 && !text.is_char_boundary(cursor) {
531        cursor -= 1;
532    }
533    cursor
534}
535
536fn current_line_prefix(text: &str, cursor: usize) -> (usize, String) {
537    let before = &text[..cursor];
538    let line_start = before.rfind('\n').map(|index| index + 1).unwrap_or(0);
539    let raw_prefix = &before[line_start..];
540    let trimmed = raw_prefix.trim_start();
541    let prefix_start = line_start + raw_prefix.len().saturating_sub(trimmed.len());
542
543    (prefix_start, trimmed.to_string())
544}
545
546fn is_source_start_context(text: &str, prefix_start: usize) -> bool {
547    text[..prefix_start].trim().is_empty()
548}
549
550const DIRECTIVE_PREFIXES: &[&str] = &[
551    "classDef",
552    "class",
553    "style",
554    "cssClass",
555    "linkStyle",
556    "click",
557    "link",
558    "callback",
559    "links",
560    "properties",
561    "details",
562    "dateFormat",
563    "inclusiveEndDates",
564    "topAxis",
565    "axisFormat",
566    "tickInterval",
567    "includes",
568    "excludes",
569    "todayMarker",
570    "weekday",
571    "weekend",
572    "section",
573    "accTitle",
574    "accDescr",
575    "accDescription",
576    "title",
577];
578
579const DIRECTIVE_HELPER_PREFIXES: &[&str] = &[
580    "classDef",
581    "class",
582    "style",
583    "cssClass",
584    "linkStyle",
585    "click",
586    "link",
587    "callback",
588    ":::",
589];
590
591const DIRECTIVE_CLASSIFY_ONLY_PREFIXES: &[&str] = &[
592    "classDef",
593    "class",
594    "style",
595    "linkStyle",
596    "click",
597    "section",
598];
599
600const PAYLOAD_ONLY_TEXT_SCAN_PREFIXES: &[&str] = &[
601    "init",
602    "initialize",
603    "wrap",
604    "cssClass",
605    "link",
606    "callback",
607    "links",
608    "properties",
609    "details",
610    "dateFormat",
611    "inclusiveEndDates",
612    "topAxis",
613    "axisFormat",
614    "tickInterval",
615    "includes",
616    "excludes",
617    "todayMarker",
618    "weekday",
619    "weekend",
620    "accTitle",
621    "accDescr",
622    "accDescription",
623    "title",
624    ":::",
625];
626
627fn offer_diagram_headers(source_start: bool, prefix: &str) -> bool {
628    if !source_start {
629        return false;
630    }
631    let prefix = prefix.trim_end();
632
633    prefix.is_empty() || diagram_header_prefix_matches(prefix)
634}
635
636fn offer_operator_items(prefix: &str) -> bool {
637    let prefix = prefix.trim_end();
638
639    prefix.ends_with("--") || prefix.ends_with("->")
640}
641
642fn offer_directive_items(prefix: &str, directive_prefix: Option<&str>) -> bool {
643    let prefix = prefix.trim_end();
644
645    prefix.trim_start().starts_with("%%")
646        || directive_prefix.is_some_and(|prefix| DIRECTIVE_HELPER_PREFIXES.contains(&prefix))
647}
648
649fn offer_direction_items(prefix: &str) -> bool {
650    prefix.trim_end() == "direction"
651}
652
653fn offer_shape_items(prefix: &str) -> bool {
654    let prefix = prefix.trim_end();
655
656    shape_object_value_prefix(prefix).is_some()
657        || prefix.ends_with("((")
658        || prefix.ends_with("{{")
659        || prefix.ends_with('[')
660        || prefix.ends_with("[/")
661        || prefix.ends_with("[\\")
662        || prefix.ends_with('>')
663}
664
665pub fn shape_object_value_prefix(prefix: &str) -> Option<ShapeObjectValuePrefix> {
666    let mut search_end = prefix.len();
667    while let Some(marker) = prefix[..search_end].rfind("@{") {
668        let next_search_end = marker;
669        let mut offset = marker + "@{".len();
670        offset += leading_whitespace_len(&prefix[offset..]);
671
672        let tail = &prefix[offset..];
673        if !tail.starts_with("shape") {
674            search_end = next_search_end;
675            continue;
676        }
677        let after_shape = offset + "shape".len();
678        if prefix[after_shape..]
679            .chars()
680            .next()
681            .is_some_and(is_shape_key_continue)
682        {
683            search_end = next_search_end;
684            continue;
685        }
686
687        offset = after_shape;
688        offset += leading_whitespace_len(&prefix[offset..]);
689        if !prefix[offset..].starts_with(':') {
690            search_end = next_search_end;
691            continue;
692        }
693
694        offset += ':'.len_utf8();
695        let whitespace = leading_whitespace_len(&prefix[offset..]);
696        let value_start = offset + whitespace;
697        if !shape_object_prefix_is_inside_shape_value(prefix, value_start) {
698            search_end = next_search_end;
699            continue;
700        }
701        return Some(ShapeObjectValuePrefix {
702            value_start,
703            has_separator_space: whitespace > 0,
704        });
705    }
706
707    None
708}
709
710fn shape_object_prefix_is_inside_shape_value(prefix: &str, value_start: usize) -> bool {
711    prefix[value_start..]
712        .chars()
713        .all(|ch| !matches!(ch, ',' | '}' | '\n' | '\r'))
714}
715
716fn leading_whitespace_len(input: &str) -> usize {
717    input
718        .chars()
719        .take_while(|ch| ch.is_whitespace())
720        .map(char::len_utf8)
721        .sum()
722}
723
724fn is_shape_key_continue(ch: char) -> bool {
725    ch == '_' || ch == '-' || ch.is_ascii_alphanumeric()
726}
727
728fn diagram_header_prefix_matches(prefix: &str) -> bool {
729    let prefix = prefix.trim_end();
730    if prefix.is_empty() {
731        return false;
732    }
733
734    text_scan::diagram_header_facts()
735        .iter()
736        .any(|fact| fact.label.starts_with(prefix))
737}
738
739fn is_payload_only_text_scan_prefix(prefix: &str) -> bool {
740    PAYLOAD_ONLY_TEXT_SCAN_PREFIXES.contains(&prefix)
741}
742
743fn is_classify_only_text_scan_prefix(prefix: &str) -> bool {
744    DIRECTIVE_CLASSIFY_ONLY_PREFIXES.contains(&prefix)
745}
746
747fn is_class_definition_detail(detail: Option<&str>) -> bool {
748    detail.is_some_and(|detail| detail.ends_with("class definition"))
749}
750
751fn apply_expected_syntax_to_completion(
752    expected: FenceExpectedSyntaxKind,
753    completion_kinds: &mut Vec<FenceCursorCompletionKind>,
754) {
755    match expected {
756        FenceExpectedSyntaxKind::IdList => {
757            completion_kinds.clear();
758            completion_kinds.push(FenceCursorCompletionKind::NodeIdentifier);
759        }
760        FenceExpectedSyntaxKind::NodeIdentifier => {
761            completion_kinds.clear();
762            completion_kinds.push(FenceCursorCompletionKind::NodeIdentifier);
763        }
764        FenceExpectedSyntaxKind::Shape => {
765            completion_kinds.clear();
766            completion_kinds.push(FenceCursorCompletionKind::Shape);
767        }
768        FenceExpectedSyntaxKind::ShapeTrigger => {
769            completion_kinds.clear();
770            completion_kinds.push(FenceCursorCompletionKind::Shape);
771        }
772        FenceExpectedSyntaxKind::Direction => {
773            completion_kinds.clear();
774            completion_kinds.push(FenceCursorCompletionKind::Direction);
775        }
776        FenceExpectedSyntaxKind::Payload => completion_kinds.clear(),
777    }
778}
779
780fn directive_prefix(line: &str) -> Option<&'static str> {
781    let trimmed = line.trim_start();
782
783    if let Some(rest) = trimmed.strip_prefix("%%{") {
784        let name = rest
785            .split(|ch: char| ch.is_whitespace() || matches!(ch, ':' | '}'))
786            .next()
787            .filter(|name| !name.is_empty())?;
788
789        return matches!(name, "init" | "initialize" | "wrap").then_some(match name {
790            "init" => "init",
791            "initialize" => "initialize",
792            "wrap" => "wrap",
793            _ => unreachable!(),
794        });
795    }
796
797    if trimmed.starts_with(":::") {
798        return Some(":::");
799    }
800
801    for &prefix in DIRECTIVE_PREFIXES {
802        if has_word_boundary(trimmed, prefix) {
803            return Some(prefix);
804        }
805    }
806
807    None
808}
809
810fn has_word_boundary(text: &str, prefix: &str) -> bool {
811    text.strip_prefix(prefix).is_some_and(|rest| {
812        rest.is_empty()
813            || rest
814                .chars()
815                .next()
816                .is_some_and(|ch| ch.is_whitespace() || matches!(ch, ':' | '{'))
817    })
818}
819
820#[cfg(test)]
821mod tests;