Skip to main content

turbovault_parser/
engine.rs

1//! Unified parsing engine - single source of truth for OFM parsing.
2//!
3//! This module provides a consolidated parsing engine that:
4//! - Uses pulldown-cmark for CommonMark foundation (headings, links, tasks, code blocks)
5//! - Tracks code block/inline code ranges to exclude from OFM regex parsing
6//! - Uses regex only for Obsidian-specific syntax (wikilinks, embeds, tags, callouts)
7//! - Builds LineIndex once and reuses for all position calculations
8//!
9//! All public parsing APIs delegate to this engine internally.
10
11use 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
27// ============================================================================
28// Compiled regex patterns (LazyLock for Rust 1.80+ SOTA)
29// Only for Obsidian-specific syntax not handled by pulldown-cmark
30// ============================================================================
31
32/// Wikilink: [[target]] or [[target|display]]
33static WIKILINK: LazyLock<Regex> = LazyLock::new(|| Regex::new(r"\[\[([^\]]+)\]\]").unwrap());
34
35/// Embed: ![[target]] or ![[target|display]]
36static EMBED: LazyLock<Regex> = LazyLock::new(|| Regex::new(r"!\[\[([^\]]+)\]\]").unwrap());
37
38/// Tag: #tag or #parent/child (but not inside words or URLs)
39static TAG: LazyLock<Regex> =
40    LazyLock::new(|| Regex::new(r"(?:^|[\s\[(])#([a-zA-Z0-9_][a-zA-Z0-9_\-/]*)").unwrap());
41
42/// Callout start: > [!TYPE] with optional fold marker and title
43static CALLOUT: LazyLock<Regex> =
44    LazyLock::new(|| Regex::new(r"^\s*>\s*\[!(\w+)\]([+-]?)\s*(.*?)$").unwrap());
45
46/// Callout continuation: > content
47static CALLOUT_CONT: LazyLock<Regex> = LazyLock::new(|| Regex::new(r"^\s*>\s*(.*)$").unwrap());
48
49/// Non-CommonMark Obsidian task states not emitted by pulldown-cmark.
50static 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// ============================================================================
58// Fast pre-filters (skip regex if pattern not present)
59// ============================================================================
60
61#[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// ============================================================================
82// Excluded ranges (code blocks, inline code, etc.)
83// ============================================================================
84
85/// Byte ranges to exclude from OFM regex parsing.
86/// These are regions where markdown syntax should not be interpreted
87/// (code blocks, inline code, HTML blocks, etc.)
88#[derive(Debug, Default, Clone)]
89struct ExcludedRanges {
90    ranges: Vec<Range<usize>>,
91}
92
93impl ExcludedRanges {
94    /// Check if a byte offset falls within any excluded range.
95    #[inline]
96    fn contains(&self, offset: usize) -> bool {
97        // Optimized: Binary search for O(log N) lookup
98        if self.ranges.is_empty() {
99            return false;
100        }
101
102        // Find the first range that starts AFTER the offset.
103        // The candidate range that might contain 'offset' is the one immediately preceding that.
104        let idx = self.ranges.partition_point(|r| r.start <= offset);
105
106        if idx == 0 {
107            // All ranges start after offset, so none contain it
108            return false;
109        }
110
111        // Check the range before the partition point
112        // partition_point returns the index of the first element where the predicate is false
113        // Predicate: r.start <= offset.
114        // False means: r.start > offset.
115        // So ranges[idx-1].start <= offset.
116        let candidate = &self.ranges[idx - 1];
117        offset < candidate.end
118    }
119
120    /// Add a range to exclude.
121    fn add(&mut self, range: Range<usize>) {
122        self.ranges.push(range);
123    }
124
125    /// Sort and merge overlapping ranges for efficient lookup.
126    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                // Overlapping or adjacent, merge
138                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// ============================================================================
150// Parse result structure
151// ============================================================================
152
153/// Intermediate parse result used by the engine.
154#[derive(Debug, Clone, Default)]
155pub(crate) struct ParseResult {
156    pub frontmatter: Option<Frontmatter>,
157    pub frontmatter_end_offset: usize, // Offset where body starts (end of frontmatter)
158    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    /// Get all links combined.
169    #[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// ============================================================================
179// Reference definitions
180// ============================================================================
181
182/// A reference definition, `[label]: destination "title"`, which the
183/// reference links `[text][label]`, `[label][]` and `[label]` take their
184/// destination from.
185#[derive(Debug, Clone, PartialEq, Eq)]
186pub struct ReferenceDefinition {
187    /// The label, as written.
188    pub label: String,
189    /// The note or URL the destination names, decoded the way a
190    /// [`Link::target`] is, so a reference link's target equals it.
191    pub target: String,
192    /// Byte range of the destination as written, angle brackets included.
193    pub destination: Range<usize>,
194}
195
196/// The reference definitions in `content` that reference links can use, in
197/// document order: the first for each label (CommonMark ignores a later one
198/// with the same label), and none inside code or frontmatter.
199pub(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
218/// Where the destination of the definition whose `[` is at `start` begins:
219/// past `[label]:` and the spaces, line break and quote markers before it.
220fn 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
241/// A link that takes its destination from a reference definition.
242fn 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
254/// The CommonMark extensions the engine reads notes with.
255fn 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
263// ============================================================================
264// Core parsing engine
265// ============================================================================
266
267/// Unified parsing engine that handles all OFM element extraction.
268///
269/// Uses a two-phase approach:
270/// 1. pulldown-cmark pass: Extract CommonMark elements + code block ranges
271/// 2. Regex pass: Extract OFM-specific elements, skipping excluded ranges
272pub(crate) struct ParseEngine<'a> {
273    content: &'a str,
274    index: LineIndex,
275    source_file: Option<&'a Path>,
276}
277
278impl<'a> ParseEngine<'a> {
279    /// Create a new parse engine for the given content.
280    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    /// Create engine with source file context (for vault-aware parsing).
289    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    /// Parse content with the given options.
298    pub(crate) fn parse(&self, options: &ParseOptions) -> ParseResult {
299        let mut result = ParseResult::default();
300
301        // Phase 1: pulldown-cmark pass
302        // - Extract frontmatter, headings, markdown links, tasks
303        // - Build excluded ranges (code blocks, inline code)
304        let (excluded, body_start) = self.pulldown_pass(options, &mut result);
305
306        // Store the body start offset (which is the end of frontmatter)
307        result.frontmatter_end_offset = body_start;
308
309        // Phase 1.5: Extract wikilinks and embeds from frontmatter YAML values.
310        // Obsidian vaults commonly store wikilinks in frontmatter fields like
311        // Area: "[[Hub]]" and Links: ["[[Doc A]]", "[[Doc B]]"].
312        // These are invisible to the body-only regex pass, so we scan them here.
313        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        // Phase 2: OFM-specific regex pass (respecting excluded ranges)
323        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        // Callouts are line-based and need special handling
343        if options.parse_callouts {
344            self.parse_callouts(body, body_start, &excluded, options, &mut result);
345        }
346
347        result
348    }
349
350    /// Phase 1: pulldown-cmark pass for CommonMark elements and excluded ranges.
351    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        // State tracking
362        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                // === Code blocks (fenced and indented) ===
382                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                // === Inline code ===
392                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                // === HTML blocks ===
401                Event::Html(_) => {
402                    excluded.add(range.clone());
403                }
404
405                // === Metadata/Frontmatter ===
406                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                        // Parse YAML frontmatter
416                        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                // === Headings ===
431                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                        // Generate anchor if not provided. A heading with no text,
448                        // such as one that is only an image, has none, the same
449                        // as the block parser reports for it.
450                        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                // A heading's text is what it renders as, the label the block
468                // parser gives it too: an image's alt is not part of it, so
469                // `# Title ![a](a.png)` is `Title`, not `Title a` with an
470                // anchor of `title-a` (#78, which 2.2.0 fixed in the block
471                // parser only).
472                Event::Text(text)
473                    if current_heading.is_some() && markdown_image_range_start.is_none() =>
474                {
475                    heading_text.push_str(&text);
476                }
477                // A setext heading can span lines; its lines are one heading.
478                Event::SoftBreak | Event::HardBreak if current_heading.is_some() => {
479                    heading_text.push(' ');
480                }
481
482                // === Tasks ===
483                Event::TaskListMarker(_checked) if options.parse_tasks => {
484                    in_task_item = true;
485                    // Read raw marker byte to detect extended states [/] [-]
486                    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                // === Markdown Links ===
513                // `[[Note]]` is a wikilink, but when the note also defines
514                // `[note]: ...`, CommonMark reads the inner `[Note]` as a
515                // shortcut reference to it. That is the wikilink's own text,
516                // not a second link.
517                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                    // A collapsed reference, `[Note][]`, is reported without
535                    // its empty label; the link is all of it.
536                    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                // Markdown image alt text and destinations should not leak OFM tags.
574                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    /// Parse wikilinks, respecting excluded ranges.
592    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            // Skip if in excluded range (code block, inline code, etc.)
614            if excluded.contains(global_start) {
615                continue;
616            }
617
618            // Skip if preceded by ! (it's an embed)
619            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    /// Parse embeds, respecting excluded ranges.
644    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            // Skip if in excluded range
666            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    /// Extract wikilinks and embeds from YAML frontmatter string values.
690    ///
691    /// Obsidian vaults commonly store wikilinks in frontmatter fields:
692    /// ```yaml
693    /// Area: "[[My Project Hub]]"
694    /// Links:
695    ///   - "[[Doc A]]"
696    ///   - "[[Doc B]]"
697    /// ```
698    ///
699    /// The body parser skips frontmatter entirely, so these links would be
700    /// invisible to the link graph without this dedicated extraction pass.
701    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        // Extract wikilinks: [[target]] or [[target|display]]
712        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            // Skip if preceded by ! (it's an embed, handled below)
717            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        // Extract embeds: ![[target]]
742        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    /// Extract Markdown links from YAML frontmatter string values.
766    ///
767    /// Since Obsidian 1.11 a text or list property can hold a Markdown link,
768    /// `source: "[Paper](papers/paper.md)"`, and Obsidian treats it as a link
769    /// like any in the body. Each line is parsed on its own, without its
770    /// indentation: YAML nests with indentation that CommonMark would read
771    /// as a code block, and a property value never spans a link across lines.
772    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        // Frontmatter comes first in the note, so its links do too.
820        result.markdown_links.splice(0..0, links);
821    }
822
823    /// Parse tags, respecting excluded ranges.
824    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            // The actual tag starts at the # character
841            let tag_name = caps.get(1).unwrap();
842            let local_start = tag_name.start() - 1; // -1 for the # prefix
843            let global_start = body_offset + local_start;
844
845            // Skip if in excluded range
846            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, // +1 for #
858                ),
859                is_nested: name.contains('/'),
860            });
861        }
862    }
863
864    /// Parse Obsidian task states that pulldown-cmark's GFM task parser ignores.
865    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    /// Parse callouts (line-based, with excluded range awareness).
919    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            // Skip if this line is in an excluded range
943            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    /// Parse simple callout (header only).
972    fn parse_callout_simple(
973        &self,
974        line: &str,
975        global_offset: usize,
976        caps: &regex::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    /// Parse callout with full multi-line content.
996    #[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: &regex::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        // Collect continuation lines
1015        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            // Skip excluded ranges
1023            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            // Check if this is a new callout (stop)
1030            if CALLOUT.is_match(line) {
1031                break;
1032            }
1033
1034            // Check if continuation line
1035            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
1066// ============================================================================
1067// Helper functions
1068// ============================================================================
1069
1070/// Exclude Obsidian link spans from tag parsing.
1071///
1072/// Same-document anchors such as `[[#Heading]]` and embedded anchors such as
1073/// `![[#Preview]]` contain `#`, but they are links, not tags.
1074fn 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
1136/// Parse callout type string into enum.
1137fn 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// ============================================================================
1157// Tests
1158// ============================================================================
1159
1160#[cfg(test)]
1161mod tests {
1162    use super::*;
1163
1164    /// The heading list is what an outline prints and what its anchors are
1165    /// cut from, so it has to agree with the block parser's heading text:
1166    /// an image contributes nothing, a link its label (#78).
1167    #[test]
1168    fn a_heading_list_entry_is_its_text_without_image_alt() {
1169        let headings = crate::parse_headings(
1170            "# Title ![a](a.png)\n\n## See [docs](u) here\n\n## ![logo](l.png)\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        // Inside a table Obsidian escapes the alias pipe as `\|`; the
1205        // backslash is part of neither the target nor the display text.
1206        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 ![image](img.png)";
1239        let engine = ParseEngine::new(content);
1240        let result = engine.parse(&ParseOptions::all());
1241
1242        // pulldown-cmark handles this - images are Tag::Image, not Tag::Link
1243        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    // Regression test for PR #15: inline code inside a heading must appear in
1272    // the extracted heading text rather than being dropped.
1273    #[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    // =========================================================================
1347    // CODE BLOCK EXCLUSION TESTS - The main reason for pulldown-cmark
1348    // =========================================================================
1349
1350    #[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        // Should only find the 2 valid links, NOT the one inside the code block
1366        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        // Indented code blocks are also excluded
1431        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); // Overlapping
1509        excluded.add(20..30);
1510        excluded.add(25..35); // Overlapping
1511
1512        excluded.optimize();
1513
1514        // Should merge to [0..15, 20..35]
1515        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    // ============================================================================
1521    // Link type classification tests
1522    // ============================================================================
1523
1524    #[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        // Note: pulldown-cmark separates inline code, so `code` becomes separate event
1624        // The heading text captured is "BIG heading" + "code" + " ~+-!@#"
1625        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        // Should be lowercased, special chars removed, spaces to hyphens
1631        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        // Consecutive spaces should collapse to single hyphen
1645        assert_eq!(
1646            result.headings[0].anchor,
1647            Some("multiple-spaces-here".to_string())
1648        );
1649    }
1650
1651    // =========================================================================
1652    // FRONTMATTER WIKILINK EXTRACTION TESTS
1653    // =========================================================================
1654
1655    #[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        // The embed should NOT also appear as a wikilink
1713        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: \"![Cover](cover.png)\"\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        // Spans land on the link itself, so a rewrite can splice them.
1767        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        // `[[Paper]]` stays one wikilink although `[paper]` is defined.
1795        assert_eq!(result.wikilinks.len(), 1);
1796        assert_eq!(result.embeds.len(), 1);
1797
1798        // Each definition with the destination as written, ready to splice.
1799        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    // =========================================================================
1887    // Tag regex and TaskStatus integration tests
1888    // =========================================================================
1889
1890    #[test]
1891    fn test_digit_first_tag() {
1892        // Tags that start with a digit should be extracted; the regex allows [a-zA-Z0-9_]
1893        // as the first character of the tag name.
1894        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        // The # in a URL fragment is preceded by a non-whitespace word character,
1923        // so the look-behind `(?:^|[\s\[(])` should prevent a match.
1924        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}