1use pulldown_cmark::{Event, HeadingLevel, LinkType as CmLinkType, Options, Parser, Tag, TagEnd};
12use regex::Regex;
13use std::ops::Range;
14use std::path::Path;
15use std::sync::LazyLock;
16use turbovault_core::{
17 Callout, CalloutType, Frontmatter, Heading, LineIndex, Link, LinkType, SourcePosition,
18 Tag as OFMTag, TaskBuilder, TaskItem,
19};
20
21use crate::ParseOptions;
22use crate::blocks::slugify;
23use crate::parsers::link_utils::{
24 classify_url, classify_wikilink, link_destination_end, markdown_link_target, split_wikilink,
25};
26
27static WIKILINK: LazyLock<Regex> = LazyLock::new(|| Regex::new(r"\[\[([^\]]+)\]\]").unwrap());
34
35static EMBED: LazyLock<Regex> = LazyLock::new(|| Regex::new(r"!\[\[([^\]]+)\]\]").unwrap());
37
38static TAG: LazyLock<Regex> =
40 LazyLock::new(|| Regex::new(r"(?:^|[\s\[(])#([a-zA-Z0-9_][a-zA-Z0-9_\-/]*)").unwrap());
41
42static CALLOUT: LazyLock<Regex> =
44 LazyLock::new(|| Regex::new(r"^\s*>\s*\[!(\w+)\]([+-]?)\s*(.*?)$").unwrap());
45
46static CALLOUT_CONT: LazyLock<Regex> = LazyLock::new(|| Regex::new(r"^\s*>\s*(.*)$").unwrap());
48
49static EXTENDED_TASK: LazyLock<Regex> = LazyLock::new(|| {
51 Regex::new(
52 r"^(?P<indent>[ \t]*)(?P<bullet>[-*+])[ \t]+\[(?P<marker>[/\-])\][ \t]*(?P<content>.*)$",
53 )
54 .unwrap()
55});
56
57#[inline]
62fn has_wikilink(content: &str) -> bool {
63 content.contains("[[")
64}
65
66#[inline]
67fn has_tag(content: &str) -> bool {
68 content.contains('#')
69}
70
71#[inline]
72fn has_callout(content: &str) -> bool {
73 content.contains("[!")
74}
75
76#[inline]
77fn has_extended_task(content: &str) -> bool {
78 content.contains("[/]") || content.contains("[-]")
79}
80
81#[derive(Debug, Default, Clone)]
89struct ExcludedRanges {
90 ranges: Vec<Range<usize>>,
91}
92
93impl ExcludedRanges {
94 #[inline]
96 fn contains(&self, offset: usize) -> bool {
97 if self.ranges.is_empty() {
99 return false;
100 }
101
102 let idx = self.ranges.partition_point(|r| r.start <= offset);
105
106 if idx == 0 {
107 return false;
109 }
110
111 let candidate = &self.ranges[idx - 1];
117 offset < candidate.end
118 }
119
120 fn add(&mut self, range: Range<usize>) {
122 self.ranges.push(range);
123 }
124
125 fn optimize(&mut self) {
127 if self.ranges.is_empty() {
128 return;
129 }
130 self.ranges.sort_by_key(|r| r.start);
131
132 let mut merged = Vec::with_capacity(self.ranges.len());
133 let mut current = self.ranges[0].clone();
134
135 for range in self.ranges.iter().skip(1) {
136 if range.start <= current.end {
137 current.end = current.end.max(range.end);
139 } else {
140 merged.push(current);
141 current = range.clone();
142 }
143 }
144 merged.push(current);
145 self.ranges = merged;
146 }
147}
148
149#[derive(Debug, Clone, Default)]
155pub(crate) struct ParseResult {
156 pub frontmatter: Option<Frontmatter>,
157 pub frontmatter_end_offset: usize, pub headings: Vec<Heading>,
159 pub wikilinks: Vec<Link>,
160 pub embeds: Vec<Link>,
161 pub markdown_links: Vec<Link>,
162 pub tags: Vec<OFMTag>,
163 pub tasks: Vec<TaskItem>,
164 pub callouts: Vec<Callout>,
165}
166
167impl ParseResult {
168 #[allow(dead_code)]
170 pub(crate) fn all_links(&self) -> impl Iterator<Item = &Link> {
171 self.wikilinks
172 .iter()
173 .chain(self.embeds.iter())
174 .chain(self.markdown_links.iter())
175 }
176}
177
178#[derive(Debug, Clone, PartialEq, Eq)]
186pub struct ReferenceDefinition {
187 pub label: String,
189 pub target: String,
192 pub destination: Range<usize>,
194}
195
196pub(crate) fn reference_definitions(content: &str) -> Vec<ReferenceDefinition> {
200 let parser = Parser::new_ext(content, markdown_options());
201 let mut definitions: Vec<ReferenceDefinition> = parser
202 .reference_definitions()
203 .iter()
204 .filter_map(|(label, definition)| {
205 let start = destination_start(content, definition.span.start)?;
206 let end = link_destination_end(content, start)?;
207 Some(ReferenceDefinition {
208 label: label.to_string(),
209 target: markdown_link_target(&definition.dest),
210 destination: start..end,
211 })
212 })
213 .collect();
214 definitions.sort_by_key(|definition| definition.destination.start);
215 definitions
216}
217
218fn destination_start(content: &str, start: usize) -> Option<usize> {
221 let bytes = content.as_bytes();
222 let mut i = start + 1;
223 loop {
224 match *bytes.get(i)? {
225 b'\\' => i += 1,
226 b']' => break,
227 _ => {}
228 }
229 i += 1;
230 }
231 if bytes.get(i + 1) != Some(&b':') {
232 return None;
233 }
234 i += 2;
235 while matches!(bytes.get(i), Some(b' ' | b'\t' | b'\r' | b'\n' | b'>')) {
236 i += 1;
237 }
238 Some(i)
239}
240
241fn is_reference(link_type: CmLinkType) -> bool {
243 matches!(
244 link_type,
245 CmLinkType::Reference
246 | CmLinkType::ReferenceUnknown
247 | CmLinkType::Collapsed
248 | CmLinkType::CollapsedUnknown
249 | CmLinkType::Shortcut
250 | CmLinkType::ShortcutUnknown
251 )
252}
253
254fn markdown_options() -> Options {
256 Options::ENABLE_TASKLISTS
257 | Options::ENABLE_YAML_STYLE_METADATA_BLOCKS
258 | Options::ENABLE_HEADING_ATTRIBUTES
259 | Options::ENABLE_STRIKETHROUGH
260 | Options::ENABLE_TABLES
261}
262
263pub(crate) struct ParseEngine<'a> {
273 content: &'a str,
274 index: LineIndex,
275 source_file: Option<&'a Path>,
276}
277
278impl<'a> ParseEngine<'a> {
279 pub(crate) fn new(content: &'a str) -> Self {
281 Self {
282 content,
283 index: LineIndex::new(content),
284 source_file: None,
285 }
286 }
287
288 pub(crate) fn with_source_file(content: &'a str, source_file: &'a Path) -> Self {
290 Self {
291 content,
292 index: LineIndex::new(content),
293 source_file: Some(source_file),
294 }
295 }
296
297 pub(crate) fn parse(&self, options: &ParseOptions) -> ParseResult {
299 let mut result = ParseResult::default();
300
301 let (excluded, body_start) = self.pulldown_pass(options, &mut result);
305
306 result.frontmatter_end_offset = body_start;
308
309 if options.parse_wikilinks && body_start > 0 {
314 let frontmatter_text = &self.content[..body_start];
315 self.parse_frontmatter_wikilinks(frontmatter_text, &mut result);
316 }
317 if options.parse_markdown_links && body_start > 0 {
318 let frontmatter_text = &self.content[..body_start];
319 self.parse_frontmatter_markdown_links(frontmatter_text, &mut result);
320 }
321
322 let body = if body_start > 0 {
324 &self.content[body_start..]
325 } else {
326 self.content
327 };
328
329 if options.parse_tasks {
330 self.parse_extended_tasks(body, body_start, &excluded, &mut result);
331 }
332
333 if options.parse_wikilinks {
334 self.parse_wikilinks(body, body_start, &excluded, &mut result);
335 self.parse_embeds(body, body_start, &excluded, &mut result);
336 }
337
338 if options.parse_tags {
339 self.parse_tags(body, body_start, &excluded, &mut result);
340 }
341
342 if options.parse_callouts {
344 self.parse_callouts(body, body_start, &excluded, options, &mut result);
345 }
346
347 result
348 }
349
350 fn pulldown_pass(
352 &self,
353 options: &ParseOptions,
354 result: &mut ParseResult,
355 ) -> (ExcludedRanges, usize) {
356 let mut excluded = ExcludedRanges::default();
357 let mut body_start: usize = 0;
358
359 let parser = Parser::new_ext(self.content, markdown_options());
360
361 let mut in_code_block = false;
363 let mut code_block_start: usize = 0;
364 let mut in_metadata = false;
365 let mut metadata_content = String::new();
366 let mut current_heading: Option<(HeadingLevel, Option<String>)> = None;
367 let mut heading_text = String::new();
368 let mut heading_start: usize = 0;
369
370 let mut in_task_item: bool = false;
371 let mut task_builder: TaskBuilder = TaskBuilder::default();
372
373 let mut current_link: Option<(String, CmLinkType)> = None;
374 let mut link_text = String::new();
375 let mut link_start: usize = 0;
376 let mut markdown_link_range_start: Option<usize> = None;
377 let mut markdown_image_range_start: Option<usize> = None;
378
379 for (event, range) in parser.into_offset_iter() {
380 match event {
381 Event::Start(Tag::CodeBlock(_)) => {
383 in_code_block = true;
384 code_block_start = range.start;
385 }
386 Event::End(TagEnd::CodeBlock) => {
387 in_code_block = false;
388 excluded.add(code_block_start..range.end);
389 }
390
391 Event::Code(text) if current_heading.is_some() => {
393 heading_text.push_str(&text);
394 excluded.add(range.clone());
395 }
396 Event::Code(_) => {
397 excluded.add(range.clone());
398 }
399
400 Event::Html(_) => {
402 excluded.add(range.clone());
403 }
404
405 Event::Start(Tag::MetadataBlock(_)) => {
407 in_metadata = true;
408 metadata_content.clear();
409 }
410 Event::End(TagEnd::MetadataBlock(_)) => {
411 in_metadata = false;
412 body_start = range.end;
413
414 if options.parse_frontmatter && !metadata_content.is_empty() {
415 if let Ok(serde_json::Value::Object(map)) =
417 crate::parse_frontmatter_yaml(&metadata_content)
418 {
419 result.frontmatter = Some(Frontmatter {
420 data: map.into_iter().collect(),
421 position: SourcePosition::start(),
422 });
423 }
424 }
425 }
426 Event::Text(text) if in_metadata => {
427 metadata_content.push_str(&text);
428 }
429
430 Event::Start(Tag::Heading { level, id, .. }) if options.parse_headings => {
432 current_heading = Some((level, id.map(|s| s.to_string())));
433 heading_text.clear();
434 heading_start = range.start;
435 }
436 Event::End(TagEnd::Heading(_)) => {
437 if let Some((level, id)) = current_heading.take() {
438 let level_num = match level {
439 HeadingLevel::H1 => 1,
440 HeadingLevel::H2 => 2,
441 HeadingLevel::H3 => 3,
442 HeadingLevel::H4 => 4,
443 HeadingLevel::H5 => 5,
444 HeadingLevel::H6 => 6,
445 };
446
447 let anchor = id.or_else(|| {
451 let slug = slugify(&heading_text);
452 (!slug.is_empty()).then_some(slug)
453 });
454
455 result.headings.push(Heading {
456 text: heading_text.trim().to_string(),
457 level: level_num,
458 position: SourcePosition::from_offset_indexed(
459 &self.index,
460 heading_start,
461 range.end - heading_start,
462 ),
463 anchor,
464 });
465 }
466 }
467 Event::Text(text)
473 if current_heading.is_some() && markdown_image_range_start.is_none() =>
474 {
475 heading_text.push_str(&text);
476 }
477 Event::SoftBreak | Event::HardBreak if current_heading.is_some() => {
479 heading_text.push(' ');
480 }
481
482 Event::TaskListMarker(_checked) if options.parse_tasks => {
484 in_task_item = true;
485 let raw_marker = self
487 .content
488 .get(range.start + 1..)
489 .and_then(|rest| rest.chars().next())
490 .unwrap_or(' ');
491 task_builder.is_completed =
492 crate::models::TaskStatus::from_marker(raw_marker).is_completed();
493 task_builder.status = Some(raw_marker);
494 task_builder.position = SourcePosition::from_offset_indexed(
495 &self.index,
496 task_line_start_for_marker(self.content, &self.index, range.start),
497 task_line_length_for_marker(self.content, &self.index, range.start),
498 );
499 }
500
501 Event::End(TagEnd::Item) if in_task_item => {
502 in_task_item = false;
503 if !task_builder.content.is_empty() {
504 result.tasks.push(task_builder.build());
505 }
506 }
507
508 Event::Text(text) if in_task_item => {
509 task_builder.content.push_str(&text);
510 }
511
512 Event::Start(Tag::Link { link_type, .. })
518 if is_reference(link_type)
519 && self.content[..range.start].ends_with('[')
520 && self.content[range.end..].starts_with(']') => {}
521 Event::Start(Tag::Link {
522 link_type,
523 dest_url,
524 ..
525 }) if !in_code_block => {
526 markdown_link_range_start = Some(range.start);
527 if options.parse_markdown_links {
528 current_link = Some((dest_url.to_string(), link_type));
529 link_text.clear();
530 link_start = range.start;
531 }
532 }
533 Event::End(TagEnd::Link) => {
534 let end = match current_link {
537 Some((_, CmLinkType::Collapsed))
538 if self.content[range.end..].starts_with("[]") =>
539 {
540 range.end + 2
541 }
542 _ => range.end,
543 };
544 if let Some(start) = markdown_link_range_start.take() {
545 excluded.add(start..end);
546 }
547 if let Some((url, _)) = current_link.take() {
548 let link_type = classify_url(&url);
549
550 result.markdown_links.push(Link {
551 type_: link_type,
552 source_file: self
553 .source_file
554 .map(|p| p.to_path_buf())
555 .unwrap_or_default(),
556 target: markdown_link_target(&url),
557 display_text: Some(link_text.trim().to_string()),
558 position: SourcePosition::from_offset_indexed(
559 &self.index,
560 link_start,
561 end - link_start,
562 ),
563 resolved_target: None,
564 is_valid: true,
565 });
566 }
567 link_text.clear();
568 }
569 Event::Text(text) if current_link.is_some() => {
570 link_text.push_str(&text);
571 }
572
573 Event::Start(Tag::Image { .. }) if !in_code_block => {
575 markdown_image_range_start = Some(range.start);
576 }
577 Event::End(TagEnd::Image) => {
578 if let Some(start) = markdown_image_range_start.take() {
579 excluded.add(start..range.end);
580 }
581 }
582
583 _ => {}
584 }
585 }
586
587 excluded.optimize();
588 (excluded, body_start)
589 }
590
591 fn parse_wikilinks(
593 &self,
594 body: &str,
595 body_offset: usize,
596 excluded: &ExcludedRanges,
597 result: &mut ParseResult,
598 ) {
599 if !has_wikilink(body) {
600 return;
601 }
602
603 let source = self
604 .source_file
605 .map(|p| p.to_path_buf())
606 .unwrap_or_default();
607
608 for caps in WIKILINK.captures_iter(body) {
609 let full_match = caps.get(0).unwrap();
610 let local_start = full_match.start();
611 let global_start = body_offset + local_start;
612
613 if excluded.contains(global_start) {
615 continue;
616 }
617
618 if local_start > 0 && body.as_bytes().get(local_start - 1) == Some(&b'!') {
620 continue;
621 }
622
623 let raw_target = caps.get(1).unwrap().as_str();
624 let (target, display_text) = split_wikilink(raw_target);
625 let link_type = classify_wikilink(&target);
626
627 result.wikilinks.push(Link {
628 type_: link_type,
629 source_file: source.clone(),
630 target,
631 display_text,
632 position: SourcePosition::from_offset_indexed(
633 &self.index,
634 global_start,
635 full_match.len(),
636 ),
637 resolved_target: None,
638 is_valid: true,
639 });
640 }
641 }
642
643 fn parse_embeds(
645 &self,
646 body: &str,
647 body_offset: usize,
648 excluded: &ExcludedRanges,
649 result: &mut ParseResult,
650 ) {
651 if !has_wikilink(body) {
652 return;
653 }
654
655 let source = self
656 .source_file
657 .map(|p| p.to_path_buf())
658 .unwrap_or_default();
659
660 for caps in EMBED.captures_iter(body) {
661 let full_match = caps.get(0).unwrap();
662 let local_start = full_match.start();
663 let global_start = body_offset + local_start;
664
665 if excluded.contains(global_start) {
667 continue;
668 }
669
670 let raw_target = caps.get(1).unwrap().as_str();
671 let (target, display_text) = split_wikilink(raw_target);
672
673 result.embeds.push(Link {
674 type_: LinkType::Embed,
675 source_file: source.clone(),
676 target,
677 display_text,
678 position: SourcePosition::from_offset_indexed(
679 &self.index,
680 global_start,
681 full_match.len(),
682 ),
683 resolved_target: None,
684 is_valid: true,
685 });
686 }
687 }
688
689 fn parse_frontmatter_wikilinks(&self, frontmatter_text: &str, result: &mut ParseResult) {
702 if !has_wikilink(frontmatter_text) {
703 return;
704 }
705
706 let source = self
707 .source_file
708 .map(|p| p.to_path_buf())
709 .unwrap_or_default();
710
711 for caps in WIKILINK.captures_iter(frontmatter_text) {
713 let full_match = caps.get(0).unwrap();
714 let global_start = full_match.start();
715
716 if global_start > 0 && frontmatter_text.as_bytes().get(global_start - 1) == Some(&b'!')
718 {
719 continue;
720 }
721
722 let raw_target = caps.get(1).unwrap().as_str();
723 let (target, display_text) = split_wikilink(raw_target);
724 let link_type = classify_wikilink(&target);
725
726 result.wikilinks.push(Link {
727 type_: link_type,
728 source_file: source.clone(),
729 target,
730 display_text,
731 position: SourcePosition::from_offset_indexed(
732 &self.index,
733 global_start,
734 full_match.len(),
735 ),
736 resolved_target: None,
737 is_valid: true,
738 });
739 }
740
741 for caps in EMBED.captures_iter(frontmatter_text) {
743 let full_match = caps.get(0).unwrap();
744 let global_start = full_match.start();
745
746 let raw_target = caps.get(1).unwrap().as_str();
747 let (target, display_text) = split_wikilink(raw_target);
748
749 result.embeds.push(Link {
750 type_: LinkType::Embed,
751 source_file: source.clone(),
752 target,
753 display_text,
754 position: SourcePosition::from_offset_indexed(
755 &self.index,
756 global_start,
757 full_match.len(),
758 ),
759 resolved_target: None,
760 is_valid: true,
761 });
762 }
763 }
764
765 fn parse_frontmatter_markdown_links(&self, frontmatter_text: &str, result: &mut ParseResult) {
773 let source = self
774 .source_file
775 .map(|p| p.to_path_buf())
776 .unwrap_or_default();
777
778 let mut links = Vec::new();
779 let mut line_start = 0;
780 for line in frontmatter_text.split_inclusive('\n') {
781 let value = line.trim_start();
782 let value_start = line_start + (line.len() - value.len());
783 line_start += line.len();
784 if !value.contains("](") {
785 continue;
786 }
787
788 let mut current: Option<(String, usize)> = None;
789 let mut text = String::new();
790 for (event, range) in Parser::new(value).into_offset_iter() {
791 match event {
792 Event::Start(Tag::Link { dest_url, .. }) => {
793 current = Some((dest_url.to_string(), range.start));
794 text.clear();
795 }
796 Event::Text(t) | Event::Code(t) if current.is_some() => text.push_str(&t),
797 Event::End(TagEnd::Link) => {
798 if let Some((url, start)) = current.take() {
799 links.push(Link {
800 type_: classify_url(&url),
801 source_file: source.clone(),
802 target: markdown_link_target(&url),
803 display_text: Some(text.trim().to_string()),
804 position: SourcePosition::from_offset_indexed(
805 &self.index,
806 value_start + start,
807 range.end - start,
808 ),
809 resolved_target: None,
810 is_valid: true,
811 });
812 }
813 }
814 _ => {}
815 }
816 }
817 }
818
819 result.markdown_links.splice(0..0, links);
821 }
822
823 fn parse_tags(
825 &self,
826 body: &str,
827 body_offset: usize,
828 excluded: &ExcludedRanges,
829 result: &mut ParseResult,
830 ) {
831 if !has_tag(body) {
832 return;
833 }
834
835 let mut excluded = excluded.clone();
836 add_wikilink_exclusions(body, body_offset, &mut excluded);
837 excluded.optimize();
838
839 for caps in TAG.captures_iter(body) {
840 let tag_name = caps.get(1).unwrap();
842 let local_start = tag_name.start() - 1; let global_start = body_offset + local_start;
844
845 if excluded.contains(global_start) {
847 continue;
848 }
849
850 let name = tag_name.as_str();
851
852 result.tags.push(OFMTag {
853 name: name.to_string(),
854 position: SourcePosition::from_offset_indexed(
855 &self.index,
856 global_start,
857 name.len() + 1, ),
859 is_nested: name.contains('/'),
860 });
861 }
862 }
863
864 fn parse_extended_tasks(
866 &self,
867 body: &str,
868 body_offset: usize,
869 excluded: &ExcludedRanges,
870 result: &mut ParseResult,
871 ) {
872 if !has_extended_task(body) {
873 return;
874 }
875
876 let mut offset = 0;
877
878 for line in body.lines() {
879 let line_start = offset;
880 let global_line_start = body_offset + line_start;
881 let line_end_size = line_ending_size_at(body, line_start + line.len());
882 offset += line.len() + line_end_size;
883
884 if excluded.contains(global_line_start) {
885 continue;
886 }
887
888 let Some(caps) = EXTENDED_TASK.captures(line) else {
889 continue;
890 };
891
892 let content = caps.name("content").map(|m| m.as_str()).unwrap_or("");
893 if content.is_empty() {
894 continue;
895 }
896
897 let indent = caps.name("indent").map(|m| m.as_str()).unwrap_or("");
898 let marker = caps
899 .name("marker")
900 .and_then(|m| m.as_str().chars().next())
901 .unwrap_or(' ');
902 let task_start = global_line_start + indent.len();
903 let mut task_builder = TaskBuilder {
904 content: content.to_string(),
905 is_completed: crate::models::TaskStatus::from_marker(marker).is_completed(),
906 status: Some(marker),
907 position: SourcePosition::from_offset_indexed(
908 &self.index,
909 task_start,
910 line.len() - indent.len(),
911 ),
912 };
913
914 result.tasks.push(task_builder.build());
915 }
916 }
917
918 fn parse_callouts(
920 &self,
921 body: &str,
922 body_offset: usize,
923 excluded: &ExcludedRanges,
924 options: &ParseOptions,
925 result: &mut ParseResult,
926 ) {
927 if !has_callout(body) {
928 return;
929 }
930
931 let lines: Vec<&str> = body.lines().collect();
932 let mut offset = 0;
933 let mut i = 0;
934
935 while i < lines.len() {
936 let line = lines[i];
937 let line_start = offset;
938 let global_line_start = body_offset + line_start;
939 let line_end_size = line_ending_size_at(body, offset + line.len());
940 offset += line.len() + line_end_size;
941
942 if excluded.contains(global_line_start) {
944 i += 1;
945 continue;
946 }
947
948 if let Some(caps) = CALLOUT.captures(line) {
949 let callout = if options.full_callouts {
950 self.parse_callout_full(
951 &lines,
952 body,
953 &mut i,
954 global_line_start,
955 &caps,
956 excluded,
957 body_offset,
958 &mut offset,
959 )
960 } else {
961 i += 1;
962 self.parse_callout_simple(line, global_line_start, &caps)
963 };
964 result.callouts.push(callout);
965 } else {
966 i += 1;
967 }
968 }
969 }
970
971 fn parse_callout_simple(
973 &self,
974 line: &str,
975 global_offset: usize,
976 caps: ®ex::Captures,
977 ) -> Callout {
978 let type_str = caps.get(1).unwrap().as_str();
979 let fold_marker = caps.get(2).unwrap().as_str();
980 let title_text = caps.get(3).unwrap().as_str();
981
982 Callout {
983 type_: parse_callout_type(type_str),
984 title: if title_text.is_empty() {
985 None
986 } else {
987 Some(title_text.to_string())
988 },
989 content: String::new(),
990 position: SourcePosition::from_offset_indexed(&self.index, global_offset, line.len()),
991 is_foldable: !fold_marker.is_empty(),
992 }
993 }
994
995 #[allow(clippy::too_many_arguments)]
997 fn parse_callout_full(
998 &self,
999 lines: &[&str],
1000 body: &str,
1001 i: &mut usize,
1002 global_line_start: usize,
1003 caps: ®ex::Captures,
1004 excluded: &ExcludedRanges,
1005 body_offset: usize,
1006 offset: &mut usize,
1007 ) -> Callout {
1008 let start_line_idx = *i;
1009 let first_line = lines[start_line_idx];
1010 let type_str = caps.get(1).unwrap().as_str();
1011 let fold_marker = caps.get(2).unwrap().as_str();
1012 let title_text = caps.get(3).unwrap().as_str();
1013
1014 let mut callout_content = String::new();
1016 *i += 1;
1017
1018 while *i < lines.len() {
1019 let line = lines[*i];
1020 let line_global_start = body_offset + *offset;
1021
1022 if excluded.contains(line_global_start) {
1024 *offset += line.len() + line_ending_size_at(body, *offset + line.len());
1025 *i += 1;
1026 continue;
1027 }
1028
1029 if CALLOUT.is_match(line) {
1031 break;
1032 }
1033
1034 if let Some(cont_caps) = CALLOUT_CONT.captures(line) {
1036 let content_part = cont_caps.get(1).unwrap().as_str();
1037 if !callout_content.is_empty() {
1038 callout_content.push('\n');
1039 }
1040 callout_content.push_str(content_part);
1041 *offset += line.len() + line_ending_size_at(body, *offset + line.len());
1042 *i += 1;
1043 } else {
1044 break;
1045 }
1046 }
1047
1048 Callout {
1049 type_: parse_callout_type(type_str),
1050 title: if title_text.is_empty() {
1051 None
1052 } else {
1053 Some(title_text.to_string())
1054 },
1055 content: callout_content,
1056 position: SourcePosition::from_offset_indexed(
1057 &self.index,
1058 global_line_start,
1059 first_line.len(),
1060 ),
1061 is_foldable: !fold_marker.is_empty(),
1062 }
1063 }
1064}
1065
1066fn add_wikilink_exclusions(body: &str, body_offset: usize, excluded: &mut ExcludedRanges) {
1075 if !has_wikilink(body) {
1076 return;
1077 }
1078
1079 for mat in WIKILINK.find_iter(body) {
1080 excluded.add(body_offset + mat.start()..body_offset + mat.end());
1081 }
1082
1083 for mat in EMBED.find_iter(body) {
1084 excluded.add(body_offset + mat.start()..body_offset + mat.end());
1085 }
1086}
1087
1088fn line_ending_size_at(content: &str, offset: usize) -> usize {
1089 let Some(remaining) = content.get(offset..) else {
1090 return 0;
1091 };
1092
1093 if remaining.starts_with("\r\n") {
1094 2
1095 } else if remaining.starts_with('\n') {
1096 1
1097 } else {
1098 0
1099 }
1100}
1101
1102fn task_line_start_for_marker(content: &str, index: &LineIndex, marker_offset: usize) -> usize {
1103 let (line, _) = index.line_col(marker_offset);
1104 let line_start = index.line_start(line).unwrap_or(marker_offset);
1105 let line_end = line_end_without_line_ending(content, line_start);
1106 let line_text = &content[line_start..line_end];
1107 line_start + leading_ascii_whitespace_len(line_text)
1108}
1109
1110fn task_line_length_for_marker(content: &str, index: &LineIndex, marker_offset: usize) -> usize {
1111 let task_start = task_line_start_for_marker(content, index, marker_offset);
1112 line_end_without_line_ending(content, task_start) - task_start
1113}
1114
1115fn line_end_without_line_ending(content: &str, line_start: usize) -> usize {
1116 let rest = content.get(line_start..).unwrap_or_default();
1117 let mut line_end = rest
1118 .find('\n')
1119 .map(|idx| line_start + idx)
1120 .unwrap_or(content.len());
1121
1122 if line_end > line_start && content.as_bytes().get(line_end - 1) == Some(&b'\r') {
1123 line_end -= 1;
1124 }
1125
1126 line_end
1127}
1128
1129fn leading_ascii_whitespace_len(s: &str) -> usize {
1130 s.as_bytes()
1131 .iter()
1132 .take_while(|byte| matches!(byte, b' ' | b'\t'))
1133 .count()
1134}
1135
1136fn parse_callout_type(type_str: &str) -> CalloutType {
1138 match type_str.to_lowercase().as_str() {
1139 "note" => CalloutType::Note,
1140 "tip" => CalloutType::Tip,
1141 "info" => CalloutType::Info,
1142 "todo" => CalloutType::Todo,
1143 "important" => CalloutType::Important,
1144 "success" => CalloutType::Success,
1145 "question" => CalloutType::Question,
1146 "warning" => CalloutType::Warning,
1147 "failure" | "fail" | "missing" => CalloutType::Failure,
1148 "danger" | "error" => CalloutType::Danger,
1149 "bug" => CalloutType::Bug,
1150 "example" => CalloutType::Example,
1151 "quote" | "cite" => CalloutType::Quote,
1152 _ => CalloutType::Note,
1153 }
1154}
1155
1156#[cfg(test)]
1161mod tests {
1162 use super::*;
1163
1164 #[test]
1168 fn a_heading_list_entry_is_its_text_without_image_alt() {
1169 let headings = crate::parse_headings(
1170 "# Title \n\n## See [docs](u) here\n\n## \n\nSetext\nhead\n===\n",
1171 );
1172 let shown: Vec<_> = headings
1173 .iter()
1174 .map(|h| (h.text.as_str(), h.anchor.as_deref()))
1175 .collect();
1176 assert_eq!(
1177 shown,
1178 [
1179 ("Title", Some("title")),
1180 ("See docs here", Some("see-docs-here")),
1181 ("", None),
1182 ("Setext head", Some("setext-head")),
1183 ]
1184 );
1185 }
1186
1187 #[test]
1188 fn test_engine_wikilinks() {
1189 let content = "See [[Note]] and [[Other|display]]";
1190 let engine = ParseEngine::new(content);
1191 let result = engine.parse(&ParseOptions::all());
1192
1193 assert_eq!(result.wikilinks.len(), 2);
1194 assert_eq!(result.wikilinks[0].target, "Note");
1195 assert_eq!(result.wikilinks[1].target, "Other");
1196 assert_eq!(
1197 result.wikilinks[1].display_text,
1198 Some("display".to_string())
1199 );
1200 }
1201
1202 #[test]
1203 fn test_engine_table_escaped_pipe() {
1204 let content = "| Area | Links |\n|---|---|\n| MCP | [[turbomcp\\|TurboMCP]], [[dagent]] |\n| Art | ![[diagram.png\\|300]] |\n";
1207 let engine = ParseEngine::new(content);
1208 let result = engine.parse(&ParseOptions::all());
1209
1210 let wikilinks: Vec<_> = result
1211 .wikilinks
1212 .iter()
1213 .map(|link| (link.target.as_str(), link.display_text.as_deref()))
1214 .collect();
1215 assert_eq!(
1216 wikilinks,
1217 [("turbomcp", Some("TurboMCP")), ("dagent", None)]
1218 );
1219 assert_eq!(result.embeds.len(), 1);
1220 assert_eq!(result.embeds[0].target, "diagram.png");
1221 assert_eq!(result.embeds[0].display_text.as_deref(), Some("300"));
1222 }
1223
1224 #[test]
1225 fn test_engine_embeds_not_wikilinks() {
1226 let content = "[[Link]] and ![[Embed]]";
1227 let engine = ParseEngine::new(content);
1228 let result = engine.parse(&ParseOptions::all());
1229
1230 assert_eq!(result.wikilinks.len(), 1);
1231 assert_eq!(result.embeds.len(), 1);
1232 assert_eq!(result.wikilinks[0].target, "Link");
1233 assert_eq!(result.embeds[0].target, "Embed");
1234 }
1235
1236 #[test]
1237 fn test_engine_markdown_links() {
1238 let content = "[text](url) and ";
1239 let engine = ParseEngine::new(content);
1240 let result = engine.parse(&ParseOptions::all());
1241
1242 assert_eq!(result.markdown_links.len(), 1);
1244 assert_eq!(result.markdown_links[0].target, "url");
1245 }
1246
1247 #[test]
1248 fn test_engine_tags() {
1249 let content = "Has #tag and #nested/tag";
1250 let engine = ParseEngine::new(content);
1251 let result = engine.parse(&ParseOptions::all());
1252
1253 assert_eq!(result.tags.len(), 2);
1254 assert!(!result.tags[0].is_nested);
1255 assert!(result.tags[1].is_nested);
1256 }
1257
1258 #[test]
1259 fn test_engine_headings_via_pulldown() {
1260 let content = "# Heading 1\n\n## Heading 2\n\n### Heading 3";
1261 let engine = ParseEngine::new(content);
1262 let result = engine.parse(&ParseOptions::all());
1263
1264 assert_eq!(result.headings.len(), 3);
1265 assert_eq!(result.headings[0].level, 1);
1266 assert_eq!(result.headings[0].text, "Heading 1");
1267 assert_eq!(result.headings[1].level, 2);
1268 assert_eq!(result.headings[2].level, 3);
1269 }
1270
1271 #[test]
1274 fn test_engine_heading_with_inline_code() {
1275 let content = "# Use `foo()` carefully\n\nBody.";
1276 let engine = ParseEngine::new(content);
1277 let result = engine.parse(&ParseOptions::all());
1278
1279 assert_eq!(result.headings.len(), 1);
1280 assert_eq!(result.headings[0].text, "Use foo() carefully");
1281 }
1282
1283 #[test]
1284 fn test_engine_tasks_via_pulldown() {
1285 let content = "- [ ] Todo task\n- [x] Done task";
1286 let engine = ParseEngine::new(content);
1287 let result = engine.parse(&ParseOptions::all());
1288
1289 assert_eq!(result.tasks.len(), 2);
1290 assert!(!result.tasks[0].is_completed);
1291 assert_eq!(result.tasks[0].content, "Todo task");
1292 assert!(result.tasks[1].is_completed);
1293 assert_eq!(result.tasks[1].content, "Done task");
1294 }
1295
1296 #[test]
1297 fn test_engine_frontmatter() {
1298 let content = "---\ntitle: Test\nauthor: Alice\n---\n\n# Content";
1299 let engine = ParseEngine::new(content);
1300 let result = engine.parse(&ParseOptions::all());
1301
1302 assert!(result.frontmatter.is_some());
1303 let fm = result.frontmatter.unwrap();
1304 assert_eq!(fm.data.get("title").and_then(|v| v.as_str()), Some("Test"));
1305 assert_eq!(
1306 fm.data.get("author").and_then(|v| v.as_str()),
1307 Some("Alice")
1308 );
1309 }
1310
1311 #[test]
1312 fn test_engine_callout_simple() {
1313 let content = "> [!NOTE] This is a note\n> Content here";
1314 let engine = ParseEngine::new(content);
1315 let result = engine.parse(&ParseOptions::all());
1316
1317 assert_eq!(result.callouts.len(), 1);
1318 assert_eq!(result.callouts[0].type_, CalloutType::Note);
1319 assert_eq!(result.callouts[0].title, Some("This is a note".to_string()));
1320 }
1321
1322 #[test]
1323 fn test_engine_callout_full() {
1324 let content = "> [!WARNING] Title\n> Line 1\n> Line 2";
1325 let engine = ParseEngine::new(content);
1326 let result = engine.parse(&ParseOptions::all().with_full_callouts());
1327
1328 assert_eq!(result.callouts.len(), 1);
1329 assert_eq!(result.callouts[0].content, "Line 1\nLine 2");
1330 }
1331
1332 #[test]
1333 fn test_full_callout_crlf_offsets_next_callout() {
1334 let content = "> [!NOTE] First\r\n> Line 1\r\n> [!WARNING] Second\r\n> Line 2";
1335 let engine = ParseEngine::new(content);
1336 let result = engine.parse(&ParseOptions::all().with_full_callouts());
1337
1338 assert_eq!(result.callouts.len(), 2);
1339 assert_eq!(result.callouts[0].content, "Line 1");
1340 assert_eq!(result.callouts[1].title, Some("Second".to_string()));
1341 assert_eq!(result.callouts[1].content, "Line 2");
1342 assert_eq!(result.callouts[1].position.line, 3);
1343 assert_eq!(result.callouts[1].position.column, 1);
1344 }
1345
1346 #[test]
1351 fn test_code_block_excludes_wikilinks() {
1352 let content = r#"
1353Normal [[Valid Link]] here.
1354
1355```rust
1356// Code block
1357let link = "[[Fake Link Inside Code]]";
1358```
1359
1360Also [[Another Valid]]
1361"#;
1362 let engine = ParseEngine::new(content);
1363 let result = engine.parse(&ParseOptions::all());
1364
1365 assert_eq!(result.wikilinks.len(), 2);
1367 assert_eq!(result.wikilinks[0].target, "Valid Link");
1368 assert_eq!(result.wikilinks[1].target, "Another Valid");
1369 }
1370
1371 #[test]
1372 fn test_code_block_excludes_embeds() {
1373 let content = r#"
1374![[Valid Embed]]
1375
1376```
1377![[Fake Embed In Code]]
1378```
1379"#;
1380 let engine = ParseEngine::new(content);
1381 let result = engine.parse(&ParseOptions::all());
1382
1383 assert_eq!(result.embeds.len(), 1);
1384 assert_eq!(result.embeds[0].target, "Valid Embed");
1385 }
1386
1387 #[test]
1388 fn test_code_block_excludes_tags() {
1389 let content = r##"
1390Real #tag here.
1391
1392```python
1393# This is a comment, not a tag
1394x = "#notag"
1395```
1396
1397Another #valid-tag
1398"##;
1399 let engine = ParseEngine::new(content);
1400 let result = engine.parse(&ParseOptions::all());
1401
1402 assert_eq!(result.tags.len(), 2);
1403 assert_eq!(result.tags[0].name, "tag");
1404 assert_eq!(result.tags[1].name, "valid-tag");
1405 }
1406
1407 #[test]
1408 fn test_inline_code_excludes_patterns() {
1409 let content = "See [[Valid]] and `[[Not A Link]]` inline";
1410 let engine = ParseEngine::new(content);
1411 let result = engine.parse(&ParseOptions::all());
1412
1413 assert_eq!(result.wikilinks.len(), 1);
1414 assert_eq!(result.wikilinks[0].target, "Valid");
1415 }
1416
1417 #[test]
1418 fn test_indented_code_block_excludes() {
1419 let content = r#"
1420Normal [[Link]]
1421
1422 // Indented code block
1423 [[Not A Link]]
1424
1425Back to normal [[Valid]]
1426"#;
1427 let engine = ParseEngine::new(content);
1428 let result = engine.parse(&ParseOptions::all());
1429
1430 assert_eq!(result.wikilinks.len(), 2);
1432 }
1433
1434 #[test]
1435 fn test_multiple_code_blocks() {
1436 let content = r#"
1437[[Link1]]
1438
1439```
1440[[Fake1]]
1441```
1442
1443[[Link2]]
1444
1445```python
1446[[Fake2]]
1447```
1448
1449[[Link3]]
1450"#;
1451 let engine = ParseEngine::new(content);
1452 let result = engine.parse(&ParseOptions::all());
1453
1454 assert_eq!(result.wikilinks.len(), 3);
1455 assert_eq!(result.wikilinks[0].target, "Link1");
1456 assert_eq!(result.wikilinks[1].target, "Link2");
1457 assert_eq!(result.wikilinks[2].target, "Link3");
1458 }
1459
1460 #[test]
1461 fn test_position_tracking() {
1462 let content = "Line 1\n[[Link]] on line 2";
1463 let engine = ParseEngine::new(content);
1464 let result = engine.parse(&ParseOptions::all());
1465
1466 assert_eq!(result.wikilinks[0].position.line, 2);
1467 assert_eq!(result.wikilinks[0].position.column, 1);
1468 assert_eq!(result.wikilinks[0].position.offset, 7);
1469 }
1470
1471 #[test]
1472 fn test_selective_parsing() {
1473 let content = "# Heading\n[[Link]] #tag";
1474 let engine = ParseEngine::new(content);
1475
1476 let opts = ParseOptions {
1477 parse_wikilinks: true,
1478 parse_headings: false,
1479 parse_tags: false,
1480 ..ParseOptions::none()
1481 };
1482 let result = engine.parse(&opts);
1483
1484 assert_eq!(result.wikilinks.len(), 1);
1485 assert!(result.headings.is_empty());
1486 assert!(result.tags.is_empty());
1487 }
1488
1489 #[test]
1490 fn test_fast_path_empty() {
1491 let content = "Plain text without any OFM elements";
1492 let engine = ParseEngine::new(content);
1493 let result = engine.parse(&ParseOptions::all());
1494
1495 assert!(result.wikilinks.is_empty());
1496 assert!(result.embeds.is_empty());
1497 assert!(result.markdown_links.is_empty());
1498 assert!(result.tags.is_empty());
1499 assert!(result.headings.is_empty());
1500 assert!(result.tasks.is_empty());
1501 assert!(result.callouts.is_empty());
1502 }
1503
1504 #[test]
1505 fn test_excluded_ranges_optimization() {
1506 let mut excluded = ExcludedRanges::default();
1507 excluded.add(0..10);
1508 excluded.add(5..15); excluded.add(20..30);
1510 excluded.add(25..35); excluded.optimize();
1513
1514 assert_eq!(excluded.ranges.len(), 2);
1516 assert_eq!(excluded.ranges[0], 0..15);
1517 assert_eq!(excluded.ranges[1], 20..35);
1518 }
1519
1520 #[test]
1525 fn test_wikilink_block_ref() {
1526 let content = "See [[Note#^blockid]] for reference";
1527 let engine = ParseEngine::new(content);
1528 let result = engine.parse(&ParseOptions::all());
1529
1530 assert_eq!(result.wikilinks.len(), 1);
1531 assert_eq!(result.wikilinks[0].target, "Note#^blockid");
1532 assert_eq!(result.wikilinks[0].type_, LinkType::BlockRef);
1533 }
1534
1535 #[test]
1536 fn test_wikilink_heading_ref() {
1537 let content = "See [[Note#Heading]] for details";
1538 let engine = ParseEngine::new(content);
1539 let result = engine.parse(&ParseOptions::all());
1540
1541 assert_eq!(result.wikilinks.len(), 1);
1542 assert_eq!(result.wikilinks[0].target, "Note#Heading");
1543 assert_eq!(result.wikilinks[0].type_, LinkType::HeadingRef);
1544 }
1545
1546 #[test]
1547 fn test_wikilink_same_doc_anchor() {
1548 let content = "See [[#Heading]] in this document";
1549 let engine = ParseEngine::new(content);
1550 let result = engine.parse(&ParseOptions::all());
1551
1552 assert_eq!(result.wikilinks.len(), 1);
1553 assert_eq!(result.wikilinks[0].target, "#Heading");
1554 assert_eq!(result.wikilinks[0].type_, LinkType::Anchor);
1555 }
1556
1557 #[test]
1558 fn test_wikilink_same_doc_block_ref() {
1559 let content = "See [[#^blockid]] in this document";
1560 let engine = ParseEngine::new(content);
1561 let result = engine.parse(&ParseOptions::all());
1562
1563 assert_eq!(result.wikilinks.len(), 1);
1564 assert_eq!(result.wikilinks[0].target, "#^blockid");
1565 assert_eq!(result.wikilinks[0].type_, LinkType::BlockRef);
1566 }
1567
1568 #[test]
1569 fn test_markdown_link_anchor() {
1570 let content = "Jump to [section](#installation)";
1571 let engine = ParseEngine::new(content);
1572 let result = engine.parse(&ParseOptions::all());
1573
1574 assert_eq!(result.markdown_links.len(), 1);
1575 assert_eq!(result.markdown_links[0].target, "#installation");
1576 assert_eq!(result.markdown_links[0].type_, LinkType::Anchor);
1577 }
1578
1579 #[test]
1580 fn test_markdown_link_heading_ref() {
1581 let content = "See [API](docs/api.md#methods) reference";
1582 let engine = ParseEngine::new(content);
1583 let result = engine.parse(&ParseOptions::all());
1584
1585 assert_eq!(result.markdown_links.len(), 1);
1586 assert_eq!(result.markdown_links[0].target, "docs/api.md#methods");
1587 assert_eq!(result.markdown_links[0].type_, LinkType::HeadingRef);
1588 }
1589
1590 #[test]
1591 fn test_markdown_link_block_ref() {
1592 let content = "See [block](note.md#^abc123) reference";
1593 let engine = ParseEngine::new(content);
1594 let result = engine.parse(&ParseOptions::all());
1595
1596 assert_eq!(result.markdown_links.len(), 1);
1597 assert_eq!(result.markdown_links[0].target, "note.md#^abc123");
1598 assert_eq!(result.markdown_links[0].type_, LinkType::BlockRef);
1599 }
1600
1601 #[test]
1602 fn test_markdown_link_external() {
1603 let content = "Visit [site](https://example.com)";
1604 let engine = ParseEngine::new(content);
1605 let result = engine.parse(&ParseOptions::all());
1606
1607 assert_eq!(result.markdown_links.len(), 1);
1608 assert_eq!(result.markdown_links[0].type_, LinkType::ExternalLink);
1609 }
1610
1611 #[test]
1612 fn test_markdown_link_relative() {
1613 let content = "See [docs](./docs/api.md) for more";
1614 let engine = ParseEngine::new(content);
1615 let result = engine.parse(&ParseOptions::all());
1616
1617 assert_eq!(result.markdown_links.len(), 1);
1618 assert_eq!(result.markdown_links[0].type_, LinkType::MarkdownLink);
1619 }
1620
1621 #[test]
1622 fn test_heading_anchor_generation() {
1623 let content = "# BIG heading?! with Special @chars";
1626 let engine = ParseEngine::new(content);
1627 let result = engine.parse(&ParseOptions::all());
1628
1629 assert_eq!(result.headings.len(), 1);
1630 assert_eq!(
1632 result.headings[0].anchor,
1633 Some("big-heading-with-special-chars".to_string())
1634 );
1635 }
1636
1637 #[test]
1638 fn test_heading_anchor_consecutive_spaces() {
1639 let content = "# Multiple Spaces Here";
1640 let engine = ParseEngine::new(content);
1641 let result = engine.parse(&ParseOptions::all());
1642
1643 assert_eq!(result.headings.len(), 1);
1644 assert_eq!(
1646 result.headings[0].anchor,
1647 Some("multiple-spaces-here".to_string())
1648 );
1649 }
1650
1651 #[test]
1656 fn test_frontmatter_wikilink_single() {
1657 let content = "---\nArea: \"[[My Hub]]\"\n---\n\n# Content";
1658 let engine = ParseEngine::new(content);
1659 let result = engine.parse(&ParseOptions::all());
1660
1661 assert_eq!(result.wikilinks.len(), 1);
1662 assert_eq!(result.wikilinks[0].target, "My Hub");
1663 }
1664
1665 #[test]
1666 fn test_frontmatter_wikilink_list() {
1667 let content =
1668 "---\nLinks:\n - \"[[Doc A]]\"\n - \"[[Doc B]]\"\n - \"[[Doc C]]\"\n---\n\nBody";
1669 let engine = ParseEngine::new(content);
1670 let result = engine.parse(&ParseOptions::all());
1671
1672 assert_eq!(result.wikilinks.len(), 3);
1673 assert_eq!(result.wikilinks[0].target, "Doc A");
1674 assert_eq!(result.wikilinks[1].target, "Doc B");
1675 assert_eq!(result.wikilinks[2].target, "Doc C");
1676 }
1677
1678 #[test]
1679 fn test_frontmatter_wikilink_with_display_text() {
1680 let content = "---\nArea: \"[[Hub|My Display Text]]\"\n---\n\nBody";
1681 let engine = ParseEngine::new(content);
1682 let result = engine.parse(&ParseOptions::all());
1683
1684 assert_eq!(result.wikilinks.len(), 1);
1685 assert_eq!(result.wikilinks[0].target, "Hub");
1686 assert_eq!(
1687 result.wikilinks[0].display_text,
1688 Some("My Display Text".to_string())
1689 );
1690 }
1691
1692 #[test]
1693 fn test_frontmatter_wikilink_with_heading_ref() {
1694 let content = "---\nLinks:\n - \"[[Note#Section]]\"\n---\n\nBody";
1695 let engine = ParseEngine::new(content);
1696 let result = engine.parse(&ParseOptions::all());
1697
1698 assert_eq!(result.wikilinks.len(), 1);
1699 assert_eq!(result.wikilinks[0].target, "Note#Section");
1700 assert_eq!(result.wikilinks[0].type_, LinkType::HeadingRef);
1701 }
1702
1703 #[test]
1704 fn test_frontmatter_embed() {
1705 let content = "---\nBanner: \"![[image.png]]\"\n---\n\nBody";
1706 let engine = ParseEngine::new(content);
1707 let result = engine.parse(&ParseOptions::all());
1708
1709 assert_eq!(result.embeds.len(), 1);
1710 assert_eq!(result.embeds[0].target, "image.png");
1711 assert_eq!(result.embeds[0].type_, LinkType::Embed);
1712 assert!(result.wikilinks.is_empty());
1714 }
1715
1716 #[test]
1717 fn test_frontmatter_and_body_wikilinks_combined() {
1718 let content = "---\nArea: \"[[Hub]]\"\n---\n\nSee [[Body Link]] here";
1719 let engine = ParseEngine::new(content);
1720 let result = engine.parse(&ParseOptions::all());
1721
1722 assert_eq!(result.wikilinks.len(), 2);
1723 assert_eq!(result.wikilinks[0].target, "Hub");
1724 assert_eq!(result.wikilinks[1].target, "Body Link");
1725 }
1726
1727 #[test]
1728 fn test_frontmatter_wikilinks_not_extracted_when_disabled() {
1729 let content = "---\nArea: \"[[Hub]]\"\n---\n\nBody";
1730 let engine = ParseEngine::new(content);
1731 let opts = ParseOptions {
1732 parse_wikilinks: false,
1733 ..ParseOptions::none()
1734 };
1735 let result = engine.parse(&opts);
1736
1737 assert!(result.wikilinks.is_empty());
1738 }
1739
1740 #[test]
1741 fn test_frontmatter_markdown_links() {
1742 let content = "---\nsource: \"[Paper](papers/paper.md)\"\nrelated:\n - \"[Spaced](<My Note.md>)\"\n - \"[Site](https://example.com)\"\ncover: \"\"\n---\n\nBody [Later](later.md)";
1743 let engine = ParseEngine::new(content);
1744 let result = engine.parse(&ParseOptions::all());
1745
1746 let links: Vec<_> = result
1747 .markdown_links
1748 .iter()
1749 .map(|link| {
1750 (
1751 link.type_,
1752 link.target.as_str(),
1753 link.display_text.as_deref(),
1754 )
1755 })
1756 .collect();
1757 assert_eq!(
1758 links,
1759 [
1760 (LinkType::MarkdownLink, "papers/paper.md", Some("Paper")),
1761 (LinkType::MarkdownLink, "My Note.md", Some("Spaced")),
1762 (LinkType::ExternalLink, "https://example.com", Some("Site")),
1763 (LinkType::MarkdownLink, "later.md", Some("Later")),
1764 ]
1765 );
1766 let span = |link: &Link| &content[link.position.offset..][..link.position.length];
1768 assert_eq!(span(&result.markdown_links[0]), "[Paper](papers/paper.md)");
1769 assert_eq!(span(&result.markdown_links[1]), "[Spaced](<My Note.md>)");
1770 }
1771
1772 #[test]
1773 fn test_reference_links() {
1774 let content = "See [Full][paper], [paper][], [Paper] and [Spaced][s].\n\n> [quoted]\n\n[[Paper]] and ![[Paper]] are wikilinks.\n\n[PAPER]: papers/paper.md \"Title\"\n[s]:\n <My Note.md>\n> [quoted]: Quoted%20Note.md\n[unused]: unused.md\n";
1775 let engine = ParseEngine::new(content);
1776 let result = engine.parse(&ParseOptions::all());
1777
1778 let span = |link: &Link| &content[link.position.offset..][..link.position.length];
1779 let links: Vec<_> = result
1780 .markdown_links
1781 .iter()
1782 .map(|link| (span(link), link.target.as_str()))
1783 .collect();
1784 assert_eq!(
1785 links,
1786 [
1787 ("[Full][paper]", "papers/paper.md"),
1788 ("[paper][]", "papers/paper.md"),
1789 ("[Paper]", "papers/paper.md"),
1790 ("[Spaced][s]", "My Note.md"),
1791 ("[quoted]", "Quoted Note.md"),
1792 ]
1793 );
1794 assert_eq!(result.wikilinks.len(), 1);
1796 assert_eq!(result.embeds.len(), 1);
1797
1798 let definitions = reference_definitions(content);
1800 let found: Vec<_> = definitions
1801 .iter()
1802 .map(|d| {
1803 (
1804 d.label.as_str(),
1805 d.target.as_str(),
1806 &content[d.destination.clone()],
1807 )
1808 })
1809 .collect();
1810 assert_eq!(
1811 found,
1812 [
1813 ("PAPER", "papers/paper.md", "papers/paper.md"),
1814 ("s", "My Note.md", "<My Note.md>"),
1815 ("quoted", "Quoted Note.md", "Quoted%20Note.md"),
1816 ("unused", "unused.md", "unused.md"),
1817 ]
1818 );
1819 }
1820
1821 #[test]
1822 fn test_reference_definitions_skip_code_and_duplicates() {
1823 let content = "```\n[a]: code.md\n```\n\n[b]: first.md\n[B]: second.md\n";
1824 let targets: Vec<_> = reference_definitions(content)
1825 .into_iter()
1826 .map(|d| d.target)
1827 .collect();
1828 assert_eq!(targets, ["first.md"]);
1829 }
1830
1831 #[test]
1832 fn test_frontmatter_markdown_links_not_extracted_when_disabled() {
1833 let content = "---\nsource: \"[Paper](paper.md)\"\n---\n\nBody";
1834 let engine = ParseEngine::new(content);
1835 let opts = ParseOptions {
1836 parse_markdown_links: false,
1837 ..ParseOptions::all()
1838 };
1839 let result = engine.parse(&opts);
1840
1841 assert!(result.markdown_links.is_empty());
1842 }
1843
1844 #[test]
1845 fn test_frontmatter_wikilink_unicode() {
1846 let content = "---\nArea: \"[[My Project Hub]]\"\n---\n\nBody";
1847 let engine = ParseEngine::new(content);
1848 let result = engine.parse(&ParseOptions::all());
1849
1850 assert_eq!(result.wikilinks.len(), 1);
1851 assert_eq!(result.wikilinks[0].target, "My Project Hub");
1852 }
1853
1854 #[test]
1855 fn test_frontmatter_no_wikilinks() {
1856 let content = "---\ntitle: Just a string\ntags:\n - rust\n---\n\nBody";
1857 let engine = ParseEngine::new(content);
1858 let result = engine.parse(&ParseOptions::all());
1859
1860 assert!(result.wikilinks.is_empty());
1861 }
1862
1863 #[test]
1864 fn test_frontmatter_multiple_fields_with_wikilinks() {
1865 let content = "---\nArea: \"[[Hub]]\"\nLayer: \"[[Security Layer]]\"\nLinks:\n - \"[[Doc A]]\"\n---\n\nBody";
1866 let engine = ParseEngine::new(content);
1867 let result = engine.parse(&ParseOptions::all());
1868
1869 assert_eq!(result.wikilinks.len(), 3);
1870 let targets: Vec<&str> = result.wikilinks.iter().map(|l| l.target.as_str()).collect();
1871 assert!(targets.contains(&"Hub"));
1872 assert!(targets.contains(&"Security Layer"));
1873 assert!(targets.contains(&"Doc A"));
1874 }
1875
1876 #[test]
1877 fn test_no_frontmatter_no_crash() {
1878 let content = "No frontmatter here\n\n[[Link]]";
1879 let engine = ParseEngine::new(content);
1880 let result = engine.parse(&ParseOptions::all());
1881
1882 assert_eq!(result.wikilinks.len(), 1);
1883 assert_eq!(result.wikilinks[0].target, "Link");
1884 }
1885
1886 #[test]
1891 fn test_digit_first_tag() {
1892 let content = "#2024 is a year";
1895 let engine = ParseEngine::new(content);
1896 let result = engine.parse(&ParseOptions::all());
1897
1898 let names: Vec<&str> = result.tags.iter().map(|t| t.name.as_str()).collect();
1899 assert!(
1900 names.contains(&"2024"),
1901 "expected tag '2024' in {:?}",
1902 names
1903 );
1904 }
1905
1906 #[test]
1907 fn test_numeric_tag_with_subtag() {
1908 let content = "#2024/q1";
1909 let engine = ParseEngine::new(content);
1910 let result = engine.parse(&ParseOptions::all());
1911
1912 let names: Vec<&str> = result.tags.iter().map(|t| t.name.as_str()).collect();
1913 assert!(
1914 names.contains(&"2024/q1"),
1915 "expected tag '2024/q1' in {:?}",
1916 names
1917 );
1918 }
1919
1920 #[test]
1921 fn test_tag_in_url_not_matched() {
1922 let content = "See https://example.com#section for details";
1925 let engine = ParseEngine::new(content);
1926 let result = engine.parse(&ParseOptions::all());
1927
1928 let names: Vec<&str> = result.tags.iter().map(|t| t.name.as_str()).collect();
1929 assert!(
1930 !names.contains(&"section"),
1931 "tag 'section' should NOT be extracted from a URL fragment, got {:?}",
1932 names
1933 );
1934 }
1935
1936 #[test]
1937 fn test_tag_in_markdown_anchor_link_not_matched() {
1938 let content = "Jump to [section](#installation) and keep #real-tag";
1939 let engine = ParseEngine::new(content);
1940 let result = engine.parse(&ParseOptions::all());
1941
1942 assert_eq!(result.markdown_links.len(), 1);
1943 let names: Vec<&str> = result.tags.iter().map(|t| t.name.as_str()).collect();
1944 assert_eq!(names, vec!["real-tag"]);
1945 }
1946
1947 #[test]
1948 fn test_tag_only_parse_excludes_markdown_anchor_link() {
1949 let content = "Jump to [section](#installation) and keep #real-tag";
1950 let engine = ParseEngine::new(content);
1951 let opts = ParseOptions {
1952 parse_tags: true,
1953 ..ParseOptions::none()
1954 };
1955 let result = engine.parse(&opts);
1956
1957 let names: Vec<&str> = result.tags.iter().map(|t| t.name.as_str()).collect();
1958 assert_eq!(names, vec!["real-tag"]);
1959 }
1960
1961 #[test]
1962 fn test_tag_in_same_doc_wikilink_not_matched() {
1963 let content = "See [[#Heading]] and ![[#Preview]] but keep #real";
1964 let engine = ParseEngine::new(content);
1965 let result = engine.parse(&ParseOptions::all());
1966
1967 assert_eq!(result.wikilinks.len(), 1);
1968 assert_eq!(result.embeds.len(), 1);
1969 let names: Vec<&str> = result.tags.iter().map(|t| t.name.as_str()).collect();
1970 assert_eq!(names, vec!["real"]);
1971 }
1972
1973 #[test]
1974 fn test_tag_only_parse_excludes_same_doc_wikilink() {
1975 let content = "See [[#Heading]] and ![[#Preview]] but keep #real";
1976 let engine = ParseEngine::new(content);
1977 let opts = ParseOptions {
1978 parse_tags: true,
1979 ..ParseOptions::none()
1980 };
1981 let result = engine.parse(&opts);
1982
1983 let names: Vec<&str> = result.tags.iter().map(|t| t.name.as_str()).collect();
1984 assert_eq!(names, vec!["real"]);
1985 }
1986
1987 #[test]
1988 fn test_parenthesized_tag_still_matches() {
1989 let content = "This remains valid (#context)";
1990 let engine = ParseEngine::new(content);
1991 let result = engine.parse(&ParseOptions::all());
1992
1993 let names: Vec<&str> = result.tags.iter().map(|t| t.name.as_str()).collect();
1994 assert_eq!(names, vec!["context"]);
1995 }
1996
1997 #[test]
1998 fn test_task_status_in_progress() {
1999 let content = "- [/] In progress task";
2000 let engine = ParseEngine::new(content);
2001 let result = engine.parse(&ParseOptions::all());
2002
2003 assert_eq!(result.tasks.len(), 1);
2004 assert_eq!(result.tasks[0].content, "In progress task");
2005 assert!(!result.tasks[0].is_completed);
2006 assert_eq!(result.tasks[0].position.line, 1);
2007 assert_eq!(result.tasks[0].position.column, 1);
2008 }
2009
2010 #[test]
2011 fn test_task_status_cancelled() {
2012 let content = "- [-] Cancelled task";
2013 let engine = ParseEngine::new(content);
2014 let result = engine.parse(&ParseOptions::all());
2015
2016 assert_eq!(result.tasks.len(), 1);
2017 assert_eq!(result.tasks[0].content, "Cancelled task");
2018 assert!(!result.tasks[0].is_completed);
2019 assert_eq!(result.tasks[0].position.line, 1);
2020 assert_eq!(result.tasks[0].position.column, 1);
2021 }
2022
2023 #[test]
2024 fn test_task_status_keeps_the_checkbox_character() {
2025 let content = "- [ ] todo\n- [x] done\n- [X] also done\n- [/] doing\n- [-] dropped";
2026 let engine = ParseEngine::new(content);
2027 let result = engine.parse(&ParseOptions::all());
2028
2029 let statuses: Vec<(char, &str)> = result
2030 .tasks
2031 .iter()
2032 .map(|task| (task.status, task.content.as_str()))
2033 .collect();
2034 assert_eq!(
2035 statuses,
2036 [
2037 (' ', "todo"),
2038 ('x', "done"),
2039 ('X', "also done"),
2040 ('/', "doing"),
2041 ('-', "dropped"),
2042 ]
2043 );
2044 }
2045
2046 #[test]
2047 fn test_extended_tasks_exclude_code_blocks() {
2048 let content = "```md\n- [/] Not a task\n```\n\n- [-] Real task";
2049 let engine = ParseEngine::new(content);
2050 let result = engine.parse(&ParseOptions::all());
2051
2052 assert_eq!(result.tasks.len(), 1);
2053 assert_eq!(result.tasks[0].content, "Real task");
2054 assert_eq!(result.tasks[0].position.line, 5);
2055 }
2056
2057 #[test]
2058 fn test_task_priority_parsing() {
2059 use turbovault_core::TaskPriority;
2060
2061 let content = "\
2062# Some header
2063
2064Some text here.
2065- [ ] A lowest priority task ⏬
2066- [ ] A low priority task 🔽
2067- [ ] A task with double priority ⏬ 🔺
2068- [ ] A normal priority task
2069- [ ] A medium priority task 🔼
2070- [ ] A high priority task ⏫
2071- [ ] A highest priority task 🔺
2072";
2073 let engine = ParseEngine::new(content);
2074 let result = engine.parse(&ParseOptions::all());
2075
2076 assert_eq!(result.tasks.len(), 7,);
2077
2078 assert_eq!(
2079 result.tasks[0].priority,
2080 TaskPriority::Lowest,
2081 "Should parse ⏬ as lowest priority marker"
2082 );
2083 assert_eq!(result.tasks[0].content, "A lowest priority task");
2084
2085 assert_eq!(
2086 result.tasks[1].priority,
2087 TaskPriority::Low,
2088 "Should parse 🔽 as low priority marker"
2089 );
2090 assert_eq!(result.tasks[1].content, "A low priority task");
2091
2092 assert_eq!(
2093 result.tasks[2].priority,
2094 TaskPriority::Normal,
2095 "Should parse double marker as normal priority"
2096 );
2097 assert_eq!(result.tasks[2].content, "A task with double priority ⏬ 🔺");
2098
2099 assert_eq!(
2100 result.tasks[3].priority,
2101 TaskPriority::Normal,
2102 "Should parse no marker as normal priority"
2103 );
2104
2105 assert_eq!(
2106 result.tasks[4].priority,
2107 TaskPriority::Medium,
2108 "Should parse 🔼 marker as medium priority"
2109 );
2110
2111 assert_eq!(
2112 result.tasks[5].priority,
2113 TaskPriority::High,
2114 "Should parse ⏫ marker as high priority"
2115 );
2116
2117 assert_eq!(
2118 result.tasks[6].priority,
2119 TaskPriority::Highest,
2120 "Should parse 🔺 marker as highest priority"
2121 );
2122 }
2123
2124 #[test]
2125 fn test_task_metadata_parsing() {
2126 let content = "- [ ] Review PR [due:: 2026-05-01], [project:: [[Team Work]]], [id:: pr-123], [dependsOn:: abc123,def456] 🔁 every weekday #review ^pr-123";
2127 let engine = ParseEngine::new(content);
2128 let result = engine.parse(&ParseOptions::all());
2129
2130 assert_eq!(result.tasks.len(), 1);
2131 assert_eq!(result.tasks[0].content, "Review PR");
2132 assert_eq!(
2133 result.tasks[0]
2134 .due_date
2135 .map(|date| date.to_string())
2136 .as_deref(),
2137 Some("2026-05-01")
2138 );
2139 assert_eq!(result.tasks[0].recurrence.as_deref(), Some("every weekday"));
2140 assert_eq!(result.tasks[0].id.as_deref(), Some("pr-123"));
2141 assert_eq!(
2142 result.tasks[0].depends_on,
2143 vec!["abc123".to_string(), "def456".to_string()]
2144 );
2145 assert_eq!(result.tasks[0].tags, vec!["review".to_string()]);
2146 assert_eq!(result.tasks[0].block_ref.as_deref(), Some("pr-123"));
2147 assert_eq!(
2148 result.tasks[0].metadata.get("project").map(String::as_str),
2149 Some("[[Team Work]]")
2150 );
2151 }
2152}