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::{ExternalInputs, ResourceReader, ResourceSnapshot};
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 let read = inputs.resources.read(inputs.reader, &resolved);
70 if resolved_text.is_none() {
76 diagnostics.push(
77 Diagnostic::simple(
78 &codes::MOS0041,
79 None,
80 format!(
81 "declared bibliography source `{}` has a non-UTF-8 path and cannot be loaded",
82 mos_core::display_path(&resolved)
83 ),
84 )
85 .with_span(span.clone()),
86 );
87 } else if read.is_err_and(|err| {
88 matches!(
89 err.kind(),
90 std::io::ErrorKind::NotFound | std::io::ErrorKind::InvalidInput
91 )
92 }) {
93 diagnostics.push(
94 Diagnostic::simple(
95 &codes::MOS0041,
96 None,
97 format!(
98 "declared bibliography source `{}` was not found",
99 mos_core::display_path(&resolved)
100 ),
101 )
102 .with_span(span.clone()),
103 );
104 }
105 let mut attributes: AttrMap = BTreeMap::new();
106 attributes.insert("src".to_owned(), AttrValue::Str(path));
107 if let Some(resolved_text) = resolved_text {
108 attributes.insert(
109 "resolved_path".to_owned(),
110 AttrValue::Str(resolved_text.to_owned()),
111 );
112 }
113 document.alloc_child(
114 root,
115 NodeSpec::new(NodeKind::Bibliography, span.clone()).with_attributes(attributes),
116 );
117}
118
119fn bibliography_path(
125 args: &[SetArg],
126 span: &SourceSpan,
127 diagnostics: &mut Vec<Diagnostic>,
128) -> Option<(String, SourceSpan)> {
129 let mut path: Option<(String, SourceSpan)> = None;
130 let mut invalid_path_arg = false;
131 for arg in args {
132 match arg {
133 SetArg::Positional { value, value_span } => {
136 if let SetValue::Str(s) = value {
137 if path.is_some() {
138 diagnostics.push(
139 Diagnostic::simple(
140 &codes::MOS0042,
141 None,
142 "duplicate path argument for `#bibliography`",
143 )
144 .with_span(value_span.clone()),
145 );
146 } else {
147 path = Some((s.clone(), value_span.clone()));
148 }
149 } else {
150 invalid_path_arg = true;
151 diagnostics.push(
152 Diagnostic::simple(
153 &codes::MOS0020,
154 None,
155 "`#bibliography(...)` expects a string path",
156 )
157 .with_span(value_span.clone()),
158 );
159 }
160 }
161 SetArg::Named {
162 key,
163 value,
164 key_span,
165 value_span,
166 } => match key.as_str() {
167 "src" | "path" => {
168 if let SetValue::Str(s) = value {
169 if path.is_some() {
170 diagnostics.push(
171 Diagnostic::simple(
172 &codes::MOS0042,
173 None,
174 "duplicate path argument for `#bibliography`",
175 )
176 .with_span(value_span.clone()),
177 );
178 } else {
179 path = Some((s.clone(), value_span.clone()));
180 }
181 } else {
182 invalid_path_arg = true;
183 diagnostics.push(
184 Diagnostic::simple(
185 &codes::MOS0020,
186 None,
187 "`#bibliography(...)` expects a string path",
188 )
189 .with_span(value_span.clone()),
190 );
191 }
192 }
193 _ => diagnostics.push(suggest::unknown_key_diagnostic(
194 format!("unknown argument `{key}` for `#bibliography` (valid: src/path)"),
195 key,
196 key_span,
197 BIBLIOGRAPHY_KEYS,
198 )),
199 },
200 }
201 }
202 let Some(path) = path else {
203 if invalid_path_arg {
204 return None;
205 }
206 diagnostics.push(
207 Diagnostic::simple(
208 &codes::MOS0040,
209 None,
210 "`#bibliography(...)` requires a path (e.g. `#bibliography(\"refs.bib\")`)",
211 )
212 .with_span(span.clone()),
213 );
214 return None;
215 };
216 let (path_text, path_span) = path;
217 if path_text.trim().is_empty() {
220 diagnostics.push(
221 Diagnostic::simple(
222 &codes::MOS0040,
223 None,
224 "`#bibliography(...)` requires a non-empty path (e.g. `#bibliography(\"refs.bib\")`)",
225 )
226 .with_span(span.clone()),
227 );
228 return None;
229 }
230 Some((path_text, path_span))
231}
232
233pub(crate) fn resolve_citations(
253 document: &mut Document,
254 diagnostics: &mut Vec<Diagnostic>,
255 bibliography: LoadedBibliography,
256) -> (Bibliography, bool) {
257 let citation_ids: Vec<NodeId> = document
258 .nodes()
259 .filter(|node| node.kind == NodeKind::Citation)
260 .map(|node| node.id)
261 .collect();
262 let bibliography_node = document
268 .nodes()
269 .find(|node| node.kind == NodeKind::Bibliography)
270 .map(|node| (node.id, node.span.clone()));
271
272 let mut numbers: BTreeMap<String, usize> = BTreeMap::new();
277
278 for citation_id in citation_ids {
279 let Some(node) = document.get(citation_id) else {
280 continue;
281 };
282 let Some(AttrValue::Str(key)) = node.attributes.get("key").cloned() else {
283 continue;
284 };
285 if bibliography.records.entries.contains_key(&key) {
286 let next_number = numbers.len() + 1;
287 let number = *numbers.entry(key.clone()).or_insert(next_number);
288 if let Some(node) = document.get_mut(citation_id) {
289 node.attributes
290 .insert("resolved".to_owned(), AttrValue::Bool(true));
291 node.attributes
292 .insert("text".to_owned(), AttrValue::Str(format!("[{number}]")));
293 if let Some(origin) = bibliography.origins.get(&key) {
294 node.attributes.insert(
295 "target_path".to_owned(),
296 AttrValue::Str(origin.path.to_string_lossy().into_owned()),
297 );
298 if let (Ok(start), Ok(end)) = (
299 i64::try_from(origin.key_span.start()),
300 i64::try_from(origin.key_span.end()),
301 ) {
302 node.attributes
303 .insert("target_span.start".to_owned(), AttrValue::Int(start));
304 node.attributes
305 .insert("target_span.end".to_owned(), AttrValue::Int(end));
306 }
307 }
308 }
309 continue;
310 }
311 if !bibliography.complete {
312 continue;
313 }
314 let mut diagnostic = Diagnostic::simple(
315 &codes::MOS0045,
316 Some(node.span.clone()),
317 format!("unknown citation key `{key}` in bibliography records"),
318 )
319 .with_annotation(mos_core::DiagnosticAnnotation::Hint(
320 "declare the key in a `#bibliography(...)` BibTeX source".to_owned(),
321 ));
322 if let Some(candidate) = nearest_citation_key(&key, &bibliography.records.entries)
323 && let Some(span) = citation_key_span(node, &key)
324 {
325 diagnostic = diagnostic.with_suggestion(Suggestion::new(span, candidate));
326 }
327 diagnostics.push(diagnostic);
328 }
329
330 if let Some((bib_id, bib_span)) = bibliography_node {
331 append_bibliography_entries(document, bib_id, &bib_span, &bibliography, &numbers);
332 }
333
334 (bibliography.records, bibliography.complete)
335}
336
337fn append_bibliography_entries(
347 document: &mut Document,
348 bib_id: NodeId,
349 bib_span: &SourceSpan,
350 bibliography: &LoadedBibliography,
351 numbers: &BTreeMap<String, usize>,
352) {
353 let mut ordered: Vec<(usize, &str)> = numbers
354 .iter()
355 .map(|(key, number)| (*number, key.as_str()))
356 .collect();
357 ordered.sort_unstable();
358
359 for (number, key) in ordered {
360 let Some(entry) = bibliography.records.entries.get(key) else {
361 continue;
362 };
363 let mut entry_attrs: AttrMap = BTreeMap::new();
364 entry_attrs.insert(
365 "entry_number".to_owned(),
366 AttrValue::Int(i64::try_from(number).unwrap_or(i64::MAX)),
367 );
368 entry_attrs.insert("entry_key".to_owned(), AttrValue::Str(key.to_owned()));
369 let paragraph = document.alloc_child(
370 bib_id,
371 NodeSpec::new(NodeKind::Paragraph, bib_span.clone()).with_attributes(entry_attrs),
372 );
373 let mut text_attrs: AttrMap = BTreeMap::new();
374 text_attrs.insert("text".to_owned(), AttrValue::Str(format_entry(key, entry)));
375 document.alloc_child(
376 paragraph,
377 NodeSpec::new(NodeKind::Text, bib_span.clone()).with_attributes(text_attrs),
378 );
379 }
380}
381
382fn format_entry(key: &str, entry: &mos_bib::BibEntry) -> String {
391 let field = |name: &str| {
392 entry
393 .field_text(name)
394 .map(clean_field)
395 .filter(|value| !value.is_empty())
396 };
397
398 let mut parts: Vec<String> = Vec::new();
399 parts.extend(field("author"));
400 parts.extend(field("title"));
401
402 let mut venue = field("journal").or_else(|| field("booktitle"));
406 if let Some(volume) = field("volume") {
407 venue = Some(match venue {
408 Some(venue) => format!("{venue}, {volume}"),
409 None => volume,
410 });
411 }
412 if let Some(pages) = field("pages") {
413 venue = Some(match venue {
414 Some(venue) => format!("{venue}, pp. {pages}"),
415 None => format!("pp. {pages}"),
416 });
417 }
418 parts.extend(venue);
419 parts.extend(field("publisher"));
420 parts.extend(field("year"));
421
422 if parts.is_empty() {
423 return key.to_owned();
424 }
425 let mut out = String::new();
426 for part in &parts {
427 if !out.is_empty() {
428 out.push(' ');
429 }
430 out.push_str(part);
431 if !part.ends_with('.') {
432 out.push('.');
433 }
434 }
435 out
436}
437
438fn clean_field(raw: &str) -> String {
441 let mut out = String::with_capacity(raw.len());
442 let mut pending_space = false;
443 for ch in raw.chars() {
444 if matches!(ch, '{' | '}') {
445 continue;
446 }
447 if ch.is_whitespace() {
448 pending_space = !out.is_empty();
449 continue;
450 }
451 if pending_space {
452 out.push(' ');
453 pending_space = false;
454 }
455 out.push(ch);
456 }
457 out
458}
459
460fn citation_key_span(node: &mos_core::Node, key: &str) -> Option<SourceSpan> {
461 let start = node.span.start().checked_add(2)?;
462 let end = start.checked_add(key.len())?;
463 (end < node.span.end()).then(|| SourceSpan::new(node.span.file.clone(), start, end))
464}
465
466fn is_citation_key(key: &str) -> bool {
467 !key.is_empty()
468 && key
469 .bytes()
470 .all(|b| b.is_ascii_alphanumeric() || matches!(b, b'_' | b'-' | b':' | b'.'))
471}
472
473fn nearest_citation_key(
478 unknown: &str,
479 records: &BTreeMap<String, mos_bib::BibEntry>,
480) -> Option<String> {
481 suggest::nearest_match(
482 unknown,
483 records
484 .keys()
485 .filter(|key| is_citation_key(key))
486 .map(String::as_str),
487 )
488 .map(str::to_owned)
489}
490
491pub(crate) struct LoadedBibliography {
492 records: Bibliography,
493 origins: BTreeMap<String, BibliographyOrigin>,
494 complete: bool,
495}
496
497struct BibliographyOrigin {
498 path: PathBuf,
499 key_span: SourceSpan,
500}
501
502pub(crate) fn load_bibliography(
503 document: &Document,
504 diagnostics: &mut Vec<Diagnostic>,
505 reader: &dyn ResourceReader,
506 resources: &mut ResourceSnapshot,
507) -> LoadedBibliography {
508 let mut merged = Bibliography::default();
509 let mut origins: BTreeMap<String, BibliographyOrigin> = BTreeMap::new();
510 let mut complete = true;
511 for node in document
512 .nodes()
513 .filter(|node| node.kind == NodeKind::Bibliography)
514 {
515 let Some(AttrValue::Str(path)) = node.attributes.get("resolved_path") else {
516 complete = false;
517 continue;
518 };
519 let path_buf = PathBuf::from(path);
520 let unreadable = |err: &dyn std::fmt::Display| {
521 Diagnostic::simple(
522 &codes::MOS0041,
523 Some(node.span.clone()),
524 format!(
525 "declared bibliography source `{}` could not be read: {err}",
526 mos_core::display_path(&path_buf)
527 ),
528 )
529 };
530 let bytes = match resources.read(reader, &path_buf) {
531 Ok(bytes) => bytes,
532 Err(err) => {
533 complete = false;
534 if !matches!(
535 err.kind(),
536 std::io::ErrorKind::NotFound | std::io::ErrorKind::InvalidInput
537 ) {
538 diagnostics.push(unreadable(&err));
539 }
540 continue;
541 }
542 };
543 let source = match std::str::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}