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 Ok(extraction.finish());
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    Ok(extraction.finish())
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(mut self) -> MarkdownFacts {
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 = source_positions(self.content, offsets);
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        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/// Map sorted fact byte boundaries to exact source lines and Unicode-scalar columns in one pass.
567fn source_positions(content: &str, mut offsets: Vec<usize>) -> Vec<(usize, usize, usize)> {
568    offsets.sort_unstable();
569    offsets.dedup();
570    let mut positions = Vec::with_capacity(offsets.len());
571    let mut next_offset = 0;
572    let mut byte = 0;
573    let mut line = 1;
574    let mut column = 0;
575    for character in content.chars() {
576        while offsets
577            .get(next_offset)
578            .is_some_and(|offset| *offset == byte)
579        {
580            positions.push((byte, line, column));
581            next_offset += 1;
582        }
583        byte += character.len_utf8();
584        if character == '\n' {
585            line += 1;
586            column = 0;
587        } else {
588            column += 1;
589        }
590    }
591    while offsets
592        .get(next_offset)
593        .is_some_and(|offset| *offset == byte)
594    {
595        positions.push((byte, line, column));
596        next_offset += 1;
597    }
598    positions
599}
600
601/// Apply exact positions for one parser source selector.
602fn apply_source_positions(
603    source: &mut MarkdownSourceSelector,
604    positions: &[(usize, usize, usize)],
605) {
606    if let Ok(index) = positions.binary_search_by_key(&source.byte_start, |position| position.0) {
607        source.line_start = positions[index].1;
608        source.column_start = positions[index].2;
609    }
610    if let Ok(index) = positions.binary_search_by_key(&source.byte_end, |position| position.0) {
611        source.line_end = positions[index].1;
612        source.column_end = positions[index].2;
613    }
614}
615
616/// Active heading assembled from bounded inline text events.
617struct HeadingBuilder {
618    /// Parser heading depth.
619    level: HeadingLevel,
620    /// Inclusive source start byte.
621    byte_start: usize,
622    /// Bounded compact label accumulator.
623    label: BoundedLabel,
624}
625
626impl HeadingBuilder {
627    /// Start one heading at its parser source range.
628    fn new(level: HeadingLevel, byte_start: usize) -> Self {
629        Self {
630            level,
631            byte_start,
632            label: BoundedLabel::default(),
633        }
634    }
635}
636
637/// Active accepted Markdown link assembled from parser events.
638struct LinkBuilder {
639    /// Static repository-relative selector.
640    selector: String,
641    /// Inclusive source start byte.
642    byte_start: usize,
643    /// Bounded compact visible label.
644    label: BoundedLabel,
645}
646
647impl LinkBuilder {
648    /// Start one accepted link at its parser source range.
649    fn new(selector: String, byte_start: usize) -> Self {
650        Self {
651            selector,
652            byte_start,
653            label: BoundedLabel::default(),
654        }
655    }
656}
657
658/// UTF-8-safe compact text accumulator with a hard byte ceiling.
659#[derive(Default)]
660struct BoundedLabel {
661    /// Retained compact text.
662    text: String,
663    /// Whether the next retained scalar needs one separating space.
664    pending_space: bool,
665    /// Whether at least one scalar could not be retained.
666    truncated: bool,
667}
668
669impl BoundedLabel {
670    /// Append one parser text fragment while compacting whitespace.
671    fn push(&mut self, value: &str) {
672        for character in value.chars() {
673            if character.is_whitespace() {
674                self.pending_space = !self.text.is_empty();
675                continue;
676            }
677            let separator_bytes = usize::from(self.pending_space && !self.text.is_empty());
678            if self
679                .text
680                .len()
681                .saturating_add(separator_bytes)
682                .saturating_add(character.len_utf8())
683                > MAX_MARKDOWN_LABEL_BYTES
684            {
685                self.truncated = true;
686                continue;
687            }
688            if separator_bytes == 1 {
689                self.text.push(' ');
690            }
691            self.pending_space = false;
692            self.text.push(character);
693        }
694    }
695
696    /// Return the bounded compact text.
697    fn finish(self) -> String {
698        self.text
699    }
700}
701
702/// Validate one parser or inline-code selector without filesystem guessing.
703fn admit_selector(value: &str, source: DocumentLinkSource) -> Option<String> {
704    if value.is_empty()
705        || value.starts_with(['/', '\\', '#', '?'])
706        || value.ends_with('/')
707        || value.contains(['\\', '\0', '\r', '\n'])
708        || value.contains("//")
709        || looks_dynamic(value)
710        || (source == DocumentLinkSource::InlineCode && value.chars().any(char::is_whitespace))
711    {
712        return None;
713    }
714    let path_end = value.find(['?', '#']).unwrap_or(value.len());
715    let path = &value[..path_end];
716    if path.is_empty() || path.ends_with(['/', '\\']) {
717        return None;
718    }
719    let first_segment = path.split('/').next().unwrap_or_default();
720    if first_segment.contains(':') {
721        return None;
722    }
723    let identity = strip_line_selector(path);
724    let final_segment = identity.rsplit('/').next().unwrap_or_default();
725    if final_segment.is_empty() || matches!(final_segment, "." | "..") {
726        return None;
727    }
728    let path_like = source == DocumentLinkSource::MarkdownDestination
729        || identity.contains('/')
730        || final_segment.starts_with('.')
731        || final_segment
732            .rsplit_once('.')
733            .is_some_and(|(stem, extension)| !stem.is_empty() && !extension.is_empty());
734    path_like.then(|| value.to_owned())
735}
736
737/// Remove an optional supported line selector when testing file-shaped syntax.
738fn strip_line_selector(path: &str) -> &str {
739    let Some((identity, selector)) = path.rsplit_once(':') else {
740        return path;
741    };
742    let selector = selector.strip_prefix('L').unwrap_or(selector);
743    let line_selector = selector.split_once('-').map_or_else(
744        || !selector.is_empty() && selector.chars().all(|character| character.is_ascii_digit()),
745        |(start, end)| {
746            !start.is_empty()
747                && !end.is_empty()
748                && start.chars().all(|character| character.is_ascii_digit())
749                && end
750                    .strip_prefix('L')
751                    .unwrap_or(end)
752                    .chars()
753                    .all(|character| character.is_ascii_digit())
754        },
755    );
756    if line_selector { identity } else { path }
757}
758
759/// Return whether a selector contains dynamic or templated syntax.
760fn looks_dynamic(value: &str) -> bool {
761    value.contains(['{', '}', '<', '>', '|', '*']) || value.contains('$')
762}
763
764/// Convert the parser heading enum into the stable numeric depth.
765fn heading_level(level: HeadingLevel) -> u8 {
766    match level {
767        HeadingLevel::H1 => 1,
768        HeadingLevel::H2 => 2,
769        HeadingLevel::H3 => 3,
770        HeadingLevel::H4 => 4,
771        HeadingLevel::H5 => 5,
772        HeadingLevel::H6 => 6,
773    }
774}
775
776/// Build the stable selector shared by heading symbols and enclosing link facts.
777fn heading_signature(slug: &str, occurrence: usize) -> String {
778    if occurrence == 1 {
779        slug.to_owned()
780    } else {
781        format!("{slug}-{}", occurrence - 1)
782    }
783}
784
785/// Build a deterministic bounded Unicode-aware heading slug.
786fn heading_slug(text: &str) -> String {
787    let mut slug = String::new();
788    let mut separator = false;
789    for character in text.chars() {
790        if character.is_alphanumeric() || character == '_' {
791            if separator && !slug.is_empty() {
792                slug.push('-');
793            }
794            separator = false;
795            slug.extend(character.to_lowercase());
796        } else if character.is_whitespace() || character == '-' {
797            separator = !slug.is_empty();
798        }
799    }
800    if slug.is_empty() {
801        "section".to_owned()
802    } else {
803        slug
804    }
805}
806
807/// Insert one enum state once while retaining its declaration order.
808fn insert_sorted_unique<T: Ord>(values: &mut Vec<T>, value: T) {
809    if let Err(index) = values.binary_search(&value) {
810        values.insert(index, value);
811    }
812}
813
814#[cfg(test)]
815mod tests {
816    use super::*;
817
818    #[test]
819    fn extracts_exact_unicode_headings_and_static_candidates() {
820        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";
821        let facts = extract_markdown_facts(content);
822
823        assert_eq!(facts.provenance, MarkdownParserProvenance::PulldownCmark);
824        assert_eq!(
825            facts.coverage.completeness,
826            MarkdownFactCompleteness::Complete
827        );
828        assert_eq!(facts.headings.len(), 2);
829        assert_eq!(facts.headings[0].text, "Über Atlas");
830        assert_eq!(facts.headings[0].slug, "über-atlas");
831        assert_eq!(facts.headings[0].occurrence, 1);
832        assert_eq!(facts.headings[0].source.line_start, 1);
833        assert_eq!(facts.headings[0].source.column_start, 0);
834        assert_eq!(facts.headings[0].source.line_end, 1);
835        assert_eq!(facts.headings[0].source.column_end, 14);
836        assert_eq!(
837            &content[facts.headings[0].source.byte_start..facts.headings[0].source.byte_end],
838            "# Über `Atlas`"
839        );
840        assert_eq!(facts.headings[1].slug, "über-atlas");
841        assert_eq!(facts.headings[1].occurrence, 2);
842        assert_eq!(facts.headings[1].source.line_start, 3);
843        assert_eq!(facts.headings[1].source.column_start, 0);
844        assert_eq!(facts.headings[1].source.line_end, 4);
845        assert_eq!(facts.headings[1].source.column_end, 10);
846        assert_eq!(
847            facts
848                .link_candidates
849                .iter()
850                .map(|candidate| (candidate.source_kind, candidate.selector.as_str()))
851                .collect::<Vec<_>>(),
852            vec![
853                (DocumentLinkSource::MarkdownDestination, "README"),
854                (
855                    DocumentLinkSource::MarkdownDestination,
856                    "../src/lib.rs#entry"
857                ),
858                (DocumentLinkSource::InlineCode, "src/lib.rs:12-20"),
859            ]
860        );
861        assert_eq!(facts.link_candidates[0].source.line_start, 6);
862        assert_eq!(facts.link_candidates[0].source.column_start, 2);
863        assert_eq!(facts.link_candidates[0].source.line_end, 6);
864        assert_eq!(facts.link_candidates[0].source.column_end, 18);
865        assert!(
866            facts
867                .link_candidates
868                .iter()
869                .all(|candidate| candidate.enclosing_heading.as_deref() == Some("über-atlas-1"))
870        );
871
872        let graph = facts.symbol_graph("docs/guide.md", Some("markdown"));
873        assert_eq!(graph.parser, ParserKind::Structural);
874        assert_eq!(graph.symbols.len(), 2);
875        assert_eq!(graph.symbols[0].kind, SymbolKind::Heading);
876        assert_eq!(graph.symbols[0].line_start, 1);
877        assert_eq!(
878            graph.symbols[0].source_selector,
879            Some(SymbolSourceSelector {
880                byte_start: 0,
881                byte_end: 15,
882                column_start: 0,
883                column_end: 14,
884            })
885        );
886        assert_eq!(
887            graph
888                .symbols
889                .iter()
890                .map(|symbol| symbol.signature.as_str())
891                .collect::<Vec<_>>(),
892            vec!["über-atlas", "über-atlas-1"]
893        );
894        assert!(
895            graph.symbols[0]
896                .detail
897                .as_deref()
898                .is_some_and(|detail| detail.contains("slug=über-atlas;occurrence=1;bytes="))
899        );
900    }
901
902    #[test]
903    fn symbol_graph_reserves_the_derived_scope_namespace() {
904        let content = format!(
905            "# {}literal\n\n# Visible\n",
906            projectatlas_core::graph::QUALIFIED_SYMBOL_SCOPE_PREFIX
907        );
908        let facts = extract_markdown_facts(&content);
909        let graph = facts.symbol_graph("README.md", Some("markdown"));
910
911        assert_eq!(facts.headings.len(), 2);
912        assert_eq!(
913            graph
914                .symbols
915                .iter()
916                .map(|symbol| symbol.name.as_str())
917                .collect::<Vec<_>>(),
918            ["Visible"]
919        );
920    }
921
922    #[test]
923    fn rejects_non_local_non_static_and_false_positive_candidates() {
924        let content = r"
925![image](assets/logo.png)
926[external](https://example.test/x)
927[absolute](/src/lib.rs)
928[drive](C:/src/lib.rs)
929[unc](//server/share/lib.rs)
930[dynamic]({target})
931[templated](docs/$name.md)
932[fragment](#entry)
933[directory](../src/)
934`foo()` `cargo test` `README` `../` `https://example.test/x` `src/lib.rs and prose`
935
936```md
937# fenced heading
938`src/fenced.rs`
939```
940
941<section>
942# raw HTML heading
943</section>
944";
945        let facts = extract_markdown_facts(content);
946
947        assert!(facts.headings.is_empty());
948        assert!(facts.link_candidates.is_empty());
949        assert_eq!(
950            facts.coverage.completeness,
951            MarkdownFactCompleteness::Partial
952        );
953        assert_eq!(
954            facts.coverage.unsupported,
955            vec![
956                MarkdownUnsupportedStructure::Image,
957                MarkdownUnsupportedStructure::RawHtmlOrMdx,
958                MarkdownUnsupportedStructure::DynamicDestination,
959            ]
960        );
961    }
962
963    #[test]
964    fn keeps_supported_markdown_outside_mdx_structure() {
965        let content = "<Component source={target}>\n# Not a heading\n</Component>\n\n# Real heading\n\n[src](src/lib.rs)\n";
966        let facts = extract_markdown_facts(content);
967
968        assert_eq!(facts.headings.len(), 1);
969        assert_eq!(facts.headings[0].text, "Real heading");
970        assert_eq!(facts.link_candidates.len(), 1);
971        assert_eq!(facts.link_candidates[0].selector, "src/lib.rs");
972        assert_eq!(
973            facts.coverage.completeness,
974            MarkdownFactCompleteness::Partial
975        );
976        assert_eq!(
977            facts.coverage.unsupported,
978            vec![MarkdownUnsupportedStructure::RawHtmlOrMdx]
979        );
980    }
981
982    #[test]
983    fn exposes_hard_limits_without_unbounded_retention() {
984        use std::fmt::Write as _;
985
986        let oversized = "x".repeat(MAX_MARKDOWN_BYTES + 1);
987        let oversized_facts = extract_markdown_facts(&oversized);
988        assert!(oversized_facts.headings.is_empty());
989        assert_eq!(
990            oversized_facts.coverage.limits,
991            vec![MarkdownFactLimit::InputBytes]
992        );
993
994        let oversized_selector = format!(
995            "[target](src/{}.rs)\n",
996            "x".repeat(MAX_DOCUMENT_SELECTOR_BYTES)
997        );
998        let selector_facts = extract_markdown_facts(&oversized_selector);
999        assert!(selector_facts.link_candidates.is_empty());
1000        assert!(
1001            selector_facts
1002                .coverage
1003                .limits
1004                .contains(&MarkdownFactLimit::SelectorBytes)
1005        );
1006
1007        let long_label = format!("# {}\n", "é".repeat(MAX_MARKDOWN_LABEL_BYTES));
1008        let label_facts = extract_markdown_facts(&long_label);
1009        assert_eq!(label_facts.headings.len(), 1);
1010        assert!(label_facts.headings[0].text.len() <= MAX_MARKDOWN_LABEL_BYTES);
1011        assert_eq!(
1012            label_facts.coverage.limits,
1013            vec![MarkdownFactLimit::LabelBytes]
1014        );
1015
1016        let mut many_headings = String::new();
1017        for index in 0..=MAX_MARKDOWN_HEADINGS {
1018            assert!(writeln!(many_headings, "# Heading {index}").is_ok());
1019        }
1020        let heading_facts = extract_markdown_facts(&many_headings);
1021        assert_eq!(heading_facts.headings.len(), MAX_MARKDOWN_HEADINGS);
1022        assert!(
1023            heading_facts
1024                .coverage
1025                .limits
1026                .contains(&MarkdownFactLimit::HeadingCount)
1027        );
1028
1029        let mut many_candidates = String::new();
1030        for index in 0..=MAX_DOCUMENT_LINK_CANDIDATES {
1031            assert!(writeln!(many_candidates, "[target](src/file_{index}.rs)").is_ok());
1032        }
1033        let candidate_facts = extract_markdown_facts(&many_candidates);
1034        assert_eq!(
1035            candidate_facts.link_candidates.len(),
1036            MAX_DOCUMENT_LINK_CANDIDATES
1037        );
1038        assert!(
1039            candidate_facts
1040                .coverage
1041                .limits
1042                .contains(&MarkdownFactLimit::CandidateCount)
1043        );
1044
1045        let mut evidence = MarkdownExtraction::new("");
1046        assert!(evidence.retain(MAX_MARKDOWN_EVIDENCE_BYTES));
1047        assert!(!evidence.retain(1));
1048        assert_eq!(evidence.limits, vec![MarkdownFactLimit::EvidenceBytes]);
1049    }
1050
1051    #[test]
1052    fn markdown_dispatch_is_additive_and_rst_remains_unsupported() {
1053        let markdown = crate::extract_symbol_graph(
1054            "docs/guide.mdx",
1055            Some("markdown"),
1056            "# Guide\n\n[src](src/lib.rs)\n",
1057        );
1058        assert_eq!(markdown.parser, ParserKind::Structural);
1059        assert_eq!(markdown.symbols.len(), 1);
1060        assert_eq!(markdown.symbols[0].kind, SymbolKind::Heading);
1061        assert!(markdown.relations.is_empty());
1062
1063        let rst = crate::extract_symbol_graph(
1064            "docs/guide.rst",
1065            Some("rst"),
1066            "Guide\n=====\n\n:doc:`src/lib.rs`\n",
1067        );
1068        assert_eq!(rst.parser, ParserKind::Fallback);
1069        assert!(rst.symbols.is_empty());
1070        assert!(rst.relations.is_empty());
1071    }
1072
1073    #[test]
1074    fn controlled_facts_preserve_results_and_propagate_cancellation() {
1075        use projectatlas_core::IndexCancellation;
1076
1077        let content = "# Guide\n\n[src](src/lib.rs)\n";
1078        let active = IndexWorkControl::new(IndexCancellation::new(), None);
1079        assert_eq!(
1080            extract_markdown_facts_controlled(content, &active),
1081            Ok(extract_markdown_facts(content))
1082        );
1083
1084        let cancellation = IndexCancellation::new();
1085        cancellation.cancel();
1086        let cancelled = IndexWorkControl::new(cancellation, None);
1087        assert_eq!(
1088            extract_markdown_facts_controlled(content, &cancelled),
1089            Err(IndexWorkFailure::Cancelled {
1090                stage: IndexWorkStage::SymbolParsing,
1091            })
1092        );
1093    }
1094}