1use std::collections::BTreeMap;
25use std::path::PathBuf;
26
27use mos_bib::Bibliography;
28use mos_core::{
29 AttrMap, AttrValue, Diagnostic, Document, NodeId, NodeKind, NodeSpec, SourceSpan, Suggestion,
30 codes,
31};
32use mos_parse::{SetArg, SetValue};
33
34use crate::dependency::{DependencySet, ExternalInputs, fingerprint_file, read_fingerprinted};
35use crate::suggest;
36
37const BIBLIOGRAPHY_KEYS: &[&str] = &["src", "path"];
40
41pub(crate) fn lower_bibliography_directive(
46 document: &mut Document,
47 root: NodeId,
48 args: &[SetArg],
49 span: &SourceSpan,
50 inputs: &mut ExternalInputs<'_>,
51 diagnostics: &mut Vec<Diagnostic>,
52) {
53 let Some((path, path_span)) = bibliography_path(args, span, diagnostics) else {
54 return;
55 };
56 let resolved = match mos_core::resolve_source_path(&path, inputs.source_file) {
57 Ok(resolved) => resolved,
58 Err(err) => {
59 diagnostics.push(suggest::unsafe_path_diagnostic(
60 format!("cannot use bibliography path `{path}`: {err}"),
61 &path,
62 span,
63 &path_span,
64 ));
65 return;
66 }
67 };
68 let resolved_text = resolved.to_str();
69 if resolved_text.is_none() {
75 inputs
76 .dependencies
77 .record(resolved.clone(), fingerprint_file(&resolved));
78 diagnostics.push(
79 Diagnostic::simple(
80 &codes::MOS0041,
81 None,
82 format!(
83 "declared bibliography source `{}` has a non-UTF-8 path and cannot be loaded",
84 mos_core::display_path(&resolved)
85 ),
86 )
87 .with_span(span.clone()),
88 );
89 } else if !resolved.is_file() {
90 diagnostics.push(
91 Diagnostic::simple(
92 &codes::MOS0041,
93 None,
94 format!(
95 "declared bibliography source `{}` was not found",
96 mos_core::display_path(&resolved)
97 ),
98 )
99 .with_span(span.clone()),
100 );
101 }
102 let mut attributes: AttrMap = BTreeMap::new();
103 attributes.insert("src".to_owned(), AttrValue::Str(path));
104 if let Some(resolved_text) = resolved_text {
105 attributes.insert(
106 "resolved_path".to_owned(),
107 AttrValue::Str(resolved_text.to_owned()),
108 );
109 }
110 document.alloc_child(
111 root,
112 NodeSpec::new(NodeKind::Bibliography, span.clone()).with_attributes(attributes),
113 );
114}
115
116fn bibliography_path(
122 args: &[SetArg],
123 span: &SourceSpan,
124 diagnostics: &mut Vec<Diagnostic>,
125) -> Option<(String, SourceSpan)> {
126 let mut path: Option<(String, SourceSpan)> = None;
127 let mut invalid_path_arg = false;
128 for arg in args {
129 match arg {
130 SetArg::Positional { value, value_span } => {
133 if let SetValue::Str(s) = value {
134 if path.is_some() {
135 diagnostics.push(
136 Diagnostic::simple(
137 &codes::MOS0042,
138 None,
139 "duplicate path argument for `#bibliography`",
140 )
141 .with_span(value_span.clone()),
142 );
143 } else {
144 path = Some((s.clone(), value_span.clone()));
145 }
146 } else {
147 invalid_path_arg = true;
148 diagnostics.push(
149 Diagnostic::simple(
150 &codes::MOS0020,
151 None,
152 "`#bibliography(...)` expects a string path",
153 )
154 .with_span(value_span.clone()),
155 );
156 }
157 }
158 SetArg::Named {
159 key,
160 value,
161 key_span,
162 value_span,
163 } => match key.as_str() {
164 "src" | "path" => {
165 if let SetValue::Str(s) = value {
166 if path.is_some() {
167 diagnostics.push(
168 Diagnostic::simple(
169 &codes::MOS0042,
170 None,
171 "duplicate path argument for `#bibliography`",
172 )
173 .with_span(value_span.clone()),
174 );
175 } else {
176 path = Some((s.clone(), value_span.clone()));
177 }
178 } else {
179 invalid_path_arg = true;
180 diagnostics.push(
181 Diagnostic::simple(
182 &codes::MOS0020,
183 None,
184 "`#bibliography(...)` expects a string path",
185 )
186 .with_span(value_span.clone()),
187 );
188 }
189 }
190 _ => diagnostics.push(suggest::unknown_key_diagnostic(
191 format!("unknown argument `{key}` for `#bibliography` (valid: src/path)"),
192 key,
193 key_span,
194 BIBLIOGRAPHY_KEYS,
195 )),
196 },
197 }
198 }
199 let Some(path) = path else {
200 if invalid_path_arg {
201 return None;
202 }
203 diagnostics.push(
204 Diagnostic::simple(
205 &codes::MOS0040,
206 None,
207 "`#bibliography(...)` requires a path (e.g. `#bibliography(\"refs.bib\")`)",
208 )
209 .with_span(span.clone()),
210 );
211 return None;
212 };
213 let (path_text, path_span) = path;
214 if path_text.trim().is_empty() {
217 diagnostics.push(
218 Diagnostic::simple(
219 &codes::MOS0040,
220 None,
221 "`#bibliography(...)` requires a non-empty path (e.g. `#bibliography(\"refs.bib\")`)",
222 )
223 .with_span(span.clone()),
224 );
225 return None;
226 }
227 Some((path_text, path_span))
228}
229
230pub(crate) fn resolve_citations(
250 document: &mut Document,
251 diagnostics: &mut Vec<Diagnostic>,
252 bibliography: LoadedBibliography,
253) -> (Bibliography, bool) {
254 let citation_ids: Vec<NodeId> = document
255 .nodes()
256 .filter(|node| node.kind == NodeKind::Citation)
257 .map(|node| node.id)
258 .collect();
259 let bibliography_node = document
265 .nodes()
266 .find(|node| node.kind == NodeKind::Bibliography)
267 .map(|node| (node.id, node.span.clone()));
268
269 let mut numbers: BTreeMap<String, usize> = BTreeMap::new();
274
275 for citation_id in citation_ids {
276 let Some(node) = document.get(citation_id) else {
277 continue;
278 };
279 let Some(AttrValue::Str(key)) = node.attributes.get("key").cloned() else {
280 continue;
281 };
282 if bibliography.records.entries.contains_key(&key) {
283 let next_number = numbers.len() + 1;
284 let number = *numbers.entry(key.clone()).or_insert(next_number);
285 if let Some(node) = document.get_mut(citation_id) {
286 node.attributes
287 .insert("resolved".to_owned(), AttrValue::Bool(true));
288 node.attributes
289 .insert("text".to_owned(), AttrValue::Str(format!("[{number}]")));
290 if let Some(origin) = bibliography.origins.get(&key) {
291 node.attributes.insert(
292 "target_path".to_owned(),
293 AttrValue::Str(origin.path.to_string_lossy().into_owned()),
294 );
295 if let (Ok(start), Ok(end)) = (
296 i64::try_from(origin.key_span.start()),
297 i64::try_from(origin.key_span.end()),
298 ) {
299 node.attributes
300 .insert("target_span.start".to_owned(), AttrValue::Int(start));
301 node.attributes
302 .insert("target_span.end".to_owned(), AttrValue::Int(end));
303 }
304 }
305 }
306 continue;
307 }
308 if !bibliography.complete {
309 continue;
310 }
311 let mut diagnostic = Diagnostic::simple(
312 &codes::MOS0045,
313 Some(node.span.clone()),
314 format!("unknown citation key `{key}` in bibliography records"),
315 )
316 .with_annotation(mos_core::DiagnosticAnnotation::Hint(
317 "declare the key in a `#bibliography(...)` BibTeX source".to_owned(),
318 ));
319 if let Some(candidate) = nearest_citation_key(&key, &bibliography.records.entries)
320 && let Some(span) = citation_key_span(node, &key)
321 {
322 diagnostic = diagnostic.with_suggestion(Suggestion::new(span, candidate));
323 }
324 diagnostics.push(diagnostic);
325 }
326
327 if let Some((bib_id, bib_span)) = bibliography_node {
328 append_bibliography_entries(document, bib_id, &bib_span, &bibliography, &numbers);
329 }
330
331 (bibliography.records, bibliography.complete)
332}
333
334fn append_bibliography_entries(
344 document: &mut Document,
345 bib_id: NodeId,
346 bib_span: &SourceSpan,
347 bibliography: &LoadedBibliography,
348 numbers: &BTreeMap<String, usize>,
349) {
350 let mut ordered: Vec<(usize, &str)> = numbers
351 .iter()
352 .map(|(key, number)| (*number, key.as_str()))
353 .collect();
354 ordered.sort_unstable();
355
356 for (number, key) in ordered {
357 let Some(entry) = bibliography.records.entries.get(key) else {
358 continue;
359 };
360 let mut entry_attrs: AttrMap = BTreeMap::new();
361 entry_attrs.insert(
362 "entry_number".to_owned(),
363 AttrValue::Int(i64::try_from(number).unwrap_or(i64::MAX)),
364 );
365 entry_attrs.insert("entry_key".to_owned(), AttrValue::Str(key.to_owned()));
366 let paragraph = document.alloc_child(
367 bib_id,
368 NodeSpec::new(NodeKind::Paragraph, bib_span.clone()).with_attributes(entry_attrs),
369 );
370 let mut text_attrs: AttrMap = BTreeMap::new();
371 text_attrs.insert("text".to_owned(), AttrValue::Str(format_entry(key, entry)));
372 document.alloc_child(
373 paragraph,
374 NodeSpec::new(NodeKind::Text, bib_span.clone()).with_attributes(text_attrs),
375 );
376 }
377}
378
379fn format_entry(key: &str, entry: &mos_bib::BibEntry) -> String {
388 let field = |name: &str| {
389 entry
390 .field_text(name)
391 .map(clean_field)
392 .filter(|value| !value.is_empty())
393 };
394
395 let mut parts: Vec<String> = Vec::new();
396 parts.extend(field("author"));
397 parts.extend(field("title"));
398
399 let mut venue = field("journal").or_else(|| field("booktitle"));
403 if let Some(volume) = field("volume") {
404 venue = Some(match venue {
405 Some(venue) => format!("{venue}, {volume}"),
406 None => volume,
407 });
408 }
409 if let Some(pages) = field("pages") {
410 venue = Some(match venue {
411 Some(venue) => format!("{venue}, pp. {pages}"),
412 None => format!("pp. {pages}"),
413 });
414 }
415 parts.extend(venue);
416 parts.extend(field("publisher"));
417 parts.extend(field("year"));
418
419 if parts.is_empty() {
420 return key.to_owned();
421 }
422 let mut out = String::new();
423 for part in &parts {
424 if !out.is_empty() {
425 out.push(' ');
426 }
427 out.push_str(part);
428 if !part.ends_with('.') {
429 out.push('.');
430 }
431 }
432 out
433}
434
435fn clean_field(raw: &str) -> String {
438 let mut out = String::with_capacity(raw.len());
439 let mut pending_space = false;
440 for ch in raw.chars() {
441 if matches!(ch, '{' | '}') {
442 continue;
443 }
444 if ch.is_whitespace() {
445 pending_space = !out.is_empty();
446 continue;
447 }
448 if pending_space {
449 out.push(' ');
450 pending_space = false;
451 }
452 out.push(ch);
453 }
454 out
455}
456
457fn citation_key_span(node: &mos_core::Node, key: &str) -> Option<SourceSpan> {
458 let start = node.span.start().checked_add(2)?;
459 let end = start.checked_add(key.len())?;
460 (end < node.span.end()).then(|| SourceSpan::new(node.span.file.clone(), start, end))
461}
462
463fn is_citation_key(key: &str) -> bool {
464 !key.is_empty()
465 && key
466 .bytes()
467 .all(|b| b.is_ascii_alphanumeric() || matches!(b, b'_' | b'-' | b':' | b'.'))
468}
469
470fn nearest_citation_key(
475 unknown: &str,
476 records: &BTreeMap<String, mos_bib::BibEntry>,
477) -> Option<String> {
478 suggest::nearest_match(
479 unknown,
480 records
481 .keys()
482 .filter(|key| is_citation_key(key))
483 .map(String::as_str),
484 )
485 .map(str::to_owned)
486}
487
488pub(crate) struct LoadedBibliography {
489 records: Bibliography,
490 origins: BTreeMap<String, BibliographyOrigin>,
491 complete: bool,
492}
493
494struct BibliographyOrigin {
495 path: PathBuf,
496 key_span: SourceSpan,
497}
498
499pub(crate) fn load_bibliography(
500 document: &Document,
501 diagnostics: &mut Vec<Diagnostic>,
502 dependencies: &mut DependencySet,
503) -> LoadedBibliography {
504 let mut merged = Bibliography::default();
505 let mut origins: BTreeMap<String, BibliographyOrigin> = BTreeMap::new();
506 let mut complete = true;
507 for node in document
508 .nodes()
509 .filter(|node| node.kind == NodeKind::Bibliography)
510 {
511 let Some(AttrValue::Str(path)) = node.attributes.get("resolved_path") else {
512 complete = false;
513 continue;
514 };
515 let path_buf = PathBuf::from(path);
516 if !path_buf.is_file() {
517 dependencies.record(path_buf, None);
518 complete = false;
519 continue;
520 }
521 let unreadable = |err: &dyn std::fmt::Display| {
522 Diagnostic::simple(
523 &codes::MOS0041,
524 Some(node.span.clone()),
525 format!(
526 "declared bibliography source `{}` could not be read: {err}",
527 mos_core::display_path(&path_buf)
528 ),
529 )
530 };
531 let bytes = match read_fingerprinted(&path_buf) {
532 Ok((bytes, fingerprint)) => {
533 dependencies.record(path_buf.clone(), Some(fingerprint));
534 bytes
535 }
536 Err(err) => {
537 dependencies.record(path_buf.clone(), None);
538 complete = false;
539 diagnostics.push(unreadable(&err));
540 continue;
541 }
542 };
543 let source = match String::from_utf8(bytes) {
544 Ok(source) => source,
545 Err(err) => {
546 complete = false;
547 diagnostics.push(unreadable(&err));
548 continue;
549 }
550 };
551 match mos_bib::parse_bibtex(&source) {
552 Ok(parsed) => {
553 for (key, entry) in parsed.entries {
554 let key_span =
555 SourceSpan::new(path_buf.clone(), entry.key_span.start, entry.key_span.end);
556 if let Some(first) = origins.get(&key) {
557 diagnostics.push(
558 Diagnostic::simple(
559 &codes::MOS0046,
560 Some(node.span.clone()),
561 format!(
562 "duplicate citation key `{key}` in bibliography source `{}`",
563 mos_core::display_path(&path_buf)
564 ),
565 )
566 .with_annotation(mos_core::DiagnosticAnnotation::Related {
567 span: first.key_span.clone(),
568 message: format!(
569 "first bibliography source for `{key}` was `{}`",
570 mos_core::display_path(&first.path)
571 ),
572 })
573 .with_annotation(mos_core::DiagnosticAnnotation::Hint(
574 "keep citation keys unique across all declared bibliography sources"
575 .to_owned(),
576 )),
577 );
578 } else {
579 origins.insert(
580 key.clone(),
581 BibliographyOrigin {
582 path: path_buf.clone(),
583 key_span,
584 },
585 );
586 merged.entries.insert(key, entry);
587 }
588 }
589 }
590 Err(err) => {
591 complete = false;
592 diagnostics.push(err.to_diagnostic(path_buf));
593 }
594 }
595 }
596 LoadedBibliography {
597 records: merged,
598 origins,
599 complete,
600 }
601}
602
603#[cfg(test)]
604mod tests {
605 #![allow(
606 clippy::unwrap_used,
607 clippy::expect_used,
608 reason = "tests panic loudly on setup failure; matches crate-wide test-module convention"
609 )]
610
611 use std::collections::BTreeMap;
612
613 use mos_bib::BibEntry;
614
615 use super::{clean_field, format_entry};
616
617 fn entry(fields: &[(&str, &str)]) -> BibEntry {
618 BibEntry {
619 entry_type: "book".to_owned(),
620 key: "key".to_owned(),
621 key_span: 0..3,
622 fields: fields
623 .iter()
624 .map(|(name, value)| ((*name).to_owned(), (*value).to_owned()))
625 .collect::<BTreeMap<String, String>>(),
626 }
627 }
628
629 #[test]
630 fn full_entry_renders_all_clauses_in_order() {
631 let entry = entry(&[
632 ("author", "Knuth, Donald E."),
633 ("title", "Literate Programming"),
634 ("journal", "The Computer Journal"),
635 ("volume", "27"),
636 ("pages", "97--111"),
637 ("year", "1984"),
638 ]);
639 assert_eq!(
640 format_entry("knuth1984", &entry),
641 "Knuth, Donald E. Literate Programming. The Computer Journal, 27, pp. 97--111. 1984."
642 );
643 }
644
645 #[test]
646 fn part_ending_in_period_is_not_double_terminated() {
647 let entry = entry(&[("author", "Knuth, D. E."), ("title", "The TeXbook")]);
648 assert_eq!(
649 format_entry("knuth", &entry),
650 "Knuth, D. E. The TeXbook.",
651 "initials already end the author clause"
652 );
653 }
654
655 #[test]
656 fn title_only_entry_renders_just_the_title() {
657 let entry = entry(&[("title", "Alone")]);
658 assert_eq!(format_entry("solo", &entry), "Alone.");
659 }
660
661 #[test]
662 fn entry_without_useful_fields_falls_back_to_its_key() {
663 let entry = entry(&[("isbn", "978-0")]);
664 assert_eq!(format_entry("fallback2001", &entry), "fallback2001");
665 }
666
667 #[test]
668 fn booktitle_and_bare_pages_still_surface() {
669 let entry = entry(&[("booktitle", "Proc. of Mosaic"), ("pages", "1--7")]);
670 assert_eq!(
671 format_entry("conf", &entry),
672 "Proc. of Mosaic, pp. 1--7.",
673 "booktitle stands in for journal; pages attach to the venue"
674 );
675 }
676
677 #[test]
678 fn clean_field_strips_braces_and_collapses_whitespace() {
679 assert_eq!(clean_field("{The {TeX}book}"), "The TeXbook");
680 assert_eq!(clean_field(" spaced\n\tout "), "spaced out");
681 assert_eq!(clean_field("{}"), "");
682 }
683
684 #[test]
685 fn quoted_bibtex_values_render_without_their_quotes() {
686 let entry = entry(&[
687 ("author", r#""Knuth, Donald E.""#),
688 ("title", "{Literate Programming}"),
689 ("year", "1984"),
690 ]);
691 assert_eq!(
692 format_entry("knuth1984", &entry),
693 "Knuth, Donald E. Literate Programming. 1984."
694 );
695 }
696}