Skip to main content

surf_parse/
citation.rs

1//! Citation & bibliography engine (CSL-style, deterministic).
2//!
3//! This module is the load-bearing deliverable of Chunk 4: a *pure*,
4//! deterministic formatting engine that produces correct in-text citations and
5//! reference-list entries for the academic styles carried by [`Format`]
6//! (MLA, APA 7, Chicago author-date, IEEE, plus ACM ≈ IEEE numbering and the
7//! generic `article` paper style ≈ APA author-date).
8//!
9//! The data model ([`Reference`], [`Author`], [`RefType`]) is consumed both by
10//! the `::cite` block (a stored definition) and by Chunk 6 (paper/report
11//! rendering). The formatting functions ([`format_in_text`],
12//! [`format_reference`], [`reference_list`], [`reference_list_keyed`]) are pure:
13//! same input → byte-identical output, no time / randomness / hashmap-iteration.
14//!
15//! Inline citations (`[@key]`, `[@key, p. 12]`, `[@key1; @key2]`) are scanned by
16//! [`crate::inline::find_inline_cites`] into [`CiteRef`] values, resolved against
17//! a [`CiteContext`] (the active style + the document's references + IEEE
18//! numbering). Renderers install a context via [`install_context`] and read it
19//! through [`with_active`] / [`substitute_text_cites`].
20
21use std::collections::{BTreeMap, BTreeSet};
22use std::cell::RefCell;
23
24use serde::{Deserialize, Serialize};
25
26use crate::types::{Block, Format};
27
28// ───────────────────────────────────────────────────────────────────────────
29// Data model
30// ───────────────────────────────────────────────────────────────────────────
31
32/// A single author/editor name, split into family (surname) + optional given.
33#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, Default)]
34pub struct Author {
35    /// Surname / family name (e.g. "Smith").
36    pub family: String,
37    /// Given name(s) as authored (e.g. "John", "Mary Jane"). `None` for
38    /// mononyms / organisations.
39    pub given: Option<String>,
40}
41
42/// Source category for a [`Reference`]; selects per-style entry shape.
43#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
44#[serde(rename_all = "lowercase")]
45pub enum RefType {
46    /// Journal / periodical article (default).
47    #[default]
48    Article,
49    /// Whole book.
50    Book,
51    /// Chapter in an edited book.
52    Chapter,
53    /// Web page / online resource.
54    Web,
55    /// Technical report / white paper.
56    Report,
57    /// Conference / proceedings paper.
58    Conference,
59}
60
61impl RefType {
62    /// Parse a `type=` value (case-insensitive). Unknown → `Article`.
63    pub fn from_str_lossy(s: &str) -> RefType {
64        match s.trim().to_ascii_lowercase().as_str() {
65            "book" => RefType::Book,
66            "chapter" | "incollection" | "bookchapter" => RefType::Chapter,
67            "web" | "website" | "online" | "webpage" => RefType::Web,
68            "report" | "techreport" | "whitepaper" => RefType::Report,
69            "conference" | "proceedings" | "inproceedings" | "paper" => RefType::Conference,
70            _ => RefType::Article,
71        }
72    }
73}
74
75/// A bibliographic reference. Optional fields use `Option`; the `key` is the
76/// stable id used by inline `[@key]` citations and bibliography anchors.
77#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, Default)]
78pub struct Reference {
79    /// Citation key / id (e.g. `smith2020`).
80    pub key: String,
81    /// Source category (article/book/web/…).
82    pub ref_type: RefType,
83    /// Authors in listed order.
84    pub authors: Vec<Author>,
85    /// Editors (for edited books / chapters).
86    pub editors: Vec<Author>,
87    /// Title of the work.
88    pub title: Option<String>,
89    /// Container: journal / book / website / proceedings name.
90    pub container: Option<String>,
91    /// Publisher (books / reports).
92    pub publisher: Option<String>,
93    /// Year of publication (kept as a string to preserve `n.d.`/`2020a`).
94    pub year: Option<String>,
95    /// Volume number.
96    pub volume: Option<String>,
97    /// Issue number.
98    pub issue: Option<String>,
99    /// Page range (e.g. `45-67`).
100    pub pages: Option<String>,
101    /// URL.
102    pub url: Option<String>,
103    /// DOI (without the `https://doi.org/` prefix).
104    pub doi: Option<String>,
105    /// Access date (ISO `YYYY-MM-DD`) for web sources.
106    pub accessed: Option<String>,
107    /// Edition (e.g. `2nd`).
108    pub edition: Option<String>,
109}
110
111// ───────────────────────────────────────────────────────────────────────────
112// Inline citation references (produced by inline.rs)
113// ───────────────────────────────────────────────────────────────────────────
114
115/// One key inside an inline citation, with an optional locator (`p. 12`).
116#[derive(Debug, Clone, PartialEq, Eq)]
117pub struct CiteItem {
118    /// Reference key (matches [`Reference::key`]).
119    pub key: String,
120    /// Locator text exactly as authored (e.g. `p. 12`, `pp. 3-4`, `sec. 2`).
121    pub locator: Option<String>,
122}
123
124/// A single inline citation group: `[@a]` is one item, `[@a; @b]` is two.
125#[derive(Debug, Clone, PartialEq, Eq)]
126pub struct CiteRef {
127    /// One or more cited items, in authored order.
128    pub items: Vec<CiteItem>,
129}
130
131// ───────────────────────────────────────────────────────────────────────────
132// Author parsing helpers
133// ───────────────────────────────────────────────────────────────────────────
134
135fn opt(s: &str) -> Option<String> {
136    let t = s.trim();
137    if t.is_empty() {
138        None
139    } else {
140        Some(t.to_string())
141    }
142}
143
144/// Parse a single author. Accepts `Last, First` (preferred) or `First Last`.
145/// A single token becomes a family-only name (mononym / organisation).
146pub fn parse_author(s: &str) -> Author {
147    let s = s.trim();
148    if let Some((last, first)) = s.split_once(',') {
149        Author {
150            family: last.trim().to_string(),
151            given: opt(first),
152        }
153    } else if let Some(idx) = s.rfind(' ') {
154        let (given, family) = s.split_at(idx);
155        Author {
156            family: family.trim().to_string(),
157            given: opt(given),
158        }
159    } else {
160        Author {
161            family: s.to_string(),
162            given: None,
163        }
164    }
165}
166
167/// Parse a `;`-separated author list (`Last, First; Last2, First2`).
168pub fn parse_authors(s: &str) -> Vec<Author> {
169    s.split(';')
170        .map(parse_author)
171        .filter(|a| !a.family.is_empty())
172        .collect()
173}
174
175/// Initials from a given name: `John` → `J.`, `Mary Jane` → `M. J.`.
176fn initials(given: &Option<String>) -> String {
177    match given {
178        None => String::new(),
179        Some(g) => g
180            .split_whitespace()
181            .filter_map(|w| w.chars().next())
182            .map(|c| format!("{}.", c.to_uppercase()))
183            .collect::<Vec<_>>()
184            .join(" "),
185    }
186}
187
188/// `Family` (no given) or `Family, Given` (inverted form).
189fn inverted(a: &Author) -> String {
190    match &a.given {
191        Some(g) => format!("{}, {}", a.family, g),
192        None => a.family.clone(),
193    }
194}
195
196/// `Family` (no given) or `Given Family` (natural form).
197fn natural(a: &Author) -> String {
198    match &a.given {
199        Some(g) => format!("{} {}", g, a.family),
200        None => a.family.clone(),
201    }
202}
203
204/// Strip a leading page-locator prefix (`p.`/`pp.`/`page`/`pages`) so styles
205/// that want a bare number in-text (MLA, Chicago) render `12` not `p. 12`.
206fn strip_page_prefix(s: &str) -> String {
207    let t = s.trim();
208    for p in ["pp.", "pages", "page", "pg.", "p."] {
209        if let Some(rest) = t.strip_prefix(p) {
210            return rest.trim().to_string();
211        }
212    }
213    t.to_string()
214}
215
216/// MLA access-date format: `2021-05-01` → `1 May 2021`. Falls back to the input
217/// when it is not an ISO date.
218fn fmt_date_mla(iso: &str) -> String {
219    const MONTHS: [&str; 12] = [
220        "January", "February", "March", "April", "May", "June", "July", "August",
221        "September", "October", "November", "December",
222    ];
223    let parts: Vec<&str> = iso.split('-').collect();
224    if parts.len() == 3 {
225        if let (Ok(y), Ok(m), Ok(d)) = (
226            parts[0].parse::<i32>(),
227            parts[1].parse::<usize>(),
228            parts[2].parse::<i32>(),
229        ) {
230            if (1..=12).contains(&m) {
231                return format!("{} {} {}", d, MONTHS[m - 1], y);
232            }
233        }
234        // fall through to raw on out-of-range month
235    }
236    iso.to_string()
237}
238
239// ───────────────────────────────────────────────────────────────────────────
240// Style helpers
241// ───────────────────────────────────────────────────────────────────────────
242
243/// True for numbered styles (IEEE / ACM): in-text `[n]`, reference list in
244/// citation order with `[n]` prefixes.
245pub fn is_numbered(style: Format) -> bool {
246    matches!(style, Format::Ieee | Format::Acm)
247}
248
249/// The bibliography section heading for a style.
250pub fn bibliography_heading(style: Format) -> &'static str {
251    match style {
252        Format::Mla => "Works Cited",
253        Format::Chicago => "Bibliography",
254        // APA / IEEE / ACM / generic article
255        _ => "References",
256    }
257}
258
259/// The active style: the document's `format:` if set, else APA author-date.
260pub fn active_style(format: Option<Format>) -> Format {
261    format.unwrap_or(Format::Apa)
262}
263
264// ── in-text author label ────────────────────────────────────────────────────
265
266/// In-text name label for author-date / author-page styles.
267/// 1 → `Smith`, 2 → `Smith & Jones` (APA) / `Smith and Jones` (MLA, Chicago),
268/// 3+ → `Smith et al.`.
269fn intext_names(r: &Reference, style: Format) -> String {
270    let fams: Vec<&str> = r.authors.iter().map(|a| a.family.as_str()).collect();
271    let amp = matches!(style, Format::Apa);
272    match fams.len() {
273        0 => r.title.clone().unwrap_or_else(|| r.key.clone()),
274        1 => fams[0].to_string(),
275        2 => {
276            if amp {
277                format!("{} & {}", fams[0], fams[1])
278            } else {
279                format!("{} and {}", fams[0], fams[1])
280            }
281        }
282        _ => format!("{} et al.", fams[0]),
283    }
284}
285
286// ───────────────────────────────────────────────────────────────────────────
287// In-text citation formatting
288// ───────────────────────────────────────────────────────────────────────────
289
290/// Format a complete inline citation group per `style`.
291///
292/// Pure. `numbers` maps reference keys → IEEE/ACM citation numbers and is only
293/// consulted for numbered styles. Unknown keys degrade gracefully (the key text
294/// for author styles, `[0]` for numbered styles).
295///
296/// Examples (APA): `[@smith2020]` → `(Smith, 2020)`,
297/// `[@smith2020, p. 12]` → `(Smith, 2020, p. 12)`,
298/// `[@a; @b]` → `(A, 2020; B, 2019)`. (IEEE): `[1], [2]`.
299pub fn format_in_text(
300    refs: &[Reference],
301    cite: &CiteRef,
302    style: Format,
303    numbers: &BTreeMap<String, usize>,
304) -> String {
305    if is_numbered(style) {
306        let parts: Vec<String> = cite
307            .items
308            .iter()
309            .map(|it| {
310                let n = numbers.get(&it.key).copied().unwrap_or(0);
311                match &it.locator {
312                    Some(loc) => format!("[{}, {}]", n, loc),
313                    None => format!("[{}]", n),
314                }
315            })
316            .collect();
317        return parts.join(", ");
318    }
319
320    let find = |key: &str| refs.iter().find(|r| r.key == key);
321    let parts: Vec<String> = cite
322        .items
323        .iter()
324        .map(|it| {
325            let r = find(&it.key);
326            let names = r
327                .map(|r| intext_names(r, style))
328                .unwrap_or_else(|| it.key.clone());
329            match style {
330                Format::Mla => {
331                    // (Author page)
332                    match &it.locator {
333                        Some(loc) => format!("{} {}", names, strip_page_prefix(loc)),
334                        None => names,
335                    }
336                }
337                Format::Chicago => {
338                    // (Author Year, page)
339                    let year = r
340                        .and_then(|r| r.year.clone())
341                        .unwrap_or_else(|| "n.d.".to_string());
342                    match &it.locator {
343                        Some(loc) => format!("{} {}, {}", names, year, strip_page_prefix(loc)),
344                        None => format!("{} {}", names, year),
345                    }
346                }
347                // APA + generic article: (Author, Year, locator)
348                _ => {
349                    let year = r
350                        .and_then(|r| r.year.clone())
351                        .unwrap_or_else(|| "n.d.".to_string());
352                    match &it.locator {
353                        Some(loc) => format!("{}, {}, {}", names, year, loc),
354                        None => format!("{}, {}", names, year),
355                    }
356                }
357            }
358        })
359        .collect();
360    format!("({})", parts.join("; "))
361}
362
363// ───────────────────────────────────────────────────────────────────────────
364// Reference-list entry formatting (per style)
365// ───────────────────────────────────────────────────────────────────────────
366
367// Italics use markdown `*...*`; HTML renderers convert to `<em>`, text renderers
368// can keep or strip them. This keeps the pure functions renderer-agnostic.
369
370fn join_apa(names: &[String]) -> String {
371    match names.len() {
372        0 => String::new(),
373        1 => names[0].clone(),
374        2 => format!("{}, & {}", names[0], names[1]),
375        _ => {
376            let (last, rest) = names.split_last().unwrap();
377            format!("{}, & {}", rest.join(", "), last)
378        }
379    }
380}
381
382fn join_and(parts: &[String]) -> String {
383    match parts.len() {
384        0 => String::new(),
385        1 => parts[0].clone(),
386        2 => format!("{}, and {}", parts[0], parts[1]),
387        _ => {
388            let (last, rest) = parts.split_last().unwrap();
389            format!("{}, and {}", rest.join(", "), last)
390        }
391    }
392}
393
394fn apa_authors(authors: &[Author]) -> String {
395    let names: Vec<String> = authors
396        .iter()
397        .map(|a| {
398            let ini = initials(&a.given);
399            if ini.is_empty() {
400                a.family.clone()
401            } else {
402                format!("{}, {}", a.family, ini)
403            }
404        })
405        .collect();
406    join_apa(&names)
407}
408
409fn mla_authors(authors: &[Author]) -> String {
410    match authors.len() {
411        0 => String::new(),
412        1 => inverted(&authors[0]),
413        2 => format!("{}, and {}", inverted(&authors[0]), natural(&authors[1])),
414        _ => format!("{}, et al.", inverted(&authors[0])),
415    }
416}
417
418fn chicago_authors(authors: &[Author]) -> String {
419    match authors.len() {
420        0 => String::new(),
421        1 => inverted(&authors[0]),
422        _ => {
423            let mut parts = vec![inverted(&authors[0])];
424            for a in &authors[1..] {
425                parts.push(natural(a));
426            }
427            join_and(&parts)
428        }
429    }
430}
431
432fn ieee_authors(authors: &[Author]) -> String {
433    let names: Vec<String> = authors
434        .iter()
435        .map(|a| {
436            let ini = initials(&a.given);
437            if ini.is_empty() {
438                a.family.clone()
439            } else {
440                format!("{} {}", ini, a.family)
441            }
442        })
443        .collect();
444    match names.len() {
445        0 => String::new(),
446        1 => names[0].clone(),
447        2 => format!("{} and {}", names[0], names[1]),
448        _ => {
449            let (last, rest) = names.split_last().unwrap();
450            format!("{}, and {}", rest.join(", "), last)
451        }
452    }
453}
454
455fn apa_reference(r: &Reference) -> String {
456    let authors = apa_authors(&r.authors);
457    let year = r.year.clone().unwrap_or_else(|| "n.d.".to_string());
458    let head = if authors.is_empty() {
459        format!("({}).", year)
460    } else {
461        format!("{} ({}).", authors, year)
462    };
463    let title = r.title.clone().unwrap_or_default();
464    match r.ref_type {
465        RefType::Book => {
466            let mut s = format!("{} *{}*.", head, title);
467            if let Some(p) = &r.publisher {
468                s.push_str(&format!(" {}.", p));
469            }
470            s
471        }
472        _ => {
473            let mut s = head;
474            if !title.is_empty() {
475                s.push_str(&format!(" {}.", title));
476            }
477            if let Some(c) = &r.container {
478                s.push_str(&format!(" *{}*", c));
479                if let Some(v) = &r.volume {
480                    s.push_str(&format!(", *{}*", v));
481                    if let Some(i) = &r.issue {
482                        s.push_str(&format!("({})", i));
483                    }
484                }
485                if let Some(pg) = &r.pages {
486                    s.push_str(&format!(", {}", pg));
487                }
488                s.push('.');
489            }
490            if let Some(d) = &r.doi {
491                s.push_str(&format!(" https://doi.org/{}", d));
492            } else if let Some(u) = &r.url {
493                s.push_str(&format!(" {}", u));
494            }
495            s
496        }
497    }
498}
499
500fn mla_reference(r: &Reference) -> String {
501    let authors = mla_authors(&r.authors);
502    let mut s = String::new();
503    if !authors.is_empty() {
504        // Avoid a double period after "et al." (which already ends in '.').
505        if authors.ends_with('.') {
506            s.push_str(&format!("{} ", authors));
507        } else {
508            s.push_str(&format!("{}. ", authors));
509        }
510    }
511    let title = r.title.clone().unwrap_or_default();
512    match r.ref_type {
513        RefType::Book => {
514            s.push_str(&format!("*{}*.", title));
515            if let Some(p) = &r.publisher {
516                s.push_str(&format!(" {},", p));
517            }
518            if let Some(y) = &r.year {
519                s.push_str(&format!(" {}.", y));
520            }
521        }
522        _ => {
523            if !title.is_empty() {
524                s.push_str(&format!("\"{}.\" ", title));
525            }
526            if let Some(c) = &r.container {
527                s.push_str(&format!("*{}*", c));
528                let mut parts: Vec<String> = Vec::new();
529                if let Some(v) = &r.volume {
530                    parts.push(format!("vol. {}", v));
531                }
532                if let Some(i) = &r.issue {
533                    parts.push(format!("no. {}", i));
534                }
535                if let Some(y) = &r.year {
536                    parts.push(y.clone());
537                }
538                if let Some(pg) = &r.pages {
539                    parts.push(format!("pp. {}", pg));
540                }
541                if matches!(r.ref_type, RefType::Web) {
542                    if let Some(u) = &r.url {
543                        parts.push(u.clone());
544                    }
545                }
546                for p in parts {
547                    s.push_str(&format!(", {}", p));
548                }
549                s.push('.');
550            }
551            if let Some(acc) = &r.accessed {
552                s.push_str(&format!(" Accessed {}.", fmt_date_mla(acc)));
553            }
554        }
555    }
556    s
557}
558
559fn chicago_reference(r: &Reference) -> String {
560    let authors = chicago_authors(&r.authors);
561    let year = r.year.clone().unwrap_or_else(|| "n.d.".to_string());
562    let head = if authors.is_empty() {
563        format!("{}.", year)
564    } else {
565        format!("{}. {}.", authors, year)
566    };
567    let title = r.title.clone().unwrap_or_default();
568    match r.ref_type {
569        RefType::Book => {
570            let mut s = format!("{} *{}*.", head, title);
571            if let Some(p) = &r.publisher {
572                s.push_str(&format!(" {}.", p));
573            }
574            s
575        }
576        _ => {
577            let mut s = format!("{} \"{}.\"", head, title);
578            if let Some(c) = &r.container {
579                s.push_str(&format!(" *{}*", c));
580                let has_vol = r.volume.is_some();
581                if let Some(v) = &r.volume {
582                    s.push_str(&format!(" {}", v));
583                }
584                if let Some(i) = &r.issue {
585                    s.push_str(&format!(" ({})", i));
586                }
587                if let Some(pg) = &r.pages {
588                    if has_vol {
589                        s.push_str(&format!(": {}", pg));
590                    } else {
591                        s.push_str(&format!(", {}", pg));
592                    }
593                }
594                s.push('.');
595            }
596            if let Some(u) = &r.url {
597                s.push_str(&format!(" {}.", u));
598            }
599            s
600        }
601    }
602}
603
604fn ieee_reference(r: &Reference, number: usize) -> String {
605    let prefix = format!("[{}] ", number);
606    let authors = ieee_authors(&r.authors);
607    let title = r.title.clone().unwrap_or_default();
608    match r.ref_type {
609        RefType::Book => {
610            let mut s = format!("{}{}, *{}*.", prefix, authors, title);
611            if let Some(p) = &r.publisher {
612                s.push_str(&format!(" {},", p));
613            }
614            if let Some(y) = &r.year {
615                s.push_str(&format!(" {}.", y));
616            }
617            s
618        }
619        RefType::Conference => {
620            let mut s = format!("{}{}, \"{},\"", prefix, authors, title);
621            if let Some(c) = &r.container {
622                s.push_str(&format!(" in *{}*", c));
623            }
624            if let Some(y) = &r.year {
625                s.push_str(&format!(", {}", y));
626            }
627            if let Some(pg) = &r.pages {
628                s.push_str(&format!(", pp. {}", pg));
629            }
630            s.push('.');
631            s
632        }
633        RefType::Web => {
634            let mut s = format!("{}{}, \"{},\"", prefix, authors, title);
635            if let Some(c) = &r.container {
636                s.push_str(&format!(" *{}*", c));
637            }
638            if let Some(y) = &r.year {
639                s.push_str(&format!(", {}", y));
640            }
641            s.push_str(". [Online]. Available: ");
642            if let Some(u) = &r.url {
643                s.push_str(u);
644            } else if let Some(d) = &r.doi {
645                s.push_str(&format!("https://doi.org/{}", d));
646            }
647            s
648        }
649        _ => {
650            let mut s = format!("{}{}, \"{},\"", prefix, authors, title);
651            if let Some(c) = &r.container {
652                s.push_str(&format!(" *{}*", c));
653            }
654            if let Some(v) = &r.volume {
655                s.push_str(&format!(", vol. {}", v));
656            }
657            if let Some(i) = &r.issue {
658                s.push_str(&format!(", no. {}", i));
659            }
660            if let Some(pg) = &r.pages {
661                s.push_str(&format!(", pp. {}", pg));
662            }
663            if let Some(y) = &r.year {
664                s.push_str(&format!(", {}", y));
665            }
666            s.push('.');
667            s
668        }
669    }
670}
671
672/// Format ONE reference-list entry for `style`. `number` is the IEEE/ACM entry
673/// number (ignored by author styles). Italics use markdown `*...*`.
674pub fn format_reference(r: &Reference, style: Format, number: Option<usize>) -> String {
675    match style {
676        Format::Mla => mla_reference(r),
677        Format::Chicago => chicago_reference(r),
678        Format::Ieee | Format::Acm => ieee_reference(r, number.unwrap_or(0)),
679        // APA + generic article paper style
680        _ => apa_reference(r),
681    }
682}
683
684/// Sort key for author styles: (first-author family ↓case, year, title ↓case).
685fn sort_key(r: &Reference) -> (String, String, String) {
686    let fam = r
687        .authors
688        .first()
689        .map(|a| a.family.to_lowercase())
690        .unwrap_or_else(|| r.title.clone().unwrap_or_default().to_lowercase());
691    (
692        fam,
693        r.year.clone().unwrap_or_default(),
694        r.title.clone().unwrap_or_default().to_lowercase(),
695    )
696}
697
698/// Build the full reference list as `(key, formatted_entry)` pairs in display
699/// order. Numbered styles (IEEE/ACM) keep the *input order* (the caller passes
700/// references in citation order) and number `1..`; author styles sort
701/// alphabetically. Deterministic.
702pub fn reference_list_keyed(refs: &[Reference], style: Format) -> Vec<(String, String)> {
703    if is_numbered(style) {
704        refs.iter()
705            .enumerate()
706            .map(|(i, r)| (r.key.clone(), format_reference(r, style, Some(i + 1))))
707            .collect()
708    } else {
709        let mut sorted: Vec<&Reference> = refs.iter().collect();
710        sorted.sort_by(|a, b| sort_key(a).cmp(&sort_key(b)));
711        sorted
712            .iter()
713            .map(|r| (r.key.clone(), format_reference(r, style, None)))
714            .collect()
715    }
716}
717
718/// Build the full reference list as formatted strings in display order.
719pub fn reference_list(refs: &[Reference], style: Format) -> Vec<String> {
720    reference_list_keyed(refs, style)
721        .into_iter()
722        .map(|(_, s)| s)
723        .collect()
724}
725
726// ───────────────────────────────────────────────────────────────────────────
727// Document context: collect references + citation order + numbering
728// ───────────────────────────────────────────────────────────────────────────
729
730/// Resolved citation context for a document: the active style, every defined
731/// reference (definition order), and the IEEE/ACM number assigned to each key.
732#[derive(Debug, Clone)]
733pub struct CiteContext {
734    /// Active citation style.
735    pub style: Format,
736    /// All references defined via `::cite`, in definition order.
737    pub references: Vec<Reference>,
738    /// Key → citation number (cited keys first in citation order, then any
739    /// defined-but-uncited references in definition order). Used by IEEE/ACM.
740    pub numbers: BTreeMap<String, usize>,
741}
742
743/// Child-block accessor for the container variants citations can nest inside.
744fn children_of(b: &Block) -> Option<&[Block]> {
745    match b {
746        Block::Page { children, .. }
747        | Block::Section { children, .. }
748        | Block::Slide { children, .. }
749        | Block::App { children, .. }
750        | Block::AppShell { children, .. }
751        | Block::Sidebar { children, .. }
752        | Block::Panel { children, .. }
753        | Block::TabContent { children, .. }
754        | Block::Drawer { children, .. }
755        | Block::Modal { children, .. } => Some(children),
756        _ => None,
757    }
758}
759
760/// Prose text of a block that may contain inline `[@key]` citations.
761fn cite_text_of(b: &Block) -> Option<&str> {
762    match b {
763        Block::Markdown { content, .. }
764        | Block::Callout { content, .. }
765        | Block::Summary { content, .. }
766        | Block::Quote { content, .. }
767        | Block::Section { content, .. }
768        | Block::Details { content, .. } => Some(content),
769        _ => None,
770    }
771}
772
773fn collect_refs_rec(blocks: &[Block], out: &mut Vec<Reference>) {
774    for b in blocks {
775        if let Block::Cite { reference, .. } = b {
776            out.push(reference.clone());
777        }
778        if let Some(children) = children_of(b) {
779            collect_refs_rec(children, out);
780        }
781    }
782}
783
784/// Collect every `::cite`-defined reference in document order (recursing into
785/// container blocks).
786pub fn collect_references(blocks: &[Block]) -> Vec<Reference> {
787    let mut out = Vec::new();
788    collect_refs_rec(blocks, &mut out);
789    out
790}
791
792fn collect_keys_rec(blocks: &[Block], order: &mut Vec<String>, seen: &mut BTreeSet<String>) {
793    for b in blocks {
794        if let Some(text) = cite_text_of(b) {
795            for (_, _, cr) in crate::inline::find_inline_cites(text) {
796                for it in cr.items {
797                    if seen.insert(it.key.clone()) {
798                        order.push(it.key);
799                    }
800                }
801            }
802        }
803        if let Some(children) = children_of(b) {
804            collect_keys_rec(children, order, seen);
805        }
806    }
807}
808
809/// Collect cited reference keys in first-appearance (citation) order.
810pub fn collect_cited_keys(blocks: &[Block]) -> Vec<String> {
811    let mut order = Vec::new();
812    let mut seen = BTreeSet::new();
813    collect_keys_rec(blocks, &mut order, &mut seen);
814    order
815}
816
817/// Assign citation numbers: cited keys first (citation order), then any
818/// defined-but-uncited references in definition order. Deterministic.
819fn assign_numbers(refs: &[Reference], order: &[String]) -> BTreeMap<String, usize> {
820    let mut nums = BTreeMap::new();
821    let mut n = 1usize;
822    for k in order {
823        if refs.iter().any(|r| &r.key == k) && !nums.contains_key(k) {
824            nums.insert(k.clone(), n);
825            n += 1;
826        }
827    }
828    for r in refs {
829        if !nums.contains_key(&r.key) {
830            nums.insert(r.key.clone(), n);
831            n += 1;
832        }
833    }
834    nums
835}
836
837/// Build a [`CiteContext`] from a document's blocks and its `format:`.
838pub fn build_context(blocks: &[Block], format: Option<Format>) -> CiteContext {
839    let references = collect_references(blocks);
840    let order = collect_cited_keys(blocks);
841    let numbers = assign_numbers(&references, &order);
842    CiteContext {
843        style: active_style(format),
844        references,
845        numbers,
846    }
847}
848
849/// References ordered for the bibliography display: number order for numbered
850/// styles, definition order otherwise (the keyed list re-sorts author styles).
851pub fn ordered_references(ctx: &CiteContext) -> Vec<Reference> {
852    if is_numbered(ctx.style) {
853        let mut v = ctx.references.clone();
854        v.sort_by_key(|r| ctx.numbers.get(&r.key).copied().unwrap_or(usize::MAX));
855        v
856    } else {
857        ctx.references.clone()
858    }
859}
860
861// ───────────────────────────────────────────────────────────────────────────
862// Ambient context for renderers (thread-local, deterministic per render)
863// ───────────────────────────────────────────────────────────────────────────
864
865thread_local! {
866    static ACTIVE: RefCell<Option<CiteContext>> = const { RefCell::new(None) };
867}
868
869/// RAII guard that clears the ambient [`CiteContext`] when dropped.
870pub struct CiteScope {
871    _private: (),
872}
873
874impl Drop for CiteScope {
875    fn drop(&mut self) {
876        ACTIVE.with(|c| *c.borrow_mut() = None);
877    }
878}
879
880/// Install `ctx` as the ambient context for the current thread for the lifetime
881/// of the returned guard. Renderers call this at the top of their entrypoint so
882/// nested `render_block` calls can resolve inline cites + the bibliography
883/// without threading the context through every signature.
884pub fn install_context(ctx: CiteContext) -> CiteScope {
885    ACTIVE.with(|c| *c.borrow_mut() = Some(ctx));
886    CiteScope { _private: () }
887}
888
889/// Run `f` with a reference to the ambient context (if any).
890pub fn with_active<R>(f: impl FnOnce(Option<&CiteContext>) -> R) -> R {
891    ACTIVE.with(|c| f(c.borrow().as_ref()))
892}
893
894/// Replace inline `[@key]` citations in `text` with their formatted in-text
895/// strings using the ambient context. No-op when there is no context, no
896/// references, or no cites. Used by text-oriented renderers (markdown/native).
897pub fn substitute_text_cites(text: &str) -> String {
898    with_active(|ctx| match ctx {
899        Some(ctx) if !ctx.references.is_empty() => {
900            let cites = crate::inline::find_inline_cites(text);
901            if cites.is_empty() {
902                return text.to_string();
903            }
904            let mut out = String::with_capacity(text.len());
905            let mut last = 0;
906            for (s, e, cr) in cites {
907                out.push_str(&text[last..s]);
908                out.push_str(&format_in_text(&ctx.references, &cr, ctx.style, &ctx.numbers));
909                last = e;
910            }
911            out.push_str(&text[last..]);
912            out
913        }
914        _ => text.to_string(),
915    })
916}
917
918#[cfg(test)]
919mod tests {
920    use super::*;
921
922    fn fixtures() -> Vec<Reference> {
923        vec![
924            Reference {
925                key: "smith2020".into(),
926                ref_type: RefType::Article,
927                authors: parse_authors("Smith, John"),
928                title: Some("Deep Learning for Climate Models".into()),
929                container: Some("Journal of Climate AI".into()),
930                year: Some("2020".into()),
931                volume: Some("12".into()),
932                issue: Some("3".into()),
933                pages: Some("45-67".into()),
934                doi: Some("10.1000/jcai.2020.45".into()),
935                ..Default::default()
936            },
937            Reference {
938                key: "jones2019".into(),
939                ref_type: RefType::Book,
940                authors: parse_authors("Jones, Alice; Brown, Bob"),
941                title: Some("Foundations of Data Science".into()),
942                publisher: Some("MIT Press".into()),
943                year: Some("2019".into()),
944                ..Default::default()
945            },
946            Reference {
947                key: "lee2021".into(),
948                ref_type: RefType::Web,
949                authors: parse_authors("Lee, Carol"),
950                title: Some("Understanding Transformers".into()),
951                container: Some("AI Weekly".into()),
952                year: Some("2021".into()),
953                url: Some("https://aiweekly.example/transformers".into()),
954                accessed: Some("2021-05-01".into()),
955                ..Default::default()
956            },
957            Reference {
958                key: "garcia2022".into(),
959                ref_type: RefType::Conference,
960                authors: parse_authors("Garcia, David; Patel, Esha; Wong, Fang"),
961                title: Some("Scalable Inference".into()),
962                container: Some("Proceedings of NeurIPS".into()),
963                year: Some("2022".into()),
964                pages: Some("100-110".into()),
965                ..Default::default()
966            },
967        ]
968    }
969
970    fn cite1(key: &str) -> CiteRef {
971        CiteRef {
972            items: vec![CiteItem {
973                key: key.into(),
974                locator: None,
975            }],
976        }
977    }
978
979    // ── L2: author parsing ──────────────────────────────────────────────────
980
981    #[test]
982    fn parse_author_inverted_and_natural() {
983        assert_eq!(parse_author("Smith, John"), Author { family: "Smith".into(), given: Some("John".into()) });
984        assert_eq!(parse_author("John Smith"), Author { family: "Smith".into(), given: Some("John".into()) });
985        assert_eq!(parse_author("Plato"), Author { family: "Plato".into(), given: None });
986    }
987
988    #[test]
989    fn parse_authors_semicolon() {
990        let a = parse_authors("Jones, Alice; Brown, Bob");
991        assert_eq!(a.len(), 2);
992        assert_eq!(a[0].family, "Jones");
993        assert_eq!(a[1].family, "Brown");
994    }
995
996    #[test]
997    fn initials_multiword() {
998        assert_eq!(initials(&Some("John".into())), "J.");
999        assert_eq!(initials(&Some("Mary Jane".into())), "M. J.");
1000        assert_eq!(initials(&None), "");
1001    }
1002
1003    // ── L6 golden: APA ──────────────────────────────────────────────────────
1004
1005    #[test]
1006    fn apa_in_text_and_list() {
1007        let refs = fixtures();
1008        let nums = BTreeMap::new();
1009        assert_eq!(format_in_text(&refs, &cite1("smith2020"), Format::Apa, &nums), "(Smith, 2020)");
1010        assert_eq!(
1011            format_in_text(&refs, &CiteRef { items: vec![CiteItem { key: "smith2020".into(), locator: Some("p. 12".into()) }] }, Format::Apa, &nums),
1012            "(Smith, 2020, p. 12)"
1013        );
1014        assert_eq!(format_in_text(&refs, &cite1("jones2019"), Format::Apa, &nums), "(Jones & Brown, 2019)");
1015        assert_eq!(format_in_text(&refs, &cite1("garcia2022"), Format::Apa, &nums), "(Garcia et al., 2022)");
1016        assert_eq!(
1017            format_in_text(&refs, &CiteRef { items: vec![CiteItem { key: "smith2020".into(), locator: None }, CiteItem { key: "jones2019".into(), locator: None }] }, Format::Apa, &nums),
1018            "(Smith, 2020; Jones & Brown, 2019)"
1019        );
1020
1021        let list = reference_list(&refs, Format::Apa);
1022        // Alphabetical: Garcia, Jones, Lee, Smith
1023        assert_eq!(list[0], "Garcia, D., Patel, E., & Wong, F. (2022). Scalable Inference. *Proceedings of NeurIPS*, 100-110.");
1024        assert_eq!(list[1], "Jones, A., & Brown, B. (2019). *Foundations of Data Science*. MIT Press.");
1025        assert_eq!(list[2], "Lee, C. (2021). Understanding Transformers. *AI Weekly*. https://aiweekly.example/transformers");
1026        assert_eq!(list[3], "Smith, J. (2020). Deep Learning for Climate Models. *Journal of Climate AI*, *12*(3), 45-67. https://doi.org/10.1000/jcai.2020.45");
1027    }
1028
1029    // ── L6 golden: MLA ──────────────────────────────────────────────────────
1030
1031    #[test]
1032    fn mla_in_text_and_list() {
1033        let refs = fixtures();
1034        let nums = BTreeMap::new();
1035        assert_eq!(format_in_text(&refs, &cite1("smith2020"), Format::Mla, &nums), "(Smith)");
1036        assert_eq!(
1037            format_in_text(&refs, &CiteRef { items: vec![CiteItem { key: "smith2020".into(), locator: Some("p. 12".into()) }] }, Format::Mla, &nums),
1038            "(Smith 12)"
1039        );
1040        assert_eq!(format_in_text(&refs, &cite1("jones2019"), Format::Mla, &nums), "(Jones and Brown)");
1041        assert_eq!(format_in_text(&refs, &cite1("garcia2022"), Format::Mla, &nums), "(Garcia et al.)");
1042
1043        let list = reference_list(&refs, Format::Mla);
1044        assert_eq!(list[0], "Garcia, David, et al. \"Scalable Inference.\" *Proceedings of NeurIPS*, 2022, pp. 100-110.");
1045        assert_eq!(list[1], "Jones, Alice, and Bob Brown. *Foundations of Data Science*. MIT Press, 2019.");
1046        assert_eq!(list[2], "Lee, Carol. \"Understanding Transformers.\" *AI Weekly*, 2021, https://aiweekly.example/transformers. Accessed 1 May 2021.");
1047        assert_eq!(list[3], "Smith, John. \"Deep Learning for Climate Models.\" *Journal of Climate AI*, vol. 12, no. 3, 2020, pp. 45-67.");
1048    }
1049
1050    // ── L6 golden: Chicago author-date ──────────────────────────────────────
1051
1052    #[test]
1053    fn chicago_in_text_and_list() {
1054        let refs = fixtures();
1055        let nums = BTreeMap::new();
1056        assert_eq!(format_in_text(&refs, &cite1("smith2020"), Format::Chicago, &nums), "(Smith 2020)");
1057        assert_eq!(
1058            format_in_text(&refs, &CiteRef { items: vec![CiteItem { key: "smith2020".into(), locator: Some("p. 12".into()) }] }, Format::Chicago, &nums),
1059            "(Smith 2020, 12)"
1060        );
1061        assert_eq!(format_in_text(&refs, &cite1("jones2019"), Format::Chicago, &nums), "(Jones and Brown 2019)");
1062
1063        let list = reference_list(&refs, Format::Chicago);
1064        assert_eq!(list[0], "Garcia, David, Esha Patel, and Fang Wong. 2022. \"Scalable Inference.\" *Proceedings of NeurIPS*, 100-110.");
1065        assert_eq!(list[1], "Jones, Alice, and Bob Brown. 2019. *Foundations of Data Science*. MIT Press.");
1066        assert_eq!(list[2], "Lee, Carol. 2021. \"Understanding Transformers.\" *AI Weekly*. https://aiweekly.example/transformers.");
1067        assert_eq!(list[3], "Smith, John. 2020. \"Deep Learning for Climate Models.\" *Journal of Climate AI* 12 (3): 45-67.");
1068    }
1069
1070    // ── L6 golden: IEEE (numbered, citation order) ──────────────────────────
1071
1072    #[test]
1073    fn ieee_in_text_and_list() {
1074        let refs = fixtures();
1075        let mut nums = BTreeMap::new();
1076        nums.insert("smith2020".to_string(), 1);
1077        nums.insert("jones2019".to_string(), 2);
1078        nums.insert("garcia2022".to_string(), 3);
1079        nums.insert("lee2021".to_string(), 4);
1080
1081        assert_eq!(format_in_text(&refs, &cite1("smith2020"), Format::Ieee, &nums), "[1]");
1082        assert_eq!(
1083            format_in_text(&refs, &CiteRef { items: vec![CiteItem { key: "smith2020".into(), locator: None }, CiteItem { key: "jones2019".into(), locator: None }] }, Format::Ieee, &nums),
1084            "[1], [2]"
1085        );
1086        assert_eq!(
1087            format_in_text(&refs, &CiteRef { items: vec![CiteItem { key: "smith2020".into(), locator: Some("p. 12".into()) }] }, Format::Ieee, &nums),
1088            "[1, p. 12]"
1089        );
1090
1091        // Reference list in citation order: smith, jones, garcia, lee
1092        let ordered = vec![refs[0].clone(), refs[1].clone(), refs[3].clone(), refs[2].clone()];
1093        let list = reference_list(&ordered, Format::Ieee);
1094        assert_eq!(list[0], "[1] J. Smith, \"Deep Learning for Climate Models,\" *Journal of Climate AI*, vol. 12, no. 3, pp. 45-67, 2020.");
1095        assert_eq!(list[1], "[2] A. Jones and B. Brown, *Foundations of Data Science*. MIT Press, 2019.");
1096        assert_eq!(list[2], "[3] D. Garcia, E. Patel, and F. Wong, \"Scalable Inference,\" in *Proceedings of NeurIPS*, 2022, pp. 100-110.");
1097        assert_eq!(list[3], "[4] C. Lee, \"Understanding Transformers,\" *AI Weekly*, 2021. [Online]. Available: https://aiweekly.example/transformers");
1098    }
1099
1100    // ── L6 determinism ──────────────────────────────────────────────────────
1101
1102    #[test]
1103    fn reference_list_is_deterministic() {
1104        let refs = fixtures();
1105        for style in [Format::Apa, Format::Mla, Format::Chicago, Format::Ieee] {
1106            assert_eq!(reference_list(&refs, style), reference_list(&refs, style));
1107        }
1108    }
1109
1110    #[test]
1111    fn bibliography_headings() {
1112        assert_eq!(bibliography_heading(Format::Mla), "Works Cited");
1113        assert_eq!(bibliography_heading(Format::Apa), "References");
1114        assert_eq!(bibliography_heading(Format::Chicago), "Bibliography");
1115        assert_eq!(bibliography_heading(Format::Ieee), "References");
1116    }
1117
1118    #[test]
1119    fn active_style_defaults_to_apa() {
1120        assert_eq!(active_style(None), Format::Apa);
1121        assert_eq!(active_style(Some(Format::Ieee)), Format::Ieee);
1122    }
1123}