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 #[default]
107 TextScan,
108 ParserComplete,
110 ParserCompleteDegradedSpans,
112 ParserRecovered,
114 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;