1use 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
13pub const MAX_MARKDOWN_BYTES: usize = 2_000_000;
15pub const MAX_MARKDOWN_HEADINGS: usize = 512;
17pub const MAX_DOCUMENT_LINK_CANDIDATES: usize = 1_024;
19pub const MAX_MARKDOWN_LABEL_BYTES: usize = 240;
21pub const MAX_DOCUMENT_SELECTOR_BYTES: usize = 512;
23pub const MAX_MARKDOWN_EVIDENCE_BYTES: usize = 262_144;
25
26#[derive(Clone, Copy, Debug, Eq, PartialEq)]
28pub enum MarkdownParserProvenance {
29 PulldownCmark,
31}
32
33#[derive(Clone, Copy, Debug, Eq, PartialEq)]
35pub enum MarkdownFactCompleteness {
36 Complete,
38 Partial,
40}
41
42#[derive(Clone, Copy, Debug, Eq, Ord, PartialEq, PartialOrd)]
44pub enum MarkdownFactLimit {
45 InputBytes,
47 HeadingCount,
49 CandidateCount,
51 LabelBytes,
53 SelectorBytes,
55 EvidenceBytes,
57}
58
59#[derive(Clone, Copy, Debug, Eq, Ord, PartialEq, PartialOrd)]
61pub enum MarkdownUnsupportedStructure {
62 Image,
64 RawHtmlOrMdx,
66 DynamicDestination,
68}
69
70#[derive(Clone, Debug, Eq, PartialEq)]
72pub struct MarkdownSourceSelector {
73 pub byte_start: usize,
75 pub byte_end: usize,
77 pub line_start: usize,
79 pub column_start: usize,
81 pub line_end: usize,
83 pub column_end: usize,
85}
86
87#[derive(Clone, Debug, Eq, PartialEq)]
89pub struct MarkdownHeadingFact {
90 pub level: u8,
92 pub text: String,
94 pub slug: String,
96 pub occurrence: usize,
98 pub source: MarkdownSourceSelector,
100}
101
102#[derive(Clone, Copy, Debug, Eq, PartialEq)]
104pub enum DocumentLinkSource {
105 MarkdownDestination,
107 InlineCode,
109}
110
111#[derive(Clone, Debug, Eq, PartialEq)]
113pub struct DocumentLinkCandidate {
114 pub source_kind: DocumentLinkSource,
116 pub selector: String,
118 pub label: Option<String>,
120 pub enclosing_heading: Option<String>,
122 pub source: MarkdownSourceSelector,
124}
125
126#[derive(Clone, Debug, Eq, PartialEq)]
128pub struct MarkdownFactCoverage {
129 pub completeness: MarkdownFactCompleteness,
131 pub limits: Vec<MarkdownFactLimit>,
133 pub unsupported: Vec<MarkdownUnsupportedStructure>,
135}
136
137#[derive(Clone, Debug, Eq, PartialEq)]
139pub struct MarkdownFacts {
140 pub provenance: MarkdownParserProvenance,
142 pub headings: Vec<MarkdownHeadingFact>,
144 pub link_candidates: Vec<DocumentLinkCandidate>,
146 pub coverage: MarkdownFactCoverage,
148}
149
150impl MarkdownFacts {
151 #[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#[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
205pub 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
219pub(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
240struct MarkdownExtraction<'a> {
242 content: &'a str,
244 line_starts: Vec<usize>,
246 headings: Vec<MarkdownHeadingFact>,
248 link_candidates: Vec<DocumentLinkCandidate>,
250 slug_occurrences: BTreeMap<String, usize>,
252 limits: Vec<MarkdownFactLimit>,
254 unsupported: Vec<MarkdownUnsupportedStructure>,
256 retained_evidence_bytes: usize,
258 heading: Option<HeadingBuilder>,
260 enclosing_heading: Option<String>,
262 link: Option<LinkBuilder>,
264 link_depth: usize,
266 image_depth: usize,
268}
269
270impl<'a> MarkdownExtraction<'a> {
271 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 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 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 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 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 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 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 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 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 fn line_at(&self, byte: usize) -> usize {
504 self.line_starts
505 .partition_point(|start| *start <= byte)
506 .max(1)
507 }
508
509 fn limit(&mut self, limit: MarkdownFactLimit) {
511 insert_sorted_unique(&mut self.limits, limit);
512 }
513
514 fn unsupported(&mut self, unsupported: MarkdownUnsupportedStructure) {
516 insert_sorted_unique(&mut self.unsupported, unsupported);
517 }
518
519 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
566fn 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
581struct HeadingBuilder {
583 level: HeadingLevel,
585 byte_start: usize,
587 label: BoundedLabel,
589}
590
591impl HeadingBuilder {
592 fn new(level: HeadingLevel, byte_start: usize) -> Self {
594 Self {
595 level,
596 byte_start,
597 label: BoundedLabel::default(),
598 }
599 }
600}
601
602struct LinkBuilder {
604 selector: String,
606 byte_start: usize,
608 label: BoundedLabel,
610}
611
612impl LinkBuilder {
613 fn new(selector: String, byte_start: usize) -> Self {
615 Self {
616 selector,
617 byte_start,
618 label: BoundedLabel::default(),
619 }
620 }
621}
622
623#[derive(Default)]
625struct BoundedLabel {
626 text: String,
628 pending_space: bool,
630 truncated: bool,
632}
633
634impl BoundedLabel {
635 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 fn finish(self) -> String {
663 self.text
664 }
665}
666
667fn 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
702fn 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
724fn looks_dynamic(value: &str) -> bool {
726 value.contains(['{', '}', '<', '>', '|', '*']) || value.contains('$')
727}
728
729fn 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
741fn 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
750fn 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
772fn 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
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}