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::{ExternalInputs, ResourceReader, ResourceSnapshot};
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    let read = inputs.resources.read(inputs.reader, &resolved);
70    // The directive only *declares* the source in this slice, so a missing
71    // file is a non-fatal warning rather than the hard error `#image(...)`
72    // raises: the node is still emitted with its resolved path, and the
73    // bibliography loader surfaces other read/parse errors using these same
74    // captured bytes.
75    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
119/// Pull the single source path out of the directive arguments. A leading
120/// positional string (`#bibliography("refs.bib")`) or the named
121/// `path:`/`src:` forms are accepted, mirroring `#image(...)`. Returns
122/// `None` (after emitting a diagnostic) when the path is missing, empty,
123/// or not a string.
124fn 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            // Positional first arg -- the path literal, same shorthand
134            // `#image("path.png")` accepts.
135            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    // A bare empty / whitespace-only path is the same mistake as omitting
218    // it -- they wrote `#bibliography("")` and meant to fill in a filename.
219    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
233/// Resolve citation keys against the already-loaded bibliography sources.
234///
235/// Known citations are rewritten to dense numeric labels assigned by first-use
236/// order. Unknown keys emit `MOS0045` and keep `[?key?]` placeholders.
237///
238/// Numbering is dense over *known* citations: a key consumes a number only
239/// when it resolves, and repeated uses of the same key reuse its first
240/// number. Unresolved keys never burn a slot, so `[1]`, `[2]`, ... always
241/// index real bibliography records. This is the numeric-placeholder slice
242/// (issue #67), not full CSL: no author-year styles, sorted output, or
243/// citation clusters.
244/// Resolve `[@key]` citations and return the records loaded from the
245/// declared bibliography sources, merged across sources, and whether every
246/// source loaded successfully. The reference
247/// resolver consumes their key set to tell an `@key` label reference that
248/// *misses* the label index but *matches* a bibliography key apart -- a
249/// near-certain "meant a citation" mistake -- from a plain unknown label (see
250/// [`crate::resolve::resolve`]); the LSP offers the same records as citation
251/// completions.
252pub(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    // The entry list attaches to the *first* Bibliography node in document
263    // order: one bibliography section is the typical document shape, and
264    // partitioning entries across several declarations has no defined
265    // semantics in this numeric slice. Later declarations still contribute
266    // records; their nodes just stay childless.
267    let bibliography_node = document
268        .nodes()
269        .find(|node| node.kind == NodeKind::Bibliography)
270        .map(|node| (node.id, node.span.clone()));
271
272    // `nodes()` walks the `BTreeMap<NodeId, Node>` in `NodeId` order, which is
273    // the lowerer's allocation order -- i.e. document order. Collecting the ids
274    // above preserves that order, so the first new key encountered here is the
275    // document's first-cited key.
276    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
337/// Append one rendered entry per *cited* key as children of the first
338/// [`NodeKind::Bibliography`] node, ordered by first-use citation number.
339///
340/// Each entry is a [`NodeKind::Paragraph`] carrying `entry_number` /
341/// `entry_key` attributes and one [`NodeKind::Text`] child with the
342/// [`format_entry`] text, so layout can render the list with `[N]` markers
343/// without re-reading BibTeX sources. Uncited records add nothing, and
344/// unresolved keys never made it into `numbers`, so the numbered entries
345/// always match the citation markers in the prose.
346fn 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
382/// Render one BibTeX entry as plain text for the bibliography list.
383///
384/// Not a CSL processor: a fixed `Author. Title. Venue, volume, pp. pages.
385/// Publisher. Year.` order where every missing field simply drops out.
386/// Brace protection is stripped and whitespace collapsed via
387/// [`clean_field`]; a part that already ends in `.` (initials like
388/// `Knuth, D. E.`) is not double-terminated. An entry with no usable fields
389/// falls back to its citation key so a numbered slot never renders empty.
390fn 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    // Venue clause: journal (or booktitle), then volume and pages appended
403    // as `, {volume}` / `, pp. {pages}` so they stay attached to the venue
404    // they qualify. Without a venue they still surface on their own.
405    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
438/// Strip BibTeX brace protection and collapse runs of whitespace, so field
439/// values like `{The {TeX}book}` render as `The TeXbook`.
440fn 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
473/// The single loaded citation key that is a conservative near-miss for
474/// `unknown`, if any. Candidate filtering stays here ([`is_citation_key`]);
475/// the selection rule (length floor, `len / 3` bound, ties suggest nothing)
476/// lives in [`crate::suggest::nearest_match`].
477fn 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}