1use std::collections::{BTreeMap, BTreeSet};
55
56use mos_core::{
57 AttrValue, Diagnostic, DiagnosticAnnotation, Document, NodeKind, SourceSpan, Suggestion, codes,
58};
59
60use crate::suggest::edit_distance;
61use crate::{LABEL_SPAN_END_ATTR, LABEL_SPAN_START_ATTR};
62
63const MAX_FIXPOINT_ITERATIONS: u32 = 8;
67
68#[derive(Clone, Debug, Eq, PartialEq)]
75enum LabelTargetKind {
76 Section { number: String },
79 Figure { number: String, supplement: String },
86 Generic,
88}
89
90#[derive(Clone, Debug)]
96struct LabelTarget {
97 kind: LabelTargetKind,
98 span: SourceSpan,
99}
100
101pub fn resolve(document: &mut Document, bib_keys: &BTreeSet<String>) -> Vec<Diagnostic> {
105 let mut diagnostics: Vec<Diagnostic> = Vec::new();
106 number_sections(document);
107 number_figures(document);
108 let labels = build_label_index(document, &mut diagnostics);
109 validate_page_references(document, &labels, &mut diagnostics);
110
111 for _ in 0..MAX_FIXPOINT_ITERATIONS {
112 let changed = rewrite_references(document, &labels, bib_keys, &mut diagnostics);
113 if !changed {
114 break;
115 }
116 }
117
118 diagnostics
119}
120
121fn validate_page_references(
127 document: &Document,
128 labels: &BTreeMap<String, LabelTarget>,
129 diagnostics: &mut Vec<Diagnostic>,
130) {
131 for node in document
132 .nodes()
133 .filter(|node| node.kind == NodeKind::PageReference)
134 {
135 let Some(AttrValue::Str(label)) = node.attributes.get("label") else {
136 continue;
137 };
138 if labels.contains_key(label) {
139 continue;
140 }
141 let mut diagnostic = Diagnostic::simple(
142 &codes::MOS0033,
143 None,
144 format!("unknown label `{label}` in `@page` reference"),
145 )
146 .with_span(node.span.clone());
147 if let Some(candidate) = nearest_label(label, labels) {
148 diagnostic = diagnostic.with_suggestion(Suggestion::new(
149 node.span.clone(),
150 format!("@page({candidate})"),
151 ));
152 }
153 diagnostics.push(diagnostic);
154 }
155}
156
157fn number_sections(document: &mut Document) {
161 let order = section_order(document);
162 let mut counters: Vec<u32> = Vec::new();
163 for (id, level) in order {
164 let depth = usize::from(level.max(1));
165 if depth > counters.len() {
166 counters.resize(depth, 0);
167 } else {
168 counters.truncate(depth);
169 }
170 counters[depth - 1] += 1;
171 let number = counters
172 .iter()
173 .map(u32::to_string)
174 .collect::<Vec<_>>()
175 .join(".");
176 if let Some(node) = document.get_mut(id) {
177 node.attributes
178 .insert("number".to_owned(), AttrValue::Str(number));
179 }
180 }
181}
182
183fn section_order(document: &Document) -> Vec<(mos_core::NodeId, u8)> {
184 nodes_of_kind(document, NodeKind::Section)
190 .into_iter()
191 .map(|id| {
192 let level = match document.get(id).and_then(|n| n.attributes.get("level")) {
193 Some(AttrValue::Int(n)) => u8::try_from((*n).clamp(1, 255)).unwrap_or(1),
194 _ => 1,
195 };
196 (id, level)
197 })
198 .collect()
199}
200
201fn number_figures(document: &mut Document) {
225 let mut counter: usize = 0;
230 for figure_id in nodes_of_kind(document, NodeKind::Figure) {
231 let Some((numbered, supplement)) = document
233 .get(figure_id)
234 .map(|node| (figure_is_numbered(node), figure_supplement_attr(node)))
235 else {
236 continue;
237 };
238 let caption = figure_caption_text(document, figure_id).and_then(|text_id| {
246 read_str_attr(document, text_id, "caption_source")
247 .or_else(|| read_str_attr(document, text_id, "text"))
248 .map(|source| (text_id, source))
249 });
250
251 if numbered {
252 counter += 1;
253 let number = counter.to_string();
254 if let Some(node) = document.get_mut(figure_id) {
255 node.attributes
256 .insert("number".to_owned(), AttrValue::Str(number.clone()));
257 }
258 if let Some((text_id, caption_source)) = caption {
259 let labelled = format!(
260 "{}: {caption_source}",
261 figure_label_prefix(&supplement, &number)
262 );
263 if let Some(node) = document.get_mut(text_id) {
264 node.attributes
267 .insert("caption_source".to_owned(), AttrValue::Str(caption_source));
268 node.attributes
269 .insert("text".to_owned(), AttrValue::Str(labelled));
270 }
271 }
272 } else {
273 if let Some(node) = document.get_mut(figure_id) {
278 node.attributes.remove("number");
279 }
280 if let Some((text_id, caption_source)) = caption
281 && let Some(node) = document.get_mut(text_id)
282 {
283 node.attributes.insert(
284 "caption_source".to_owned(),
285 AttrValue::Str(caption_source.clone()),
286 );
287 node.attributes
288 .insert("text".to_owned(), AttrValue::Str(caption_source));
289 }
290 }
291 }
292}
293
294fn nodes_of_kind(document: &Document, kind: NodeKind) -> Vec<mos_core::NodeId> {
301 document
302 .nodes()
303 .filter(|node| node.kind == kind)
304 .map(|node| node.id)
305 .collect()
306}
307
308fn figure_caption_text(
312 document: &Document,
313 figure_id: mos_core::NodeId,
314) -> Option<mos_core::NodeId> {
315 let figure = document.get(figure_id)?;
316 for &child_id in &figure.children {
317 let Some(child) = document.get(child_id) else {
318 continue;
319 };
320 let is_caption = child.kind == NodeKind::Paragraph
321 && matches!(child.attributes.get("role"), Some(AttrValue::Str(role)) if role == "caption");
322 if !is_caption {
323 continue;
324 }
325 for &grandchild_id in &child.children {
326 if document
327 .get(grandchild_id)
328 .is_some_and(|gc| gc.kind == NodeKind::Text)
329 {
330 return Some(grandchild_id);
331 }
332 }
333 }
334 None
335}
336
337fn read_str_attr(document: &Document, id: mos_core::NodeId, key: &str) -> Option<String> {
340 match document.get(id)?.attributes.get(key) {
341 Some(AttrValue::Str(s)) => Some(s.clone()),
342 _ => None,
343 }
344}
345
346const fn figure_supplement() -> &'static str {
358 "Figure"
359}
360
361fn figure_is_numbered(node: &mos_core::Node) -> bool {
365 !matches!(
366 node.attributes.get("numbered"),
367 Some(AttrValue::Bool(false))
368 )
369}
370
371fn figure_supplement_attr(node: &mos_core::Node) -> String {
378 match node.attributes.get("supplement") {
379 Some(AttrValue::Str(s)) => s.clone(),
380 _ => figure_supplement().to_owned(),
381 }
382}
383
384fn figure_label_prefix(supplement: &str, number: &str) -> String {
390 if supplement.is_empty() {
391 number.to_owned()
392 } else {
393 format!("{supplement}\u{00A0}{number}")
394 }
395}
396
397fn captured_number(node: &mos_core::Node) -> String {
402 match node.attributes.get("number") {
403 Some(AttrValue::Str(s)) => s.clone(),
404 _ => String::new(),
405 }
406}
407
408fn classify_target(node: &mos_core::Node) -> LabelTargetKind {
412 match node.kind {
413 NodeKind::Section => LabelTargetKind::Section {
414 number: captured_number(node),
415 },
416 NodeKind::Figure => LabelTargetKind::Figure {
417 number: captured_number(node),
418 supplement: figure_supplement_attr(node),
419 },
420 _ => LabelTargetKind::Generic,
421 }
422}
423
424fn declared_labels(document: &Document) -> BTreeSet<String> {
429 document
430 .nodes()
431 .filter(|node| !matches!(node.kind, NodeKind::Reference | NodeKind::PageReference))
432 .filter_map(|node| match node.attributes.get("label") {
433 Some(AttrValue::Str(label)) => Some(label.clone()),
434 _ => None,
435 })
436 .collect()
437}
438
439fn nonconflicting_rename(label: &str, declared: &BTreeSet<String>) -> String {
446 let ceiling = declared.len().saturating_add(2);
447 (2..=ceiling)
448 .map(|n| format!("{label}-{n}"))
449 .find(|candidate| !declared.contains(candidate))
450 .unwrap_or_else(|| format!("{label}-{ceiling}"))
451}
452
453fn build_label_index(
461 document: &Document,
462 diagnostics: &mut Vec<Diagnostic>,
463) -> BTreeMap<String, LabelTarget> {
464 let mut occupied_labels = declared_labels(document);
465 let mut index: BTreeMap<String, LabelTarget> = BTreeMap::new();
466 for node in document.nodes() {
467 if matches!(node.kind, NodeKind::Reference | NodeKind::PageReference) {
471 continue;
472 }
473 let Some(AttrValue::Str(label)) = node.attributes.get("label") else {
474 continue;
475 };
476 if let Some(existing) = index.get(label) {
477 let rename = nonconflicting_rename(label, &occupied_labels);
484 occupied_labels.insert(rename.clone());
485 let suggestion = label_span(node).map(|span| Suggestion::new(span, rename));
486 let mut diagnostic = Diagnostic::simple(
487 &codes::MOS0030,
488 None,
489 format!("label `{label}` is declared more than once"),
490 )
491 .with_span(node.span.clone())
492 .with_annotation(DiagnosticAnnotation::Related {
493 span: existing.span.clone(),
494 message: format!("first declaration of `{label}` is here"),
495 });
496 if let Some(suggestion) = suggestion {
497 diagnostic = diagnostic.with_suggestion(suggestion);
498 }
499 diagnostics.push(diagnostic);
500 continue;
501 }
502 index.insert(
503 label.clone(),
504 LabelTarget {
505 kind: classify_target(node),
506 span: node.span.clone(),
507 },
508 );
509 }
510 index
511}
512
513fn label_span(node: &mos_core::Node) -> Option<SourceSpan> {
514 let start = match node.attributes.get(LABEL_SPAN_START_ATTR) {
515 Some(AttrValue::Int(value)) => usize::try_from(*value).ok()?,
516 _ => return None,
517 };
518 let end = match node.attributes.get(LABEL_SPAN_END_ATTR) {
519 Some(AttrValue::Int(value)) => usize::try_from(*value).ok()?,
520 _ => return None,
521 };
522 if start > end {
523 return None;
524 }
525 Some(SourceSpan::new(node.span.file.clone(), start, end))
526}
527
528fn render_target(target: &LabelTarget, label: &str) -> String {
539 match &target.kind {
540 LabelTargetKind::Section { number } if !number.is_empty() => number.clone(),
541 LabelTargetKind::Figure { number, supplement } if !number.is_empty() => {
542 figure_label_prefix(supplement, number)
543 }
544 LabelTargetKind::Section { .. }
547 | LabelTargetKind::Figure { .. }
548 | LabelTargetKind::Generic => label.to_owned(),
549 }
550}
551
552fn is_reference_label(label: &str) -> bool {
559 !label.is_empty()
560 && label
561 .bytes()
562 .all(|b| b.is_ascii_alphanumeric() || matches!(b, b'_' | b'-' | b':' | b'.'))
563}
564
565fn nearest_label(unknown: &str, labels: &BTreeMap<String, LabelTarget>) -> Option<String> {
584 if unknown.len() < 3 {
585 return None;
586 }
587 let max_distance = unknown.len() / 3;
588 labels
589 .keys()
590 .filter(|label| is_reference_label(label))
591 .map(|label| (edit_distance(unknown, label), label))
592 .filter(|&(distance, _)| distance <= max_distance)
593 .min_by(|a, b| a.0.cmp(&b.0).then_with(|| a.1.cmp(b.1)))
594 .map(|(_, label)| label.clone())
595}
596
597fn rewrite_references(
601 document: &mut Document,
602 labels: &BTreeMap<String, LabelTarget>,
603 bib_keys: &BTreeSet<String>,
604 diagnostics: &mut Vec<Diagnostic>,
605) -> bool {
606 let references: Vec<mos_core::NodeId> = document
607 .nodes()
608 .filter(|n| n.kind == NodeKind::Reference)
609 .map(|n| n.id)
610 .collect();
611
612 let mut changed = false;
613 for ref_id in references {
614 let Some(node) = document.get(ref_id) else {
615 continue;
616 };
617 let Some(AttrValue::Str(label)) = node.attributes.get("label").cloned() else {
618 continue;
619 };
620 let resolved_text = if let Some(target) = labels.get(&label) {
621 render_target(target, &label)
622 } else {
623 let already_diagnosed = diagnostics
624 .iter()
625 .any(|d| d.def().code() == codes::MOS0033.code() && d.span() == Some(&node.span));
626 if !already_diagnosed {
627 let mut diagnostic = Diagnostic::simple(
628 &codes::MOS0033,
629 None,
630 format!("unknown label `{label}` in `@` reference"),
631 )
632 .with_span(node.span.clone());
633 if bib_keys.contains(&label) {
641 diagnostic = diagnostic
642 .with_annotation(DiagnosticAnnotation::Hint(format!(
643 "`{label}` is a bibliography key; cite it as `[@{label}]`"
644 )))
645 .with_suggestion(Suggestion::new(node.span.clone(), format!("[@{label}]")));
646 } else if let Some(candidate) = nearest_label(&label, labels) {
647 diagnostic = diagnostic.with_suggestion(Suggestion::new(
651 node.span.clone(),
652 format!("@{candidate}"),
653 ));
654 }
655 diagnostics.push(diagnostic);
656 }
657 continue;
658 };
659
660 if let Some(node) = document.get_mut(ref_id) {
661 let new = AttrValue::Str(resolved_text);
662 if node.attributes.get("text") != Some(&new) {
663 node.attributes.insert("text".to_owned(), new);
664 changed = true;
665 }
666 }
667 }
668 changed
669}
670
671#[cfg(test)]
672mod tests {
673 use std::path::PathBuf;
674
675 use mos_core::Severity;
676
677 use super::*;
678
679 fn lower(src: &str) -> (Document, Vec<Diagnostic>) {
680 let r = crate::lower(src, &PathBuf::from("test.mos"));
681 (r.document, r.diagnostics)
682 }
683
684 fn apply_suggestion(src: &str, suggestion: &Suggestion) -> String {
685 let mut out = String::new();
686 out.push_str(&src[..suggestion.span.start()]);
687 out.push_str(&suggestion.replacement);
688 out.push_str(&src[suggestion.span.end()..]);
689 out
690 }
691
692 fn section_numbers(doc: &Document) -> Vec<(String, String)> {
693 doc.nodes()
694 .filter(|n| n.kind == NodeKind::Section)
695 .map(|n| {
696 let title = n
697 .children
698 .iter()
699 .filter_map(|c| doc.get(*c))
700 .find_map(|c| match c.attributes.get("text") {
701 Some(AttrValue::Str(s)) => Some(s.clone()),
702 _ => None,
703 })
704 .unwrap_or_default();
705 let number = match n.attributes.get("number") {
706 Some(AttrValue::Str(s)) => s.clone(),
707 _ => String::new(),
708 };
709 (title, number)
710 })
711 .collect()
712 }
713
714 #[test]
715 fn assigns_hierarchical_section_numbers() {
716 let (doc, diags) = lower("= Intro\n\n== Background\n\n== Aims\n\n= Methods\n\n== Sample\n");
717 assert!(diags.is_empty(), "{diags:?}");
718 let nums = section_numbers(&doc);
719 let pairs: Vec<(&str, &str)> = nums.iter().map(|(t, n)| (t.as_str(), n.as_str())).collect();
720 assert_eq!(
721 pairs,
722 vec![
723 ("Intro", "1"),
724 ("Background", "1.1"),
725 ("Aims", "1.2"),
726 ("Methods", "2"),
727 ("Sample", "2.1"),
728 ]
729 );
730 }
731
732 #[test]
733 fn duplicate_label_emits_mos0030_and_keeps_first() {
734 let src = "= A <dup>\n\n= B <dup>\n\nsee @dup\n";
735 let (doc, diags) = lower(src);
736 let mos0030: Vec<&Diagnostic> = diags
737 .iter()
738 .filter(|d| d.def().code() == codes::MOS0030.code())
739 .collect();
740 assert_eq!(
741 mos0030.len(),
742 1,
743 "expected exactly one MOS0030, got {diags:?}"
744 );
745 let d = mos0030[0];
746 assert_eq!(d.def().code(), codes::MOS0030.code());
747 assert_eq!(d.severity(), Severity::Error);
748 assert!(
749 d.message().contains("`dup`"),
750 "MOS0030 message should name the duplicated label, got {:?}",
751 d.message()
752 );
753 assert_eq!(
757 d.span().map(|span| &src[span.start()..span.end()]),
758 Some("= B <dup>"),
759 "MOS0030 span should cover the second heading exactly"
760 );
761 assert_eq!(
762 d.annotations().len(),
763 1,
764 "MOS0030 should reference the first decl"
765 );
766 let related = d.annotations().iter().find_map(|a| match a {
767 DiagnosticAnnotation::Related { span, message } => Some((span, message)),
768 _ => None,
769 });
770 assert!(related.is_some(), "MOS0030 carries a Related annotation");
771 if let Some((note_span, note_message)) = related {
772 assert_eq!(
773 &src[note_span.start()..note_span.end()],
774 "= A <dup>",
775 "MOS0030 note should point at the original declaration exactly"
776 );
777 assert!(
778 note_message.contains("`dup`"),
779 "first-decl note should name the label, got {note_message:?}"
780 );
781 }
782 let suggestions = d.suggestions();
788 assert_eq!(
789 suggestions.len(),
790 1,
791 "MOS0030 should carry exactly one rename suggestion, got {suggestions:?}"
792 );
793 if let Some(suggestion) = suggestions.first() {
794 assert_eq!(
795 &src[suggestion.span.start()..suggestion.span.end()],
796 "dup",
797 "suggestion span should cover only the duplicate label token"
798 );
799 assert_eq!(
800 suggestion.replacement, "dup-2",
801 "suggestion should rename the duplicate label deterministically"
802 );
803 assert_eq!(
804 apply_suggestion(src, suggestion),
805 "= A <dup>\n\n= B <dup-2>\n\nsee @dup\n",
806 "applying the fix must preserve the heading and label delimiters"
807 );
808 }
809 let reference_text = doc
811 .nodes()
812 .find(|n| n.kind == NodeKind::Reference)
813 .and_then(|n| n.attributes.get("text"));
814 assert_eq!(reference_text, Some(&AttrValue::Str("1".to_owned())));
815 }
816
817 #[test]
818 fn triple_duplicate_label_emits_one_mos0030_per_redeclaration() {
819 let src = "= A <dup>\n\n= B <dup>\n\n= C <dup>\n\nsee @dup\n";
823 let (doc, diags) = lower(src);
824 let mos0030: Vec<&Diagnostic> = diags
825 .iter()
826 .filter(|d| d.def().code() == codes::MOS0030.code())
827 .collect();
828 assert_eq!(
829 mos0030.len(),
830 2,
831 "expected two MOS0030 (one per redeclaration), got {diags:?}"
832 );
833 let spans: Vec<&str> = mos0030
834 .iter()
835 .filter_map(|d| d.span().map(|s| &src[s.start()..s.end()]))
836 .collect();
837 assert_eq!(
838 spans.len(),
839 mos0030.len(),
840 "every MOS0030 must carry a primary span"
841 );
842 assert!(
843 spans.contains(&"= B <dup>"),
844 "missing span for second decl, got {spans:?}"
845 );
846 assert!(
847 spans.contains(&"= C <dup>"),
848 "missing span for third decl, got {spans:?}"
849 );
850 for d in &mos0030 {
852 let related = d.annotations().iter().find_map(|a| match a {
853 DiagnosticAnnotation::Related { span, message } => Some((span, message)),
854 _ => None,
855 });
856 assert!(related.is_some(), "MOS0030 carries a Related annotation");
857 if let Some((ns, _)) = related {
858 assert_eq!(
859 &src[ns.start()..ns.end()],
860 "= A <dup>",
861 "every redeclaration must link back to the first decl"
862 );
863 }
864 let suggestions = d.suggestions();
869 assert_eq!(
870 suggestions.len(),
871 1,
872 "each MOS0030 carries exactly one rename suggestion, got {suggestions:?}"
873 );
874 if let Some(suggestion) = suggestions.first() {
875 assert_eq!(&src[suggestion.span.start()..suggestion.span.end()], "dup");
876 }
877 }
878 let replacements: Vec<&str> = mos0030
879 .iter()
880 .filter_map(|d| d.suggestions().first())
881 .map(|suggestion| suggestion.replacement.as_str())
882 .collect();
883 assert_eq!(replacements, vec!["dup-2", "dup-3"]);
884 let reference_text = doc
885 .nodes()
886 .find(|n| n.kind == NodeKind::Reference)
887 .and_then(|n| n.attributes.get("text"));
888 assert_eq!(reference_text, Some(&AttrValue::Str("1".to_owned())));
889 }
890
891 #[test]
892 fn duplicate_suggestion_skips_existing_label() {
893 let src = "= A <dup>\n\n= B <dup-2>\n\n= C <dup>\n";
899 let (_doc, diags) = lower(src);
900 let mos0030: Vec<&Diagnostic> = diags
901 .iter()
902 .filter(|d| d.def().code() == codes::MOS0030.code())
903 .collect();
904 assert_eq!(mos0030.len(), 1, "only `dup` is duplicated, got {diags:?}");
905 let d = mos0030[0];
906 let suggestions = d.suggestions();
907 assert_eq!(
908 suggestions.len(),
909 1,
910 "the duplicate carries one rename suggestion, got {suggestions:?}"
911 );
912 if let Some(suggestion) = suggestions.first() {
913 assert_eq!(
914 suggestion.replacement, "dup-3",
915 "rename must skip the existing `dup-2` and land on the next free suffix"
916 );
917 assert_eq!(
918 &src[suggestion.span.start()..suggestion.span.end()],
919 "dup",
920 "suggestion targets the duplicate label token"
921 );
922 assert_eq!(
923 apply_suggestion(src, suggestion),
924 "= A <dup>\n\n= B <dup-2>\n\n= C <dup-3>\n",
925 "applying the fix must preserve the duplicate declaration syntax"
926 );
927 }
928 }
929
930 #[test]
931 fn unknown_label_emits_mos0033() {
932 let (doc, diags) = lower("see @no:such\n");
933 let mos0033: Vec<&Diagnostic> = diags
934 .iter()
935 .filter(|d| d.def().code() == codes::MOS0033.code())
936 .collect();
937 assert_eq!(
938 mos0033.len(),
939 1,
940 "expected exactly one MOS0033 even with the fixpoint loop, got {diags:?}"
941 );
942 let d = mos0033[0];
943 assert_eq!(d.def().code(), codes::MOS0033.code());
944 assert_eq!(d.severity(), Severity::Error);
945 assert!(
946 d.message().contains("`no:such`"),
947 "MOS0033 message should name the missing label, got {:?}",
948 d.message()
949 );
950 assert!(
951 d.span().is_some(),
952 "MOS0033 must carry a span so editors can jump to the bad reference"
953 );
954 let reference_text = doc
955 .nodes()
956 .find(|n| n.kind == NodeKind::Reference)
957 .and_then(|n| n.attributes.get("text"));
958 assert_eq!(
961 reference_text,
962 Some(&AttrValue::Str("?no:such?".to_owned()))
963 );
964 }
965
966 #[test]
967 fn multiple_unknown_references_each_emit_one_mos0033() {
968 let src = "see @alpha and @beta and @gamma\n";
971 let (_doc, diags) = lower(src);
972 let mos0033: Vec<&Diagnostic> = diags
973 .iter()
974 .filter(|d| d.def().code() == codes::MOS0033.code())
975 .collect();
976 assert_eq!(
977 mos0033.len(),
978 3,
979 "expected one MOS0033 per unknown label, got {diags:?}"
980 );
981 let labels: BTreeSet<&str> = mos0033
982 .iter()
983 .filter_map(|d| {
984 let msg = &d.message();
986 let start = msg.find('`')? + 1;
987 let end = start + msg[start..].find('`')?;
988 Some(&msg[start..end])
989 })
990 .collect();
991 assert_eq!(
992 labels,
993 ["alpha", "beta", "gamma"].into_iter().collect(),
994 "each unknown label should appear exactly once"
995 );
996 }
997
998 #[test]
999 fn unknown_reference_suggestion_is_not_duplicated_after_fixpoint_rerun() {
1000 let src = "= Intro <intro>\n\nsee @intro and @intrdo\n";
1004 let (doc, diags) = lower(src);
1005 let mos0033: Vec<&Diagnostic> = diags
1006 .iter()
1007 .filter(|d| d.def().code() == codes::MOS0033.code())
1008 .collect();
1009 assert_eq!(
1010 mos0033.len(),
1011 1,
1012 "expected one MOS0033 after fixpoint rerun, got {diags:?}"
1013 );
1014 let d = mos0033[0];
1015 let suggestions = d.suggestions();
1016 assert_eq!(
1017 suggestions.len(),
1018 1,
1019 "expected one suggestion after fixpoint rerun, got {suggestions:?}"
1020 );
1021 if let Some(suggestion) = suggestions.first() {
1022 assert_eq!(suggestion.replacement, "@intro");
1023 assert_eq!(
1024 apply_suggestion(src, suggestion),
1025 "= Intro <intro>\n\nsee @intro and @intro\n",
1026 "fix should replace only the unknown reference token"
1027 );
1028 }
1029 let reference_texts: Vec<&str> = doc
1030 .nodes()
1031 .filter(|n| n.kind == NodeKind::Reference)
1032 .filter_map(|n| match n.attributes.get("text") {
1033 Some(AttrValue::Str(s)) => Some(s.as_str()),
1034 _ => None,
1035 })
1036 .collect();
1037 assert_eq!(
1038 reference_texts,
1039 vec!["1", "?intrdo?"],
1040 "resolved refs rewrite while unknown refs keep visible placeholders"
1041 );
1042 }
1043
1044 #[test]
1045 fn reference_resolves_to_section_number() {
1046 let (doc, diags) =
1047 lower("= Intro <intro>\n\n= Methods <methods>\n\nsee @methods and @intro\n");
1048 assert!(diags.is_empty(), "{diags:?}");
1049 let refs: Vec<String> = doc
1050 .nodes()
1051 .filter(|n| n.kind == NodeKind::Reference)
1052 .filter_map(|n| match n.attributes.get("text") {
1053 Some(AttrValue::Str(s)) => Some(s.clone()),
1054 _ => None,
1055 })
1056 .collect();
1057 assert_eq!(refs, vec!["2".to_owned(), "1".to_owned()]);
1058 }
1059
1060 #[test]
1061 fn paragraph_label_indexes_paragraph() {
1062 let (doc, diags) = lower("<note> a side note here\n\nsee @note\n");
1066 assert!(diags.is_empty(), "{diags:?}");
1067 let reference_text = doc
1068 .nodes()
1069 .find(|n| n.kind == NodeKind::Reference)
1070 .and_then(|n| n.attributes.get("text"));
1071 assert_eq!(reference_text, Some(&AttrValue::Str("note".to_owned())));
1072 }
1073
1074 fn make_node(
1078 doc: &mut Document,
1079 kind: NodeKind,
1080 label: Option<&str>,
1081 number: Option<&str>,
1082 ) -> mos_core::NodeId {
1083 let mut attrs = mos_core::AttrMap::new();
1084 if let Some(l) = label {
1085 attrs.insert("label".to_owned(), AttrValue::Str(l.to_owned()));
1086 }
1087 if let Some(n) = number {
1088 attrs.insert("number".to_owned(), AttrValue::Str(n.to_owned()));
1089 }
1090 doc.alloc_child(
1091 doc.root,
1092 mos_core::NodeSpec::new(kind, SourceSpan::placeholder(doc.file.clone()))
1093 .with_attributes(attrs),
1094 )
1095 }
1096
1097 fn make_text(doc: &mut Document, parent: mos_core::NodeId, text: &str) -> mos_core::NodeId {
1101 let mut attrs = mos_core::AttrMap::new();
1102 attrs.insert("text".to_owned(), AttrValue::Str(text.to_owned()));
1103 doc.alloc_child(
1104 parent,
1105 mos_core::NodeSpec::new(NodeKind::Text, SourceSpan::placeholder(doc.file.clone()))
1106 .with_attributes(attrs),
1107 )
1108 }
1109
1110 fn make_captioned_figure(
1116 doc: &mut Document,
1117 label: Option<&str>,
1118 caption: &str,
1119 ) -> (mos_core::NodeId, mos_core::NodeId) {
1120 let figure = make_node(doc, NodeKind::Figure, label, None);
1121 let mut caption_attrs = mos_core::AttrMap::new();
1122 caption_attrs.insert("role".to_owned(), AttrValue::Str("caption".to_owned()));
1123 let caption_para = doc.alloc_child(
1124 figure,
1125 mos_core::NodeSpec::new(
1126 NodeKind::Paragraph,
1127 SourceSpan::placeholder(doc.file.clone()),
1128 )
1129 .with_attributes(caption_attrs),
1130 );
1131 let caption_text = make_text(doc, caption_para, caption);
1132 (figure, caption_text)
1133 }
1134
1135 fn node_number(doc: &Document, id: mos_core::NodeId) -> String {
1140 doc.get(id).map(captured_number).unwrap_or_default()
1141 }
1142
1143 #[test]
1144 fn classify_target_distinguishes_kinds() {
1145 let mut doc = Document::new(PathBuf::from("test.mos"));
1146 let section_id = make_node(&mut doc, NodeKind::Section, Some("sec"), Some("1.2"));
1147 let figure_id = make_node(&mut doc, NodeKind::Figure, Some("fig"), Some("3"));
1148 let paragraph_id = make_node(&mut doc, NodeKind::Paragraph, Some("p"), None);
1149
1150 assert_eq!(
1151 doc.get(section_id).map(classify_target),
1152 Some(LabelTargetKind::Section {
1153 number: "1.2".to_owned()
1154 })
1155 );
1156
1157 assert_eq!(
1158 doc.get(figure_id).map(classify_target),
1159 Some(LabelTargetKind::Figure {
1160 number: "3".to_owned(),
1161 supplement: "Figure".to_owned(),
1162 })
1163 );
1164
1165 assert_eq!(
1166 doc.get(paragraph_id).map(classify_target),
1167 Some(LabelTargetKind::Generic)
1168 );
1169 }
1170
1171 #[test]
1172 fn figure_reference_renders_kind_aware_text() {
1173 let mut doc = Document::new(PathBuf::from("test.mos"));
1181 let figure_id = make_node(&mut doc, NodeKind::Figure, Some("fig:one"), None);
1182 let ref_id = doc.alloc_child(
1183 doc.root,
1184 mos_core::NodeSpec::new(
1185 NodeKind::Reference,
1186 SourceSpan::placeholder(doc.file.clone()),
1187 )
1188 .with_attributes({
1189 let mut a = mos_core::AttrMap::new();
1190 a.insert("label".to_owned(), AttrValue::Str("fig:one".to_owned()));
1191 a.insert("text".to_owned(), AttrValue::Str("?fig:one?".to_owned()));
1192 a
1193 }),
1194 );
1195
1196 let diags = resolve(&mut doc, &BTreeSet::new());
1197 assert!(diags.is_empty(), "{diags:?}");
1198
1199 assert_eq!(
1201 doc.get(figure_id).and_then(|f| f.attributes.get("number")),
1202 Some(&AttrValue::Str("1".to_owned()))
1203 );
1204
1205 let mut sink: Vec<Diagnostic> = Vec::new();
1206 let index = build_label_index(&doc, &mut sink);
1207 assert!(sink.is_empty(), "{sink:?}");
1208 assert_eq!(
1209 index.get("fig:one").map(|target| &target.kind),
1210 Some(&LabelTargetKind::Figure {
1211 number: "1".to_owned(),
1212 supplement: "Figure".to_owned(),
1213 })
1214 );
1215
1216 assert_eq!(
1217 doc.get(ref_id).and_then(|r| r.attributes.get("text")),
1218 Some(&AttrValue::Str("Figure\u{00A0}1".to_owned())),
1219 "a figure reference resolves to kind-aware `Figure N` text, joined by a non-breaking space"
1220 );
1221 }
1222
1223 #[test]
1224 fn captioned_figure_gets_supplement_label_stamped() {
1225 let mut doc = Document::new(PathBuf::from("test.mos"));
1229 let (figure, caption_text) = make_captioned_figure(&mut doc, Some("fig:a"), "A plot.");
1230
1231 let diags = resolve(&mut doc, &BTreeSet::new());
1232 assert!(diags.is_empty(), "{diags:?}");
1233
1234 assert_eq!(node_number(&doc, figure), "1");
1235 assert_eq!(
1236 read_str_attr(&doc, caption_text, "text"),
1237 Some("Figure\u{00A0}1: A plot.".to_owned()),
1238 "the caption is prefixed with the non-breaking `Figure N: ` label"
1239 );
1240 }
1241
1242 #[test]
1243 fn skipped_figure_omits_label_and_does_not_advance_counter() {
1244 let mut doc = Document::new(PathBuf::from("test.mos"));
1249 let (skipped, skipped_caption) =
1250 make_captioned_figure(&mut doc, Some("fig:skip"), "Decorative.");
1251 if let Some(node) = doc.get_mut(skipped) {
1252 node.attributes
1253 .insert("numbered".to_owned(), AttrValue::Bool(false));
1254 }
1255 let (numbered, numbered_caption) =
1256 make_captioned_figure(&mut doc, Some("fig:num"), "A plot.");
1257
1258 let diags = resolve(&mut doc, &BTreeSet::new());
1259 assert!(diags.is_empty(), "{diags:?}");
1260
1261 assert_eq!(
1262 node_number(&doc, skipped),
1263 "",
1264 "a skipped figure carries no number"
1265 );
1266 assert_eq!(
1267 read_str_attr(&doc, skipped_caption, "text"),
1268 Some("Decorative.".to_owned()),
1269 "a skipped figure's caption keeps no `Figure N:` prefix"
1270 );
1271 assert_eq!(
1272 node_number(&doc, numbered),
1273 "1",
1274 "the skipped figure must not consume or gap the counter"
1275 );
1276 assert_eq!(
1277 read_str_attr(&doc, numbered_caption, "text"),
1278 Some("Figure\u{00A0}1: A plot.".to_owned())
1279 );
1280 }
1281
1282 #[test]
1283 fn custom_supplement_renders_in_caption_and_reference() {
1284 let mut doc = Document::new(PathBuf::from("test.mos"));
1287 let (figure, caption_text) = make_captioned_figure(&mut doc, Some("fig:plate"), "A map.");
1288 if let Some(node) = doc.get_mut(figure) {
1289 node.attributes
1290 .insert("supplement".to_owned(), AttrValue::Str("Plate".to_owned()));
1291 }
1292 let ref_id = doc.alloc_child(
1293 doc.root,
1294 mos_core::NodeSpec::new(
1295 NodeKind::Reference,
1296 SourceSpan::placeholder(doc.file.clone()),
1297 )
1298 .with_attributes({
1299 let mut a = mos_core::AttrMap::new();
1300 a.insert("label".to_owned(), AttrValue::Str("fig:plate".to_owned()));
1301 a.insert("text".to_owned(), AttrValue::Str("?fig:plate?".to_owned()));
1302 a
1303 }),
1304 );
1305
1306 let diags = resolve(&mut doc, &BTreeSet::new());
1307 assert!(diags.is_empty(), "{diags:?}");
1308
1309 assert_eq!(
1310 read_str_attr(&doc, caption_text, "text"),
1311 Some("Plate\u{00A0}1: A map.".to_owned()),
1312 "the caption uses the custom supplement word"
1313 );
1314 assert_eq!(
1315 doc.get(ref_id).and_then(|r| r.attributes.get("text")),
1316 Some(&AttrValue::Str("Plate\u{00A0}1".to_owned())),
1317 "a reference renders the custom supplement, not `Figure`"
1318 );
1319 }
1320
1321 #[test]
1322 fn empty_supplement_renders_number_only() {
1323 let mut doc = Document::new(PathBuf::from("test.mos"));
1329 let (figure, caption_text) = make_captioned_figure(&mut doc, Some("fig:plain"), "A chart.");
1330 if let Some(node) = doc.get_mut(figure) {
1331 node.attributes
1332 .insert("supplement".to_owned(), AttrValue::Str(String::new()));
1333 }
1334 let ref_id = doc.alloc_child(
1335 doc.root,
1336 mos_core::NodeSpec::new(
1337 NodeKind::Reference,
1338 SourceSpan::placeholder(doc.file.clone()),
1339 )
1340 .with_attributes({
1341 let mut a = mos_core::AttrMap::new();
1342 a.insert("label".to_owned(), AttrValue::Str("fig:plain".to_owned()));
1343 a.insert("text".to_owned(), AttrValue::Str("?fig:plain?".to_owned()));
1344 a
1345 }),
1346 );
1347
1348 let diags = resolve(&mut doc, &BTreeSet::new());
1349 assert!(diags.is_empty(), "{diags:?}");
1350
1351 assert_eq!(
1352 read_str_attr(&doc, caption_text, "text"),
1353 Some("1: A chart.".to_owned()),
1354 "an empty supplement renders the number with no word and no leading space"
1355 );
1356 assert_eq!(
1357 doc.get(ref_id).and_then(|r| r.attributes.get("text")),
1358 Some(&AttrValue::Str("1".to_owned())),
1359 "a reference to a number-only figure renders just the number"
1360 );
1361 }
1362
1363 #[test]
1364 fn reference_to_skipped_figure_renders_bare_label() {
1365 let mut doc = Document::new(PathBuf::from("test.mos"));
1368 let figure = make_node(&mut doc, NodeKind::Figure, Some("fig:skip"), None);
1369 if let Some(node) = doc.get_mut(figure) {
1370 node.attributes
1371 .insert("numbered".to_owned(), AttrValue::Bool(false));
1372 }
1373 let ref_id = doc.alloc_child(
1374 doc.root,
1375 mos_core::NodeSpec::new(
1376 NodeKind::Reference,
1377 SourceSpan::placeholder(doc.file.clone()),
1378 )
1379 .with_attributes({
1380 let mut a = mos_core::AttrMap::new();
1381 a.insert("label".to_owned(), AttrValue::Str("fig:skip".to_owned()));
1382 a.insert("text".to_owned(), AttrValue::Str("?fig:skip?".to_owned()));
1383 a
1384 }),
1385 );
1386
1387 let diags = resolve(&mut doc, &BTreeSet::new());
1388 assert!(diags.is_empty(), "{diags:?}");
1389
1390 assert_eq!(
1391 doc.get(ref_id).and_then(|r| r.attributes.get("text")),
1392 Some(&AttrValue::Str("fig:skip".to_owned())),
1393 "a reference to a skipped figure renders the bare label"
1394 );
1395 }
1396
1397 #[test]
1398 fn resolve_is_idempotent_for_captioned_figures() {
1399 let mut doc = Document::new(PathBuf::from("test.mos"));
1406 let (_figure, caption_text) = make_captioned_figure(&mut doc, Some("fig:a"), "A plot.");
1407
1408 let first = resolve(&mut doc, &BTreeSet::new());
1409 assert!(first.is_empty(), "{first:?}");
1410 let after_first = read_str_attr(&doc, caption_text, "text");
1411 assert_eq!(after_first, Some("Figure\u{00A0}1: A plot.".to_owned()));
1412
1413 let second = resolve(&mut doc, &BTreeSet::new());
1414 assert!(second.is_empty(), "{second:?}");
1415 assert_eq!(
1416 read_str_attr(&doc, caption_text, "text"),
1417 after_first,
1418 "a second resolve pass must not re-stamp the figure label"
1419 );
1420 }
1421
1422 #[test]
1423 fn figures_get_sequential_document_order_numbers() {
1424 let mut doc = Document::new(PathBuf::from("test.mos"));
1428 let first = make_node(&mut doc, NodeKind::Figure, Some("fig:a"), None);
1429 let middle = make_node(&mut doc, NodeKind::Figure, None, None);
1430 let last = make_node(&mut doc, NodeKind::Figure, Some("fig:c"), None);
1431
1432 let diags = resolve(&mut doc, &BTreeSet::new());
1433 assert!(diags.is_empty(), "{diags:?}");
1434
1435 assert_eq!(node_number(&doc, first), "1");
1436 assert_eq!(
1437 node_number(&doc, middle),
1438 "2",
1439 "unlabelled figures are still numbered"
1440 );
1441 assert_eq!(node_number(&doc, last), "3");
1442 }
1443
1444 #[test]
1445 fn figures_and_sections_use_independent_counters() {
1446 let mut doc = Document::new(PathBuf::from("test.mos"));
1450 let sec_one = make_node(&mut doc, NodeKind::Section, Some("sec:a"), None);
1451 let fig_one = make_node(&mut doc, NodeKind::Figure, Some("fig:a"), None);
1452 let sec_two = make_node(&mut doc, NodeKind::Section, Some("sec:b"), None);
1453 let fig_two = make_node(&mut doc, NodeKind::Figure, Some("fig:b"), None);
1454
1455 let diags = resolve(&mut doc, &BTreeSet::new());
1456 assert!(diags.is_empty(), "{diags:?}");
1457
1458 assert_eq!(node_number(&doc, sec_one), "1");
1459 assert_eq!(node_number(&doc, sec_two), "2");
1460 assert_eq!(node_number(&doc, fig_one), "1");
1461 assert_eq!(node_number(&doc, fig_two), "2");
1462 }
1463
1464 #[test]
1465 fn section_target_index_carries_resolved_number() {
1466 let (doc, diags) = lower("= Intro <intro>\n\n== Methods <methods>\n");
1467 assert!(diags.is_empty(), "{diags:?}");
1468
1469 let mut sink: Vec<Diagnostic> = Vec::new();
1470 let index = build_label_index(&doc, &mut sink);
1471 assert!(sink.is_empty(), "{sink:?}");
1472
1473 assert_eq!(
1474 index.get("intro").map(|t| &t.kind),
1475 Some(&LabelTargetKind::Section {
1476 number: "1".to_owned()
1477 })
1478 );
1479 assert_eq!(
1480 index.get("methods").map(|t| &t.kind),
1481 Some(&LabelTargetKind::Section {
1482 number: "1.1".to_owned()
1483 })
1484 );
1485 }
1486
1487 #[test]
1488 fn level_three_numbers_correctly() {
1489 let (doc, diags) = lower("= A\n\n== B\n\n=== C\n\n== D\n\n= E\n");
1490 assert!(diags.is_empty(), "{diags:?}");
1491 let nums: Vec<String> = doc
1492 .nodes()
1493 .filter(|n| n.kind == NodeKind::Section)
1494 .filter_map(|n| match n.attributes.get("number") {
1495 Some(AttrValue::Str(s)) => Some(s.clone()),
1496 _ => None,
1497 })
1498 .collect();
1499 assert_eq!(nums, vec!["1", "1.1", "1.1.1", "1.2", "2"]);
1500 }
1501
1502 #[test]
1503 fn unknown_reference_suggests_nearest_label() {
1504 let src = "= Intro <intro>\n\nsee @intrdo\n";
1507 let (doc, diags) = lower(src);
1508 let mos0033: Vec<&Diagnostic> = diags
1509 .iter()
1510 .filter(|d| d.def().code() == codes::MOS0033.code())
1511 .collect();
1512 assert_eq!(
1513 mos0033.len(),
1514 1,
1515 "expected exactly one MOS0033, got {diags:?}"
1516 );
1517 let d = mos0033[0];
1518 assert!(
1520 d.message().contains("`intrdo`"),
1521 "message should still name the missing label, got {:?}",
1522 d.message()
1523 );
1524 assert_eq!(
1525 d.span().map(|span| &src[span.start()..span.end()]),
1526 Some("@intrdo"),
1527 "MOS0033 span should still cover the bad reference exactly"
1528 );
1529 let suggestions = d.suggestions();
1531 assert_eq!(
1532 suggestions.len(),
1533 1,
1534 "expected one nearest-label suggestion, got {suggestions:?}"
1535 );
1536 if let Some(suggestion) = suggestions.first() {
1537 assert_eq!(
1538 &src[suggestion.span.start()..suggestion.span.end()],
1539 "@intrdo",
1540 "suggestion should replace the whole `@` reference token"
1541 );
1542 assert_eq!(suggestion.replacement, "@intro");
1543 assert_eq!(
1544 apply_suggestion(src, suggestion),
1545 "= Intro <intro>\n\nsee @intro\n",
1546 "applying the fix should rewrite `@intrdo` to `@intro`"
1547 );
1548 }
1549 let reference_text = doc
1551 .nodes()
1552 .find(|n| n.kind == NodeKind::Reference)
1553 .and_then(|n| n.attributes.get("text"));
1554 assert_eq!(reference_text, Some(&AttrValue::Str("?intrdo?".to_owned())));
1555 }
1556
1557 #[test]
1558 fn unknown_reference_suggestion_breaks_ties_deterministically() {
1559 let src = "= A <intra>\n\n= B <intro>\n\nsee @intrx\n";
1563 let (_doc, diags) = lower(src);
1564 let mos0033: Vec<&Diagnostic> = diags
1565 .iter()
1566 .filter(|d| d.def().code() == codes::MOS0033.code())
1567 .collect();
1568 assert_eq!(mos0033.len(), 1, "got {diags:?}");
1569 if let Some(d) = mos0033.first() {
1570 let suggestions = d.suggestions();
1571 assert_eq!(
1572 suggestions.len(),
1573 1,
1574 "exactly one nearest-label suggestion, got {suggestions:?}"
1575 );
1576 if let Some(suggestion) = suggestions.first() {
1577 assert_eq!(
1578 suggestion.replacement, "@intra",
1579 "ties must resolve to the lexicographically smaller label"
1580 );
1581 }
1582 }
1583 }
1584
1585 #[test]
1586 fn unknown_reference_without_close_match_has_no_suggestion() {
1587 let src = "= Intro <intro>\n\nsee @conclusion\n";
1589 let (_doc, diags) = lower(src);
1590 let mos0033: Vec<&Diagnostic> = diags
1591 .iter()
1592 .filter(|d| d.def().code() == codes::MOS0033.code())
1593 .collect();
1594 assert_eq!(mos0033.len(), 1, "got {diags:?}");
1595 if let Some(d) = mos0033.first() {
1596 assert!(
1597 d.suggestions().is_empty(),
1598 "an unrelated label must not be suggested, got {:?}",
1599 d.suggestions()
1600 );
1601 }
1602 }
1603
1604 #[test]
1605 fn short_unknown_reference_has_no_suggestion() {
1606 let src = "= A <ax>\n\nsee @ab\n";
1609 let (_doc, diags) = lower(src);
1610 let mos0033: Vec<&Diagnostic> = diags
1611 .iter()
1612 .filter(|d| d.def().code() == codes::MOS0033.code())
1613 .collect();
1614 assert_eq!(mos0033.len(), 1, "got {diags:?}");
1615 if let Some(d) = mos0033.first() {
1616 assert!(
1617 d.suggestions().is_empty(),
1618 "short references must not be guessed, got {:?}",
1619 d.suggestions()
1620 );
1621 }
1622 }
1623
1624 #[test]
1625 fn unreferenceable_label_is_not_suggested() {
1626 let mut doc = Document::new(PathBuf::from("test.mos"));
1631 let _figure = make_node(&mut doc, NodeKind::Figure, Some("intro x"), None);
1632 let _reference = make_node(&mut doc, NodeKind::Reference, Some("introx"), None);
1633
1634 let mut diagnostics: Vec<Diagnostic> = Vec::new();
1635 let index = build_label_index(&doc, &mut diagnostics);
1636 let changed = rewrite_references(&mut doc, &index, &BTreeSet::new(), &mut diagnostics);
1637 assert!(!changed, "an unknown reference rewrites no text");
1638
1639 let mos0033: Vec<&Diagnostic> = diagnostics
1640 .iter()
1641 .filter(|d| d.def().code() == codes::MOS0033.code())
1642 .collect();
1643 assert_eq!(mos0033.len(), 1, "got {diagnostics:?}");
1644 if let Some(d) = mos0033.first() {
1645 assert!(
1646 d.suggestions().is_empty(),
1647 "an unreferenceable label must not be suggested, got {:?}",
1648 d.suggestions()
1649 );
1650 }
1651 }
1652}