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 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
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(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
566fn 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
601fn 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
616struct HeadingBuilder {
618 level: HeadingLevel,
620 byte_start: usize,
622 label: BoundedLabel,
624}
625
626impl HeadingBuilder {
627 fn new(level: HeadingLevel, byte_start: usize) -> Self {
629 Self {
630 level,
631 byte_start,
632 label: BoundedLabel::default(),
633 }
634 }
635}
636
637struct LinkBuilder {
639 selector: String,
641 byte_start: usize,
643 label: BoundedLabel,
645}
646
647impl LinkBuilder {
648 fn new(selector: String, byte_start: usize) -> Self {
650 Self {
651 selector,
652 byte_start,
653 label: BoundedLabel::default(),
654 }
655 }
656}
657
658#[derive(Default)]
660struct BoundedLabel {
661 text: String,
663 pending_space: bool,
665 truncated: bool,
667}
668
669impl BoundedLabel {
670 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 fn finish(self) -> String {
698 self.text
699 }
700}
701
702fn 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
737fn 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
759fn looks_dynamic(value: &str) -> bool {
761 value.contains(['{', '}', '<', '>', '|', '*']) || value.contains('$')
762}
763
764fn 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
776fn 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
785fn 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
807fn 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
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}