Skip to main content

projectatlas_symbols/
markdown.rs

1//! Bounded parser facts for Markdown and the Markdown subset of MDX.
2
3use crate::check_parser_iteration;
4use projectatlas_core::symbols::{
5    CodeSymbol, ParserKind, SymbolGraph, SymbolKind, SymbolSourceSelector,
6};
7use projectatlas_core::{IndexWorkControl, IndexWorkFailure, IndexWorkStage};
8use pulldown_cmark::{Event, HeadingLevel, Options, Parser, Tag, TagEnd};
9use std::collections::BTreeMap;
10use std::convert::Infallible;
11use std::ops::Range;
12
13/// Maximum UTF-8 bytes admitted to one Markdown parse.
14pub const MAX_MARKDOWN_BYTES: usize = 2_000_000;
15/// Maximum headings retained from one Markdown document.
16pub const MAX_MARKDOWN_HEADINGS: usize = 512;
17/// Maximum explicit document-reference candidates retained from one document.
18pub const MAX_DOCUMENT_LINK_CANDIDATES: usize = 1_024;
19/// Maximum UTF-8 bytes retained for one heading or link label.
20pub const MAX_MARKDOWN_LABEL_BYTES: usize = 240;
21/// Maximum UTF-8 bytes retained for one repository-relative selector.
22pub const MAX_DOCUMENT_SELECTOR_BYTES: usize = 512;
23/// Maximum aggregate UTF-8 evidence bytes retained from one document.
24pub const MAX_MARKDOWN_EVIDENCE_BYTES: usize = 262_144;
25
26/// Parser implementation that emitted Markdown facts.
27#[derive(Clone, Copy, Debug, Eq, PartialEq)]
28pub enum MarkdownParserProvenance {
29    /// The workspace-pinned `pulldown-cmark` parser.
30    PulldownCmark,
31}
32
33/// Completeness of one bounded Markdown extraction.
34#[derive(Clone, Copy, Debug, Eq, PartialEq)]
35pub enum MarkdownFactCompleteness {
36    /// All supported syntax was examined without reaching a fact limit.
37    Complete,
38    /// A hard limit or explicitly unsupported structure prevented complete coverage.
39    Partial,
40}
41
42/// Hard bound reached while extracting Markdown facts.
43#[derive(Clone, Copy, Debug, Eq, Ord, PartialEq, PartialOrd)]
44pub enum MarkdownFactLimit {
45    /// The document exceeded the admitted parser byte ceiling.
46    InputBytes,
47    /// The retained heading count reached its ceiling.
48    HeadingCount,
49    /// The retained explicit-reference count reached its ceiling.
50    CandidateCount,
51    /// A compact label exceeded its per-label byte ceiling.
52    LabelBytes,
53    /// A repository-relative selector exceeded its byte ceiling.
54    SelectorBytes,
55    /// Aggregate retained evidence reached its per-document ceiling.
56    EvidenceBytes,
57}
58
59/// Unsupported Markdown/MDX structure observed by the parser.
60#[derive(Clone, Copy, Debug, Eq, Ord, PartialEq, PartialOrd)]
61pub enum MarkdownUnsupportedStructure {
62    /// An image target was present; images never document source identities.
63    Image,
64    /// Raw HTML or JSX-like structure was present and was not interpreted as Markdown.
65    RawHtmlOrMdx,
66    /// A parser destination was dynamic or templated rather than a static identity.
67    DynamicDestination,
68}
69
70/// Exact source selector for one parser fact.
71#[derive(Clone, Debug, Eq, PartialEq)]
72pub struct MarkdownSourceSelector {
73    /// Inclusive UTF-8 byte offset.
74    pub byte_start: usize,
75    /// Exclusive UTF-8 byte offset.
76    pub byte_end: usize,
77    /// Inclusive one-based start line.
78    pub line_start: usize,
79    /// Zero-based Unicode-scalar start column.
80    pub column_start: usize,
81    /// Inclusive one-based end line.
82    pub line_end: usize,
83    /// Exclusive zero-based Unicode-scalar end column.
84    pub column_end: usize,
85}
86
87/// One bounded Markdown heading fact.
88#[derive(Clone, Debug, Eq, PartialEq)]
89pub struct MarkdownHeadingFact {
90    /// Heading depth from one through six.
91    pub level: u8,
92    /// Compact bounded heading label.
93    pub text: String,
94    /// Deterministic lowercase selector slug.
95    pub slug: String,
96    /// One-based occurrence among headings with the same slug.
97    pub occurrence: usize,
98    /// Exact source range for this heading.
99    pub source: MarkdownSourceSelector,
100}
101
102/// Syntax that supplied one explicit document-reference candidate.
103#[derive(Clone, Copy, Debug, Eq, PartialEq)]
104pub enum DocumentLinkSource {
105    /// A destination emitted by the Markdown parser, including resolved reference links.
106    MarkdownDestination,
107    /// A complete inline-code span containing only one repository-relative selector.
108    InlineCode,
109}
110
111/// One explicit static repository-local reference awaiting filesystem resolution.
112#[derive(Clone, Debug, Eq, PartialEq)]
113pub struct DocumentLinkCandidate {
114    /// Parser syntax that supplied the candidate.
115    pub source_kind: DocumentLinkSource,
116    /// Bounded repository-relative selector, including any query or fragment evidence.
117    pub selector: String,
118    /// Compact parser-visible label when the syntax supplies one.
119    pub label: Option<String>,
120    /// Stable enclosing heading selector, or the document file when absent.
121    pub enclosing_heading: Option<String>,
122    /// Exact source range for the complete link or code span.
123    pub source: MarkdownSourceSelector,
124}
125
126/// Coverage state for one Markdown extraction.
127#[derive(Clone, Debug, Eq, PartialEq)]
128pub struct MarkdownFactCoverage {
129    /// Whether supported structure was completely examined.
130    pub completeness: MarkdownFactCompleteness,
131    /// Deduplicated hard limits reached during extraction.
132    pub limits: Vec<MarkdownFactLimit>,
133    /// Deduplicated unsupported structure observed during extraction.
134    pub unsupported: Vec<MarkdownUnsupportedStructure>,
135}
136
137/// Bounded parser facts derived from one Markdown or MDX document.
138#[derive(Clone, Debug, Eq, PartialEq)]
139pub struct MarkdownFacts {
140    /// Parser provenance for every fact in this batch.
141    pub provenance: MarkdownParserProvenance,
142    /// Deterministic headings in source order.
143    pub headings: Vec<MarkdownHeadingFact>,
144    /// Explicit static local-reference candidates in source order.
145    pub link_candidates: Vec<DocumentLinkCandidate>,
146    /// Completeness, limit, and unsupported-structure state.
147    pub coverage: MarkdownFactCoverage,
148}
149
150impl MarkdownFacts {
151    /// Project the heading facts into the existing symbol graph contract.
152    #[must_use]
153    pub fn symbol_graph(&self, path: &str, language: Option<&str>) -> SymbolGraph {
154        let symbols = self
155            .headings
156            .iter()
157            .filter_map(|heading| Some((heading, crate::compact_symbol_identity(&heading.text)?)))
158            .map(|(heading, name)| CodeSymbol {
159                path: path.to_owned(),
160                language: language.map(str::to_owned),
161                name,
162                kind: SymbolKind::Heading,
163                signature: heading_signature(&heading.slug, heading.occurrence),
164                exported: false,
165                documentation: None,
166                line_start: heading.source.line_start,
167                line_end: heading.source.line_end,
168                source_selector: Some(SymbolSourceSelector {
169                    byte_start: heading.source.byte_start,
170                    byte_end: heading.source.byte_end,
171                    column_start: heading.source.column_start,
172                    column_end: heading.source.column_end,
173                }),
174                parent: None,
175                parser: ParserKind::Structural,
176                detail: Some(format!(
177                    "level={};slug={};occurrence={};bytes={}..{}",
178                    heading.level,
179                    heading.slug,
180                    heading.occurrence,
181                    heading.source.byte_start,
182                    heading.source.byte_end
183                )),
184            })
185            .collect();
186        SymbolGraph {
187            path: path.to_owned(),
188            language: language.map(str::to_owned),
189            parser: ParserKind::Structural,
190            symbols,
191            relations: Vec::new(),
192        }
193    }
194}
195
196/// Extract bounded headings and explicit local-reference candidates.
197#[must_use]
198pub fn extract_markdown_facts(content: &str) -> MarkdownFacts {
199    match extract_markdown_facts_checked(content, &mut || Ok::<(), Infallible>(())) {
200        Ok(facts) => facts,
201        Err(unreachable) => match unreachable {},
202    }
203}
204
205/// Extract Markdown facts while observing the shared indexing cancellation boundary.
206///
207/// # Errors
208///
209/// Returns a typed cancellation or deadline failure without returning partial work.
210pub fn extract_markdown_facts_controlled(
211    content: &str,
212    control: &IndexWorkControl,
213) -> Result<MarkdownFacts, IndexWorkFailure> {
214    extract_markdown_facts_checked(content, &mut || {
215        control.check(IndexWorkStage::SymbolParsing)
216    })
217}
218
219/// Extract Markdown facts while observing the caller's indexing checkpoint.
220pub(crate) fn extract_markdown_facts_checked<E>(
221    content: &str,
222    check: &mut impl FnMut() -> Result<(), E>,
223) -> Result<MarkdownFacts, E> {
224    check()?;
225    let mut extraction = MarkdownExtraction::new(content);
226    if content.len() > MAX_MARKDOWN_BYTES {
227        extraction.limit(MarkdownFactLimit::InputBytes);
228        return extraction.finish(check);
229    }
230
231    let parser = Parser::new_ext(content, Options::all()).into_offset_iter();
232    for (iteration, (event, range)) in parser.enumerate() {
233        check_parser_iteration(iteration, check)?;
234        extraction.consume(event, range);
235    }
236    check()?;
237    extraction.finish(check)
238}
239
240/// Mutable bounded state for one parser pass.
241struct MarkdownExtraction<'a> {
242    /// Original source used for exact range normalization.
243    content: &'a str,
244    /// Sorted byte offsets for one-based line lookup.
245    line_starts: Vec<usize>,
246    /// Retained heading facts in source order.
247    headings: Vec<MarkdownHeadingFact>,
248    /// Retained explicit references in source order.
249    link_candidates: Vec<DocumentLinkCandidate>,
250    /// Per-slug occurrence counters for duplicate heading identity.
251    slug_occurrences: BTreeMap<String, usize>,
252    /// Deduplicated limits reached during extraction.
253    limits: Vec<MarkdownFactLimit>,
254    /// Deduplicated unsupported structure observed during extraction.
255    unsupported: Vec<MarkdownUnsupportedStructure>,
256    /// Aggregate retained evidence bytes.
257    retained_evidence_bytes: usize,
258    /// Heading currently receiving inline parser text.
259    heading: Option<HeadingBuilder>,
260    /// Stable selector for the current enclosing heading section.
261    enclosing_heading: Option<String>,
262    /// Accepted link currently receiving parser-visible label text.
263    link: Option<LinkBuilder>,
264    /// Current link nesting depth, including rejected links.
265    link_depth: usize,
266    /// Current image nesting depth used to suppress alt-text code candidates.
267    image_depth: usize,
268}
269
270impl<'a> MarkdownExtraction<'a> {
271    /// Initialize bounded state and exact line offsets.
272    fn new(content: &'a str) -> Self {
273        let mut line_starts = vec![0];
274        line_starts.extend(
275            content
276                .bytes()
277                .enumerate()
278                .filter_map(|(index, byte)| (byte == b'\n').then_some(index + 1)),
279        );
280        Self {
281            content,
282            line_starts,
283            headings: Vec::new(),
284            link_candidates: Vec::new(),
285            slug_occurrences: BTreeMap::new(),
286            limits: Vec::new(),
287            unsupported: Vec::new(),
288            retained_evidence_bytes: 0,
289            heading: None,
290            enclosing_heading: None,
291            link: None,
292            link_depth: 0,
293            image_depth: 0,
294        }
295    }
296
297    /// Consume one parser event and its exact source range.
298    fn consume(&mut self, event: Event<'_>, range: Range<usize>) {
299        match event {
300            Event::Start(Tag::Heading { level, .. }) => {
301                self.enclosing_heading = None;
302                self.heading = Some(HeadingBuilder::new(level, range.start));
303            }
304            Event::End(TagEnd::Heading(_)) => self.finish_heading(range.end),
305            Event::Start(Tag::Link { dest_url, .. }) => {
306                self.link_depth = self.link_depth.saturating_add(1);
307                self.link = if dest_url.len() > MAX_DOCUMENT_SELECTOR_BYTES {
308                    self.limit(MarkdownFactLimit::SelectorBytes);
309                    None
310                } else {
311                    admit_selector(dest_url.as_ref(), DocumentLinkSource::MarkdownDestination)
312                        .map(|selector| LinkBuilder::new(selector, range.start))
313                };
314                if looks_dynamic(dest_url.as_ref()) {
315                    self.unsupported(MarkdownUnsupportedStructure::DynamicDestination);
316                }
317            }
318            Event::End(TagEnd::Link) => {
319                self.finish_link(range.end);
320                self.link_depth = self.link_depth.saturating_sub(1);
321            }
322            Event::Start(Tag::Image { .. }) => {
323                self.image_depth = self.image_depth.saturating_add(1);
324                self.unsupported(MarkdownUnsupportedStructure::Image);
325            }
326            Event::End(TagEnd::Image) => {
327                self.image_depth = self.image_depth.saturating_sub(1);
328            }
329            Event::Text(text) => {
330                if let Some(heading) = self.heading.as_mut() {
331                    heading.label.push(text.as_ref());
332                }
333                if let Some(link) = self.link.as_mut() {
334                    link.label.push(text.as_ref());
335                }
336            }
337            Event::Code(code) => {
338                if let Some(heading) = self.heading.as_mut() {
339                    heading.label.push(code.as_ref());
340                }
341                if let Some(link) = self.link.as_mut() {
342                    link.label.push(code.as_ref());
343                }
344                if self.link_depth == 0 && self.image_depth == 0 {
345                    self.push_code_candidate(code.as_ref(), range);
346                }
347            }
348            Event::Html(_) | Event::InlineHtml(_) => {
349                self.unsupported(MarkdownUnsupportedStructure::RawHtmlOrMdx);
350            }
351            _ => {}
352        }
353    }
354
355    /// Complete and retain the active heading when within bounds.
356    fn finish_heading(&mut self, byte_end: usize) {
357        let Some(heading) = self.heading.take() else {
358            return;
359        };
360        if heading.label.truncated {
361            self.limit(MarkdownFactLimit::LabelBytes);
362        }
363        let text = heading.label.finish();
364        if text.is_empty() {
365            return;
366        }
367        if self.headings.len() >= MAX_MARKDOWN_HEADINGS {
368            self.limit(MarkdownFactLimit::HeadingCount);
369            return;
370        }
371        let slug = heading_slug(&text);
372        let evidence_bytes = text.len().saturating_add(slug.len());
373        if !self.retain(evidence_bytes) {
374            return;
375        }
376        let occurrence = self.slug_occurrences.entry(slug.clone()).or_default();
377        *occurrence = occurrence.saturating_add(1);
378        let signature = heading_signature(&slug, *occurrence);
379        self.headings.push(MarkdownHeadingFact {
380            level: heading_level(heading.level),
381            text,
382            slug,
383            occurrence: *occurrence,
384            source: self.source_selector(heading.byte_start, byte_end),
385        });
386        self.enclosing_heading = Some(signature);
387    }
388
389    /// Complete and retain the active parser-emitted link when within bounds.
390    fn finish_link(&mut self, byte_end: usize) {
391        let Some(link) = self.link.take() else {
392            return;
393        };
394        if link.label.truncated {
395            self.limit(MarkdownFactLimit::LabelBytes);
396        }
397        let label = link.label.finish();
398        self.push_candidate(
399            DocumentLinkSource::MarkdownDestination,
400            link.selector,
401            (!label.is_empty()).then_some(label),
402            link.byte_start,
403            byte_end,
404        );
405    }
406
407    /// Admit a complete inline-code span only when it is one path selector.
408    fn push_code_candidate(&mut self, code: &str, range: Range<usize>) {
409        if code != code.trim() {
410            return;
411        }
412        if code.len() > MAX_DOCUMENT_SELECTOR_BYTES {
413            self.limit(MarkdownFactLimit::SelectorBytes);
414            return;
415        }
416        let Some(selector) = admit_selector(code, DocumentLinkSource::InlineCode) else {
417            return;
418        };
419        self.push_candidate(
420            DocumentLinkSource::InlineCode,
421            selector,
422            None,
423            range.start,
424            range.end,
425        );
426    }
427
428    /// Retain one already-admitted explicit candidate within count and byte bounds.
429    fn push_candidate(
430        &mut self,
431        source_kind: DocumentLinkSource,
432        selector: String,
433        label: Option<String>,
434        byte_start: usize,
435        byte_end: usize,
436    ) {
437        if selector.len() > MAX_DOCUMENT_SELECTOR_BYTES {
438            self.limit(MarkdownFactLimit::SelectorBytes);
439            return;
440        }
441        if self.link_candidates.len() >= MAX_DOCUMENT_LINK_CANDIDATES {
442            self.limit(MarkdownFactLimit::CandidateCount);
443            return;
444        }
445        let evidence_bytes = selector
446            .len()
447            .saturating_add(label.as_ref().map_or(0, String::len))
448            .saturating_add(self.enclosing_heading.as_ref().map_or(0, String::len));
449        if !self.retain(evidence_bytes) {
450            return;
451        }
452        self.link_candidates.push(DocumentLinkCandidate {
453            source_kind,
454            selector,
455            label,
456            enclosing_heading: self.enclosing_heading.clone(),
457            source: self.source_selector(byte_start, byte_end),
458        });
459    }
460
461    /// Reserve aggregate evidence bytes without exceeding the hard ceiling.
462    fn retain(&mut self, bytes: usize) -> bool {
463        let Some(next) = self.retained_evidence_bytes.checked_add(bytes) else {
464            self.limit(MarkdownFactLimit::EvidenceBytes);
465            return false;
466        };
467        if next > MAX_MARKDOWN_EVIDENCE_BYTES {
468            self.limit(MarkdownFactLimit::EvidenceBytes);
469            return false;
470        }
471        self.retained_evidence_bytes = next;
472        true
473    }
474
475    /// Convert parser byte offsets into the public exact source selector.
476    fn source_selector(&self, byte_start: usize, byte_end: usize) -> MarkdownSourceSelector {
477        let byte_end = self.trim_trailing_line_endings(byte_start, byte_end);
478        MarkdownSourceSelector {
479            byte_start,
480            byte_end,
481            line_start: self.line_at(byte_start),
482            column_start: 0,
483            line_end: self.line_at(byte_end.saturating_sub(1).max(byte_start)),
484            column_end: 0,
485        }
486    }
487
488    /// Remove parser-owned trailing line endings from one heading or link range.
489    fn trim_trailing_line_endings(&self, byte_start: usize, mut byte_end: usize) -> usize {
490        while byte_end > byte_start
491            && self
492                .content
493                .as_bytes()
494                .get(byte_end - 1)
495                .is_some_and(|byte| matches!(byte, b'\r' | b'\n'))
496        {
497            byte_end -= 1;
498        }
499        byte_end
500    }
501
502    /// Return the one-based line containing a byte offset.
503    fn line_at(&self, byte: usize) -> usize {
504        self.line_starts
505            .partition_point(|start| *start <= byte)
506            .max(1)
507    }
508
509    /// Record one reached limit once in stable enum order.
510    fn limit(&mut self, limit: MarkdownFactLimit) {
511        insert_sorted_unique(&mut self.limits, limit);
512    }
513
514    /// Record one unsupported structure once in stable enum order.
515    fn unsupported(&mut self, unsupported: MarkdownUnsupportedStructure) {
516        insert_sorted_unique(&mut self.unsupported, unsupported);
517    }
518
519    /// Finalize immutable public facts and derive completeness.
520    fn finish<E>(mut self, check: &mut impl FnMut() -> Result<(), E>) -> Result<MarkdownFacts, E> {
521        let mut offsets = Vec::with_capacity(
522            self.headings
523                .len()
524                .saturating_add(self.link_candidates.len())
525                .saturating_mul(2),
526        );
527        for source in self.headings.iter().map(|heading| &heading.source).chain(
528            self.link_candidates
529                .iter()
530                .map(|candidate| &candidate.source),
531        ) {
532            offsets.push(source.byte_start);
533            offsets.push(source.byte_end);
534        }
535        let positions = crate::source_positions::source_positions(self.content, offsets, check)?;
536        for source in self
537            .headings
538            .iter_mut()
539            .map(|heading| &mut heading.source)
540            .chain(
541                self.link_candidates
542                    .iter_mut()
543                    .map(|candidate| &mut candidate.source),
544            )
545        {
546            apply_source_positions(source, &positions);
547        }
548        let completeness = if self.limits.is_empty() && self.unsupported.is_empty() {
549            MarkdownFactCompleteness::Complete
550        } else {
551            MarkdownFactCompleteness::Partial
552        };
553        Ok(MarkdownFacts {
554            provenance: MarkdownParserProvenance::PulldownCmark,
555            headings: self.headings,
556            link_candidates: self.link_candidates,
557            coverage: MarkdownFactCoverage {
558                completeness,
559                limits: self.limits,
560                unsupported: self.unsupported,
561            },
562        })
563    }
564}
565
566/// Apply exact positions for one parser source selector.
567fn apply_source_positions(
568    source: &mut MarkdownSourceSelector,
569    positions: &[(usize, usize, usize)],
570) {
571    if let Ok(index) = positions.binary_search_by_key(&source.byte_start, |position| position.0) {
572        source.line_start = positions[index].1;
573        source.column_start = positions[index].2;
574    }
575    if let Ok(index) = positions.binary_search_by_key(&source.byte_end, |position| position.0) {
576        source.line_end = positions[index].1;
577        source.column_end = positions[index].2;
578    }
579}
580
581/// Active heading assembled from bounded inline text events.
582struct HeadingBuilder {
583    /// Parser heading depth.
584    level: HeadingLevel,
585    /// Inclusive source start byte.
586    byte_start: usize,
587    /// Bounded compact label accumulator.
588    label: BoundedLabel,
589}
590
591impl HeadingBuilder {
592    /// Start one heading at its parser source range.
593    fn new(level: HeadingLevel, byte_start: usize) -> Self {
594        Self {
595            level,
596            byte_start,
597            label: BoundedLabel::default(),
598        }
599    }
600}
601
602/// Active accepted Markdown link assembled from parser events.
603struct LinkBuilder {
604    /// Static repository-relative selector.
605    selector: String,
606    /// Inclusive source start byte.
607    byte_start: usize,
608    /// Bounded compact visible label.
609    label: BoundedLabel,
610}
611
612impl LinkBuilder {
613    /// Start one accepted link at its parser source range.
614    fn new(selector: String, byte_start: usize) -> Self {
615        Self {
616            selector,
617            byte_start,
618            label: BoundedLabel::default(),
619        }
620    }
621}
622
623/// UTF-8-safe compact text accumulator with a hard byte ceiling.
624#[derive(Default)]
625struct BoundedLabel {
626    /// Retained compact text.
627    text: String,
628    /// Whether the next retained scalar needs one separating space.
629    pending_space: bool,
630    /// Whether at least one scalar could not be retained.
631    truncated: bool,
632}
633
634impl BoundedLabel {
635    /// Append one parser text fragment while compacting whitespace.
636    fn push(&mut self, value: &str) {
637        for character in value.chars() {
638            if character.is_whitespace() {
639                self.pending_space = !self.text.is_empty();
640                continue;
641            }
642            let separator_bytes = usize::from(self.pending_space && !self.text.is_empty());
643            if self
644                .text
645                .len()
646                .saturating_add(separator_bytes)
647                .saturating_add(character.len_utf8())
648                > MAX_MARKDOWN_LABEL_BYTES
649            {
650                self.truncated = true;
651                continue;
652            }
653            if separator_bytes == 1 {
654                self.text.push(' ');
655            }
656            self.pending_space = false;
657            self.text.push(character);
658        }
659    }
660
661    /// Return the bounded compact text.
662    fn finish(self) -> String {
663        self.text
664    }
665}
666
667/// Validate one parser or inline-code selector without filesystem guessing.
668fn admit_selector(value: &str, source: DocumentLinkSource) -> Option<String> {
669    if value.is_empty()
670        || value.starts_with(['/', '\\', '#', '?'])
671        || value.ends_with('/')
672        || value.contains(['\\', '\0', '\r', '\n'])
673        || value.contains("//")
674        || looks_dynamic(value)
675        || (source == DocumentLinkSource::InlineCode && value.chars().any(char::is_whitespace))
676    {
677        return None;
678    }
679    let path_end = value.find(['?', '#']).unwrap_or(value.len());
680    let path = &value[..path_end];
681    if path.is_empty() || path.ends_with(['/', '\\']) {
682        return None;
683    }
684    let first_segment = path.split('/').next().unwrap_or_default();
685    if first_segment.contains(':') {
686        return None;
687    }
688    let identity = strip_line_selector(path);
689    let final_segment = identity.rsplit('/').next().unwrap_or_default();
690    if final_segment.is_empty() || matches!(final_segment, "." | "..") {
691        return None;
692    }
693    let path_like = source == DocumentLinkSource::MarkdownDestination
694        || identity.contains('/')
695        || final_segment.starts_with('.')
696        || final_segment
697            .rsplit_once('.')
698            .is_some_and(|(stem, extension)| !stem.is_empty() && !extension.is_empty());
699    path_like.then(|| value.to_owned())
700}
701
702/// Remove an optional supported line selector when testing file-shaped syntax.
703fn strip_line_selector(path: &str) -> &str {
704    let Some((identity, selector)) = path.rsplit_once(':') else {
705        return path;
706    };
707    let selector = selector.strip_prefix('L').unwrap_or(selector);
708    let line_selector = selector.split_once('-').map_or_else(
709        || !selector.is_empty() && selector.chars().all(|character| character.is_ascii_digit()),
710        |(start, end)| {
711            !start.is_empty()
712                && !end.is_empty()
713                && start.chars().all(|character| character.is_ascii_digit())
714                && end
715                    .strip_prefix('L')
716                    .unwrap_or(end)
717                    .chars()
718                    .all(|character| character.is_ascii_digit())
719        },
720    );
721    if line_selector { identity } else { path }
722}
723
724/// Return whether a selector contains dynamic or templated syntax.
725fn looks_dynamic(value: &str) -> bool {
726    value.contains(['{', '}', '<', '>', '|', '*']) || value.contains('$')
727}
728
729/// Convert the parser heading enum into the stable numeric depth.
730fn heading_level(level: HeadingLevel) -> u8 {
731    match level {
732        HeadingLevel::H1 => 1,
733        HeadingLevel::H2 => 2,
734        HeadingLevel::H3 => 3,
735        HeadingLevel::H4 => 4,
736        HeadingLevel::H5 => 5,
737        HeadingLevel::H6 => 6,
738    }
739}
740
741/// Build the stable selector shared by heading symbols and enclosing link facts.
742fn heading_signature(slug: &str, occurrence: usize) -> String {
743    if occurrence == 1 {
744        slug.to_owned()
745    } else {
746        format!("{slug}-{}", occurrence - 1)
747    }
748}
749
750/// Build a deterministic bounded Unicode-aware heading slug.
751fn heading_slug(text: &str) -> String {
752    let mut slug = String::new();
753    let mut separator = false;
754    for character in text.chars() {
755        if character.is_alphanumeric() || character == '_' {
756            if separator && !slug.is_empty() {
757                slug.push('-');
758            }
759            separator = false;
760            slug.extend(character.to_lowercase());
761        } else if character.is_whitespace() || character == '-' {
762            separator = !slug.is_empty();
763        }
764    }
765    if slug.is_empty() {
766        "section".to_owned()
767    } else {
768        slug
769    }
770}
771
772/// Insert one enum state once while retaining its declaration order.
773fn insert_sorted_unique<T: Ord>(values: &mut Vec<T>, value: T) {
774    if let Err(index) = values.binary_search(&value) {
775        values.insert(index, value);
776    }
777}
778
779#[cfg(test)]
780mod tests {
781    use super::*;
782
783    #[test]
784    fn markdown_parser_handles_malformed_wikilinks_without_panicking() {
785        for content in [" \u{fffd} ![[] ]()]]", "- [o]:u\n \t"] {
786            let facts = extract_markdown_facts(content);
787            assert_eq!(facts.provenance, MarkdownParserProvenance::PulldownCmark);
788            assert!(facts.headings.len() <= MAX_MARKDOWN_HEADINGS);
789            assert!(facts.link_candidates.len() <= MAX_DOCUMENT_LINK_CANDIDATES);
790            for capability in projectatlas_core::language::LANGUAGE_CAPABILITIES
791                .iter()
792                .filter(|capability| {
793                    capability.symbol_parser
794                        == projectatlas_core::language::SymbolParserOwner::Markdown
795                })
796            {
797                for language in
798                    std::iter::once(capability.id).chain(capability.aliases.iter().copied())
799                {
800                    for path in [capability.fixtures.positive_path, "fixture.mdx"] {
801                        let graph = crate::extract_symbol_graph(path, Some(language), content);
802                        assert_eq!(graph.path, path);
803                    }
804                }
805            }
806        }
807    }
808
809    #[test]
810    fn markdown_parser_preserves_linear_wikilink_occurrences() {
811        let source = "[[a|]] ".repeat(16);
812        let facts = extract_markdown_facts(&source);
813        assert_eq!(facts.link_candidates.len(), 16);
814        assert!(facts.coverage.limits.is_empty());
815        for (index, candidate) in facts.link_candidates.iter().enumerate() {
816            assert_eq!(candidate.selector, "a");
817            assert_eq!(candidate.source.byte_start, index * 7);
818            assert_eq!(
819                &source[candidate.source.byte_start..candidate.source.byte_end],
820                "[[a|]]"
821            );
822        }
823    }
824
825    #[test]
826    fn extracts_exact_unicode_headings_and_static_candidates() {
827        let content = "# Über `Atlas`\n\nÜber Atlas\n----------\n\né [readme](README)\n\n[core][core]\n\n[core]: ../src/lib.rs#entry\n\nUse `src/lib.rs:12-20`.\n";
828        let facts = extract_markdown_facts(content);
829
830        assert_eq!(facts.provenance, MarkdownParserProvenance::PulldownCmark);
831        assert_eq!(
832            facts.coverage.completeness,
833            MarkdownFactCompleteness::Complete
834        );
835        assert_eq!(facts.headings.len(), 2);
836        assert_eq!(facts.headings[0].text, "Über Atlas");
837        assert_eq!(facts.headings[0].slug, "über-atlas");
838        assert_eq!(facts.headings[0].occurrence, 1);
839        assert_eq!(facts.headings[0].source.line_start, 1);
840        assert_eq!(facts.headings[0].source.column_start, 0);
841        assert_eq!(facts.headings[0].source.line_end, 1);
842        assert_eq!(facts.headings[0].source.column_end, 14);
843        assert_eq!(
844            &content[facts.headings[0].source.byte_start..facts.headings[0].source.byte_end],
845            "# Über `Atlas`"
846        );
847        assert_eq!(facts.headings[1].slug, "über-atlas");
848        assert_eq!(facts.headings[1].occurrence, 2);
849        assert_eq!(facts.headings[1].source.line_start, 3);
850        assert_eq!(facts.headings[1].source.column_start, 0);
851        assert_eq!(facts.headings[1].source.line_end, 4);
852        assert_eq!(facts.headings[1].source.column_end, 10);
853        assert_eq!(
854            facts
855                .link_candidates
856                .iter()
857                .map(|candidate| (candidate.source_kind, candidate.selector.as_str()))
858                .collect::<Vec<_>>(),
859            vec![
860                (DocumentLinkSource::MarkdownDestination, "README"),
861                (
862                    DocumentLinkSource::MarkdownDestination,
863                    "../src/lib.rs#entry"
864                ),
865                (DocumentLinkSource::InlineCode, "src/lib.rs:12-20"),
866            ]
867        );
868        assert_eq!(facts.link_candidates[0].source.line_start, 6);
869        assert_eq!(facts.link_candidates[0].source.column_start, 2);
870        assert_eq!(facts.link_candidates[0].source.line_end, 6);
871        assert_eq!(facts.link_candidates[0].source.column_end, 18);
872        assert!(
873            facts
874                .link_candidates
875                .iter()
876                .all(|candidate| candidate.enclosing_heading.as_deref() == Some("über-atlas-1"))
877        );
878
879        let graph = facts.symbol_graph("docs/guide.md", Some("markdown"));
880        assert_eq!(graph.parser, ParserKind::Structural);
881        assert_eq!(graph.symbols.len(), 2);
882        assert_eq!(graph.symbols[0].kind, SymbolKind::Heading);
883        assert_eq!(graph.symbols[0].line_start, 1);
884        assert_eq!(
885            graph.symbols[0].source_selector,
886            Some(SymbolSourceSelector {
887                byte_start: 0,
888                byte_end: 15,
889                column_start: 0,
890                column_end: 14,
891            })
892        );
893        assert_eq!(
894            graph
895                .symbols
896                .iter()
897                .map(|symbol| symbol.signature.as_str())
898                .collect::<Vec<_>>(),
899            vec!["über-atlas", "über-atlas-1"]
900        );
901        assert!(
902            graph.symbols[0]
903                .detail
904                .as_deref()
905                .is_some_and(|detail| detail.contains("slug=über-atlas;occurrence=1;bytes="))
906        );
907    }
908
909    #[test]
910    fn symbol_graph_reserves_the_derived_scope_namespace() {
911        let content = format!(
912            "# {}literal\n\n# Visible\n",
913            projectatlas_core::graph::QUALIFIED_SYMBOL_SCOPE_PREFIX
914        );
915        let facts = extract_markdown_facts(&content);
916        let graph = facts.symbol_graph("README.md", Some("markdown"));
917
918        assert_eq!(facts.headings.len(), 2);
919        assert_eq!(
920            graph
921                .symbols
922                .iter()
923                .map(|symbol| symbol.name.as_str())
924                .collect::<Vec<_>>(),
925            ["Visible"]
926        );
927    }
928
929    #[test]
930    fn rejects_non_local_non_static_and_false_positive_candidates() {
931        let content = r"
932![image](assets/logo.png)
933[external](https://example.test/x)
934[absolute](/src/lib.rs)
935[drive](C:/src/lib.rs)
936[unc](//server/share/lib.rs)
937[dynamic]({target})
938[templated](docs/$name.md)
939[fragment](#entry)
940[directory](../src/)
941`foo()` `cargo test` `README` `../` `https://example.test/x` `src/lib.rs and prose`
942
943```md
944# fenced heading
945`src/fenced.rs`
946```
947
948<section>
949# raw HTML heading
950</section>
951";
952        let facts = extract_markdown_facts(content);
953
954        assert!(facts.headings.is_empty());
955        assert!(facts.link_candidates.is_empty());
956        assert_eq!(
957            facts.coverage.completeness,
958            MarkdownFactCompleteness::Partial
959        );
960        assert_eq!(
961            facts.coverage.unsupported,
962            vec![
963                MarkdownUnsupportedStructure::Image,
964                MarkdownUnsupportedStructure::RawHtmlOrMdx,
965                MarkdownUnsupportedStructure::DynamicDestination,
966            ]
967        );
968    }
969
970    #[test]
971    fn keeps_supported_markdown_outside_mdx_structure() {
972        let content = "<Component source={target}>\n# Not a heading\n</Component>\n\n# Real heading\n\n[src](src/lib.rs)\n";
973        let facts = extract_markdown_facts(content);
974
975        assert_eq!(facts.headings.len(), 1);
976        assert_eq!(facts.headings[0].text, "Real heading");
977        assert_eq!(facts.link_candidates.len(), 1);
978        assert_eq!(facts.link_candidates[0].selector, "src/lib.rs");
979        assert_eq!(
980            facts.coverage.completeness,
981            MarkdownFactCompleteness::Partial
982        );
983        assert_eq!(
984            facts.coverage.unsupported,
985            vec![MarkdownUnsupportedStructure::RawHtmlOrMdx]
986        );
987    }
988
989    #[test]
990    fn exposes_hard_limits_without_unbounded_retention() {
991        use std::fmt::Write as _;
992
993        let oversized = "x".repeat(MAX_MARKDOWN_BYTES + 1);
994        let oversized_facts = extract_markdown_facts(&oversized);
995        assert!(oversized_facts.headings.is_empty());
996        assert_eq!(
997            oversized_facts.coverage.limits,
998            vec![MarkdownFactLimit::InputBytes]
999        );
1000
1001        let oversized_selector = format!(
1002            "[target](src/{}.rs)\n",
1003            "x".repeat(MAX_DOCUMENT_SELECTOR_BYTES)
1004        );
1005        let selector_facts = extract_markdown_facts(&oversized_selector);
1006        assert!(selector_facts.link_candidates.is_empty());
1007        assert!(
1008            selector_facts
1009                .coverage
1010                .limits
1011                .contains(&MarkdownFactLimit::SelectorBytes)
1012        );
1013
1014        let long_label = format!("# {}\n", "é".repeat(MAX_MARKDOWN_LABEL_BYTES));
1015        let label_facts = extract_markdown_facts(&long_label);
1016        assert_eq!(label_facts.headings.len(), 1);
1017        assert!(label_facts.headings[0].text.len() <= MAX_MARKDOWN_LABEL_BYTES);
1018        assert_eq!(
1019            label_facts.coverage.limits,
1020            vec![MarkdownFactLimit::LabelBytes]
1021        );
1022
1023        let mut many_headings = String::new();
1024        for index in 0..=MAX_MARKDOWN_HEADINGS {
1025            assert!(writeln!(many_headings, "# Heading {index}").is_ok());
1026        }
1027        let heading_facts = extract_markdown_facts(&many_headings);
1028        assert_eq!(heading_facts.headings.len(), MAX_MARKDOWN_HEADINGS);
1029        assert!(
1030            heading_facts
1031                .coverage
1032                .limits
1033                .contains(&MarkdownFactLimit::HeadingCount)
1034        );
1035
1036        let mut many_candidates = String::new();
1037        for index in 0..=MAX_DOCUMENT_LINK_CANDIDATES {
1038            assert!(writeln!(many_candidates, "[target](src/file_{index}.rs)").is_ok());
1039        }
1040        let candidate_facts = extract_markdown_facts(&many_candidates);
1041        assert_eq!(
1042            candidate_facts.link_candidates.len(),
1043            MAX_DOCUMENT_LINK_CANDIDATES
1044        );
1045        assert!(
1046            candidate_facts
1047                .coverage
1048                .limits
1049                .contains(&MarkdownFactLimit::CandidateCount)
1050        );
1051
1052        let mut evidence = MarkdownExtraction::new("");
1053        assert!(evidence.retain(MAX_MARKDOWN_EVIDENCE_BYTES));
1054        assert!(!evidence.retain(1));
1055        assert_eq!(evidence.limits, vec![MarkdownFactLimit::EvidenceBytes]);
1056    }
1057
1058    #[test]
1059    fn markdown_dispatch_is_additive_and_rst_remains_unsupported() {
1060        let markdown = crate::extract_symbol_graph(
1061            "docs/guide.mdx",
1062            Some("markdown"),
1063            "# Guide\n\n[src](src/lib.rs)\n",
1064        );
1065        assert_eq!(markdown.parser, ParserKind::Structural);
1066        assert_eq!(markdown.symbols.len(), 1);
1067        assert_eq!(markdown.symbols[0].kind, SymbolKind::Heading);
1068        assert!(markdown.relations.is_empty());
1069
1070        let rst = crate::extract_symbol_graph(
1071            "docs/guide.rst",
1072            Some("rst"),
1073            "Guide\n=====\n\n:doc:`src/lib.rs`\n",
1074        );
1075        assert_eq!(rst.parser, ParserKind::Fallback);
1076        assert!(rst.symbols.is_empty());
1077        assert!(rst.relations.is_empty());
1078    }
1079
1080    #[test]
1081    fn controlled_facts_preserve_results_and_propagate_cancellation() {
1082        use projectatlas_core::IndexCancellation;
1083
1084        let content = "# Guide\n\n[src](src/lib.rs)\n";
1085        let active = IndexWorkControl::new(IndexCancellation::new(), None);
1086        assert_eq!(
1087            extract_markdown_facts_controlled(content, &active),
1088            Ok(extract_markdown_facts(content))
1089        );
1090
1091        let cancellation = IndexCancellation::new();
1092        cancellation.cancel();
1093        let cancelled = IndexWorkControl::new(cancellation, None);
1094        assert_eq!(
1095            extract_markdown_facts_controlled(content, &cancelled),
1096            Err(IndexWorkFailure::Cancelled {
1097                stage: IndexWorkStage::SymbolParsing,
1098            })
1099        );
1100    }
1101}