Skip to main content

mos_eval/
bibliography.rs

1//! Lower `#bibliography(...)` directives into a semantic node.
2//!
3//! This is the *source boundary* only (manifest ยง4, MVP 4): the directive
4//! declares one bibliography database path, which is resolved relative to
5//! the current `.mos` source file and stashed on a
6//! [`NodeKind::Bibliography`] node. After lowering, citation keys are checked
7//! against the parsed BibTeX records from those declared sources, resolved
8//! citations get numeric `[N]` markers, and the first bibliography node
9//! receives one rendered entry child per cited key in first-use order
10//! (numeric slice only; CSL styles are a later slice).
11//!
12//! Diagnostics:
13//!
14//! - `MOS0040`: `#bibliography(...)` called without a (non-empty) path.
15//! - `MOS0042`: `#bibliography(...)` declared more than one path; first wins.
16//! - `MOS0020`: the path argument is present but not a string.
17//! - `MOS0015`: an unknown keyword argument was supplied.
18//! - `MOS0041`: the resolved path does not point to a file on disk, or is not
19//!   valid UTF-8 (a warning; the node is still emitted, with `resolved_path`
20//!   only in the first case, so an unloadable source counts as incomplete).
21//! - `MOS0045`: a citation key does not exist in a complete parsed bibliography set.
22//! - `MOS0046`: a citation key appears in more than one declared bibliography source.
23
24use 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
37/// Named keys accepted by [`bibliography_path`]; the MOS0015 nearest-match
38/// candidate set. Keep in sync with the match arms.
39const BIBLIOGRAPHY_KEYS: &[&str] = &["src", "path"];
40
41/// Lower `#bibliography("refs.bib")` into a bibliography node.
42///
43/// The literal path is recorded under `src`; the path resolved against the
44/// source file's directory is recorded under `resolved_path`.
45pub(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    // The directive only *declares* the source in this slice, so a missing
70    // file is a non-fatal warning rather than the hard error `#image(...)`
71    // raises: the node is still emitted with its resolved path, and the
72    // BibTeX-reading slice surfaces a read/parse error when it opens the
73    // database for real.
74    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
116/// Pull the single source path out of the directive arguments. A leading
117/// positional string (`#bibliography("refs.bib")`) or the named
118/// `path:`/`src:` forms are accepted, mirroring `#image(...)`. Returns
119/// `None` (after emitting a diagnostic) when the path is missing, empty,
120/// or not a string.
121fn 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            // Positional first arg -- the path literal, same shorthand
131            // `#image("path.png")` accepts.
132            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    // A bare empty / whitespace-only path is the same mistake as omitting
215    // it -- they wrote `#bibliography("")` and meant to fill in a filename.
216    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
230/// Resolve citation keys against the already-loaded bibliography sources.
231///
232/// Known citations are rewritten to dense numeric labels assigned by first-use
233/// order. Unknown keys emit `MOS0045` and keep `[?key?]` placeholders.
234///
235/// Numbering is dense over *known* citations: a key consumes a number only
236/// when it resolves, and repeated uses of the same key reuse its first
237/// number. Unresolved keys never burn a slot, so `[1]`, `[2]`, ... always
238/// index real bibliography records. This is the numeric-placeholder slice
239/// (issue #67), not full CSL: no author-year styles, sorted output, or
240/// citation clusters.
241/// Resolve `[@key]` citations and return the records loaded from the
242/// declared bibliography sources, merged across sources, and whether every
243/// source loaded successfully. The reference
244/// resolver consumes their key set to tell an `@key` label reference that
245/// *misses* the label index but *matches* a bibliography key apart -- a
246/// near-certain "meant a citation" mistake -- from a plain unknown label (see
247/// [`crate::resolve::resolve`]); the LSP offers the same records as citation
248/// completions.
249pub(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    // The entry list attaches to the *first* Bibliography node in document
260    // order: one bibliography section is the typical document shape, and
261    // partitioning entries across several declarations has no defined
262    // semantics in this numeric slice. Later declarations still contribute
263    // records; their nodes just stay childless.
264    let bibliography_node = document
265        .nodes()
266        .find(|node| node.kind == NodeKind::Bibliography)
267        .map(|node| (node.id, node.span.clone()));
268
269    // `nodes()` walks the `BTreeMap<NodeId, Node>` in `NodeId` order, which is
270    // the lowerer's allocation order -- i.e. document order. Collecting the ids
271    // above preserves that order, so the first new key encountered here is the
272    // document's first-cited key.
273    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
334/// Append one rendered entry per *cited* key as children of the first
335/// [`NodeKind::Bibliography`] node, ordered by first-use citation number.
336///
337/// Each entry is a [`NodeKind::Paragraph`] carrying `entry_number` /
338/// `entry_key` attributes and one [`NodeKind::Text`] child with the
339/// [`format_entry`] text, so layout can render the list with `[N]` markers
340/// without re-reading BibTeX sources. Uncited records add nothing, and
341/// unresolved keys never made it into `numbers`, so the numbered entries
342/// always match the citation markers in the prose.
343fn 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
379/// Render one BibTeX entry as plain text for the bibliography list.
380///
381/// Not a CSL processor: a fixed `Author. Title. Venue, volume, pp. pages.
382/// Publisher. Year.` order where every missing field simply drops out.
383/// Brace protection is stripped and whitespace collapsed via
384/// [`clean_field`]; a part that already ends in `.` (initials like
385/// `Knuth, D. E.`) is not double-terminated. An entry with no usable fields
386/// falls back to its citation key so a numbered slot never renders empty.
387fn 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    // Venue clause: journal (or booktitle), then volume and pages appended
400    // as `, {volume}` / `, pp. {pages}` so they stay attached to the venue
401    // they qualify. Without a venue they still surface on their own.
402    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
435/// Strip BibTeX brace protection and collapse runs of whitespace, so field
436/// values like `{The {TeX}book}` render as `The TeXbook`.
437fn 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
470/// The single loaded citation key that is a conservative near-miss for
471/// `unknown`, if any. Candidate filtering stays here ([`is_citation_key`]);
472/// the selection rule (length floor, `len / 3` bound, ties suggest nothing)
473/// lives in [`crate::suggest::nearest_match`].
474fn 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}