Oregami
Repositories/oxedyne/fe2o3

oxedyne/fe2o3/fe2o3_austenite/src/bib.rs

50.3 KiB, 58 runs

created by r1870400018:36623, which is this file's identity for as long as the history lasts, whatever it is later renamed to

download · who wrote it · its history

1//! Bibliography: a BibTeX reader and a Chicago author-date citation formatter.
2//!
3//! The engine reads a `.bib` file into [`Bibliography`], formats an in-text citation as
4//! `(Surname Year)` through [`Bibliography::cite`], and sets a sorted reference list through
5//! [`Bibliography::reference_list`]. It is standalone: the reader wires `#cite(<key>)` to `cite`
6//! and the back-matter Bibliography section to `reference_list`; nothing here touches the parser or
7//! the page.
8//!
9//! The oracle is Typst 0.15.1's `chicago-author-date` style, matched against a render of the
10//! Lucronics bibliography. Three behaviours are Typst-specific and reproduced deliberately, each
11//! noted at its site: the sort orders works by one author before works by that author with
12//! coauthors (author count is the tie-break after the first surname); a journal article with no
13//! volume sets a bare comma after the journal name (`*Journal*,.`); and page ranges are abbreviated
14//! by the Chicago rule (`263--291` sets as `263-91`).
15//!
16//! [Written with AI entirely](https://need2know.ai/entirely-ai/code)\
17//! Anthropic Claude
18
19use oxedyne_fe2o3_core::prelude::*;
20
21use std::collections::BTreeMap;
22
23/// The closed set of entry types the formatter styles. An unrecognised `@type` reads as
24/// [`EntryKind::Misc`], which sets title-italic like a book -- a declared fallback, not a hidden gap.
25#[derive(Clone, Copy, Debug, PartialEq, Eq)]
26pub enum EntryKind {
27 Book,
28 Article,
29 InCollection,
30 InProceedings,
31 TechReport,
32 Report,
33 Online,
34 Unpublished,
35 Misc,
36}
37
38impl EntryKind {
39 fn from_type(s: &str) -> Self {
40 match s.to_ascii_lowercase().as_str() {
41 "book" | "booklet" | "proceedings" => Self::Book,
42 "article" => Self::Article,
43 "incollection" | "inbook" => Self::InCollection,
44 "inproceedings" | "conference" => Self::InProceedings,
45 "techreport" => Self::TechReport,
46 "report" => Self::Report,
47 "online" | "electronic" | "misc" => Self::Online,
48 "unpublished" => Self::Unpublished,
49 _ => Self::Misc,
50 }
51 }
52}
53
54/// One personal or corporate name. A corporate name carries its whole literal in `family` with an
55/// empty `given`; a personal name is inverted to `family, given` for the list and cited by `family`.
56#[derive(Clone, Debug, PartialEq, Eq)]
57pub struct Name {
58 family: String,
59 given: String,
60 corporate: bool,
61}
62
63impl Name {
64 pub fn family(&self) -> &str { &self.family }
65 pub fn given(&self) -> &str { &self.given }
66 pub fn is_corporate(&self) -> bool { self.corporate }
67
68 /// Sets the name inverted for a reference list: `Family, Given` (personal) or the literal
69 /// (corporate).
70 fn inverted(&self) -> String {
71 if self.corporate || self.given.is_empty() {
72 self.family.clone()
73 } else {
74 fmt!("{}, {}", self.family, self.given)
75 }
76 }
77
78 /// Sets the name in natural order for a coauthor position: `Given Family`.
79 fn natural(&self) -> String {
80 if self.corporate || self.given.is_empty() {
81 self.family.clone()
82 } else {
83 fmt!("{} {}", self.given, self.family)
84 }
85 }
86}
87
88/// A parsed BibTeX entry. Fields are held decoded (LaTeX accents and escapes resolved) in file
89/// order; a reader wanting a raw field asks by name through [`Entry::field`].
90#[derive(Clone, Debug)]
91pub struct Entry {
92 key: String,
93 kind: EntryKind,
94 fields: Vec<(String, String)>,
95}
96
97impl Entry {
98 pub fn key(&self) -> &str { &self.key }
99 pub fn kind(&self) -> EntryKind { self.kind }
100
101 /// Returns the decoded value of a field by name, case-insensitively.
102 pub fn field(&self, name: &str) -> Option<&str> {
103 self.fields.iter()
104 .find(|(k, _)| k.eq_ignore_ascii_case(name))
105 .map(|(_, v)| v.as_str())
106 }
107
108 /// The names credited for citation: authors, or editors when there is no author.
109 pub fn credited(&self) -> Vec<Name> {
110 match self.field("author") {
111 Some(a) => parse_names(a),
112 None => match self.field("editor") {
113 Some(e) => parse_names(e),
114 None => Vec::new(),
115 },
116 }
117 }
118
119 fn year(&self) -> Option<i64> {
120 self.field("year").and_then(|y| {
121 let digits: String = y.chars().filter(|c| c.is_ascii_digit()).collect();
122 digits.parse::<i64>().ok()
123 })
124 }
125}
126
127/// A styled fragment of a reference line. The block layer sets an [`RefStyle::Italic`] run in
128/// italic; everything else -- quotation marks, full stops, DOI URLs -- is literal text.
129#[derive(Clone, Debug, PartialEq, Eq)]
130pub struct RefRun {
131 pub text: String,
132 pub style: RefStyle,
133}
134
135#[derive(Clone, Copy, Debug, PartialEq, Eq)]
136pub enum RefStyle {
137 Normal,
138 Italic,
139}
140
141/// One formatted reference: the entry key and its runs, ready for the block layer.
142#[derive(Clone, Debug, PartialEq, Eq)]
143pub struct Reference {
144 pub key: String,
145 pub runs: Vec<RefRun>,
146}
147
148impl Reference {
149 /// The reference as one plain string, italics flattened away. For tests and diagnostics.
150 pub fn plain(&self) -> String {
151 let mut s = String::new();
152 for r in &self.runs {
153 s.push_str(&r.text);
154 }
155 s
156 }
157}
158
159/// A parsed bibliography and the record of which keys a document has cited.
160#[derive(Debug)]
161pub struct Bibliography {
162 entries: Vec<Entry>, // in file order
163 index: BTreeMap<String, usize>, // key -> position in `entries`
164 cited: Vec<String>, // cited keys, first-cite order, deduplicated
165}
166
167impl Bibliography {
168 /// Reads a BibTeX source string into a bibliography. Comments, `@string`/`@preamble` blocks and
169 /// stray text between entries are skipped; an entry with a duplicate key keeps the first seen.
170 pub fn parse(src: &str) -> Outcome<Self> {
171 let chars: Vec<char> = src.chars().collect();
172 let mut entries: Vec<Entry> = Vec::new();
173 let mut index: BTreeMap<String, usize> = BTreeMap::new();
174 let mut i = 0usize;
175 let n = chars.len();
176 while i < n {
177 // Advance to the next entry opener.
178 if chars[i] != '@' {
179 i += 1;
180 continue;
181 }
182 let (entry_opt, next) = res!(parse_entry(&chars, i));
183 i = next;
184 if let Some(entry) = entry_opt {
185 if !index.contains_key(&entry.key) {
186 index.insert(entry.key.clone(), entries.len());
187 entries.push(entry);
188 }
189 }
190 }
191 Ok(Self { entries, index, cited: Vec::new() })
192 }
193
194 pub fn len(&self) -> usize { self.entries.len() }
195 pub fn is_empty(&self) -> bool { self.entries.is_empty() }
196
197 /// Borrows an entry by key.
198 pub fn entry(&self, key: &str) -> Option<&Entry> {
199 self.index.get(key).map(|&p| &self.entries[p])
200 }
201
202 /// Records a key as cited, in first-cite order, for the reference list. A key unknown to the
203 /// bibliography is ignored here and reported by [`Bibliography::cite`] at formatting time.
204 pub fn mark_cited(&mut self, key: &str) {
205 if self.index.contains_key(key) && !self.cited.iter().any(|k| k == key) {
206 self.cited.push(key.to_string());
207 }
208 }
209
210 /// Formats one in-text citation for a run of keys and records each as cited. Sets
211 /// `(Surname Year)` for one key and `(A Year; B Year)` for several, matching the oracle's
212 /// semicolon separator.
213 pub fn cite(&mut self, keys: &[&str]) -> Outcome<String> {
214 for &k in keys {
215 self.mark_cited(k);
216 }
217 self.format_citation(keys)
218 }
219
220 /// Formats an in-text citation without recording it. Used where the reader has already marked
221 /// the keys, or for a pure formatting query.
222 pub fn format_citation(&self, keys: &[&str]) -> Outcome<String> {
223 if keys.is_empty() {
224 return Err(err!("A citation needs at least one key."; Input, Invalid, Missing));
225 }
226 let suffixes = self.cited_suffixes();
227 let mut labels: Vec<String> = Vec::with_capacity(keys.len());
228 for &k in keys {
229 let entry = res!(self.entry(k).ok_or_else(||
230 err!("Citation key {:?} is not in the bibliography.", k; Input, Missing)));
231 labels.push(cite_label(entry, suffixes.get(k).map(|s| s.as_str()).unwrap_or("")));
232 }
233 Ok(fmt!("({})", labels.join("; ")))
234 }
235
236 /// The in-text label for a single key without the surrounding parentheses, e.g. `Scott 1976`.
237 pub fn label(&self, key: &str) -> Outcome<String> {
238 let entry = res!(self.entry(key).ok_or_else(||
239 err!("Citation key {:?} is not in the bibliography.", key; Input, Missing)));
240 let suffixes = self.cited_suffixes();
241 Ok(cite_label(entry, suffixes.get(key).map(|s| s.as_str()).unwrap_or("")))
242 }
243
244 /// The year-disambiguation suffixes across the marked-cited works, keyed by entry key, so an in-text
245 /// citation reads the same `2019a`/`2019b` its reference does. Computed over the sorted cited set, which
246 /// is complete by the time citations are formatted (the reader marks every key before the walk sets them).
247 fn cited_suffixes(&self) -> BTreeMap<String, String> {
248 let mut refs: Vec<&Entry> = self.cited.iter()
249 .filter_map(|k| self.entry(k))
250 .collect();
251 sort_entries(&mut refs);
252 year_suffixes(&refs)
253 }
254
255 /// The sorted, Chicago-styled reference list for the cited keys. If nothing has been marked
256 /// cited, the list is empty -- Typst prints only cited works.
257 pub fn reference_list(&self) -> Vec<Reference> {
258 let mut refs: Vec<&Entry> = self.cited.iter()
259 .filter_map(|k| self.entry(k))
260 .collect();
261 sort_entries(&mut refs);
262 let suffixes = year_suffixes(&refs);
263 refs.iter()
264 .map(|e| format_reference(e, suffixes.get(e.key()).map(|s| s.as_str()).unwrap_or("")))
265 .collect()
266 }
267
268 /// Every entry in the file, sorted and formatted. For tests and for a "print all" mode.
269 pub fn all_references(&self) -> Vec<Reference> {
270 let mut refs: Vec<&Entry> = self.entries.iter().collect();
271 sort_entries(&mut refs);
272 let suffixes = year_suffixes(&refs);
273 refs.iter()
274 .map(|e| format_reference(e, suffixes.get(e.key()).map(|s| s.as_str()).unwrap_or("")))
275 .collect()
276 }
277}
278
279// ---------------------------------------------------------------------------------------------
280// Parsing.
281// ---------------------------------------------------------------------------------------------
282
283/// Parses one `@type{ key, field = value, ... }` starting at `chars[start]` (the `@`). Returns the
284/// entry (or `None` for a `@string`/`@preamble`/`@comment` block) and the index just past its close.
285fn parse_entry(chars: &[char], start: usize) -> Outcome<(Option<Entry>, usize)> {
286 let n = chars.len();
287 let mut i = start + 1; // past '@'
288 // Read the type word.
289 let mut kind_word = String::new();
290 while i < n && (chars[i].is_ascii_alphabetic()) {
291 kind_word.push(chars[i]);
292 i += 1;
293 }
294 // Skip to the opening brace or paren.
295 while i < n && chars[i] != '{' && chars[i] != '(' {
296 if chars[i] == '@' {
297 // A malformed entry with no body; bail without consuming the next '@'.
298 return Ok((None, i));
299 }
300 i += 1;
301 }
302 if i >= n {
303 return Ok((None, n));
304 }
305 let open = chars[i];
306 let close = if open == '{' { '}' } else { ')' };
307 i += 1;
308
309 let lower = kind_word.to_ascii_lowercase();
310 if lower == "string" || lower == "preamble" || lower == "comment" {
311 // Skip the balanced body.
312 let end = skip_balanced(chars, i, open, close);
313 return Ok((None, end));
314 }
315
316 // Read the citation key up to the first comma.
317 let mut key = String::new();
318 while i < n && chars[i] != ',' && chars[i] != close {
319 if !chars[i].is_whitespace() {
320 key.push(chars[i]);
321 }
322 i += 1;
323 }
324 let mut fields: Vec<(String, String)> = Vec::new();
325 // Read fields.
326 loop {
327 // Skip separators and whitespace.
328 while i < n && (chars[i] == ',' || chars[i].is_whitespace()) {
329 i += 1;
330 }
331 if i >= n || chars[i] == close {
332 if i < n {
333 i += 1; // consume close
334 }
335 break;
336 }
337 // Field name.
338 let mut name = String::new();
339 while i < n && chars[i] != '=' && chars[i] != close && !chars[i].is_whitespace() {
340 name.push(chars[i]);
341 i += 1;
342 }
343 // Skip whitespace to '='.
344 while i < n && chars[i].is_whitespace() {
345 i += 1;
346 }
347 if i >= n || chars[i] != '=' {
348 // No value; a trailing key or malformed field. Stop at close if present.
349 if i < n && chars[i] == close {
350 i += 1;
351 }
352 break;
353 }
354 i += 1; // past '='
355 while i < n && chars[i].is_whitespace() {
356 i += 1;
357 }
358 let (raw, next) = read_value(chars, i);
359 i = next;
360 if !name.is_empty() {
361 // `author` and `editor` are read by `parse_names`, which needs the BibTeX brace grouping intact:
362 // a corporate author is written `{{Name}}`, so after `read_value` strips the outer delimiter its
363 // value is one whole `{...}` group, and that surviving group is the only signal that the name is an
364 // organisation and must not be surname-inverted. `decode_value` collapses grouping braces, so
365 // decoding here would erase it and leave "International Energy Agency" to invert to "Agency,
366 // International Energy". These two fields are therefore stored raw; `parse_names` decodes each name
367 // part itself. Every other field is decoded now, as before.
368 let value = if name.eq_ignore_ascii_case("author") || name.eq_ignore_ascii_case("editor") {
369 raw
370 } else {
371 decode_value(&raw)
372 };
373 fields.push((name.clone(), value));
374 }
375 }
376
377 let entry = Entry {
378 key,
379 kind: EntryKind::from_type(&lower),
380 fields,
381 };
382 Ok((Some(entry), i))
383}
384
385/// Reads a field value: a `{...}` group, a `"..."` string, or a bare token (number or macro name).
386/// Returns the raw inner text (braces or quotes stripped at the outermost level only) and the next
387/// index.
388fn read_value(chars: &[char], start: usize) -> (String, usize) {
389 let n = chars.len();
390 let mut i = start;
391 if i >= n {
392 return (String::new(), i);
393 }
394 match chars[i] {
395 '{' => {
396 let mut depth = 0i32;
397 let mut out = String::new();
398 while i < n {
399 let c = chars[i];
400 if c == '{' {
401 depth += 1;
402 if depth > 1 {
403 out.push(c);
404 }
405 i += 1;
406 } else if c == '}' {
407 depth -= 1;
408 if depth == 0 {
409 i += 1;
410 break;
411 }
412 out.push(c);
413 i += 1;
414 } else {
415 out.push(c);
416 i += 1;
417 }
418 }
419 (out, i)
420 }
421 '"' => {
422 // Quoted value; braces inside protect nested quotes.
423 let mut depth = 0i32;
424 let mut out = String::new();
425 i += 1; // past opening quote
426 while i < n {
427 let c = chars[i];
428 if c == '{' {
429 depth += 1;
430 out.push(c);
431 i += 1;
432 } else if c == '}' {
433 depth -= 1;
434 out.push(c);
435 i += 1;
436 } else if c == '"' && depth == 0 {
437 i += 1;
438 break;
439 } else {
440 out.push(c);
441 i += 1;
442 }
443 }
444 (out, i)
445 }
446 _ => {
447 // Bare token to the next comma or closing brace/paren.
448 let mut out = String::new();
449 while i < n && chars[i] != ',' && chars[i] != '}' && chars[i] != ')' {
450 out.push(chars[i]);
451 i += 1;
452 }
453 (out.trim().to_string(), i)
454 }
455 }
456}
457
458/// Returns the index just past the balanced group opened at `i` (already inside the opener).
459fn skip_balanced(chars: &[char], start: usize, open: char, close: char) -> usize {
460 let n = chars.len();
461 let mut i = start;
462 let mut depth = 1i32;
463 while i < n && depth > 0 {
464 if chars[i] == open {
465 depth += 1;
466 } else if chars[i] == close {
467 depth -= 1;
468 }
469 i += 1;
470 }
471 i
472}
473
474// ---------------------------------------------------------------------------------------------
475// Value decoding: LaTeX accents, escapes, dashes and quotes to Unicode.
476// ---------------------------------------------------------------------------------------------
477
478/// Decodes a raw BibTeX value to display text: resolves accent commands (`{\'e}` -> e-acute), the
479/// literal escapes `\&`/`\%`/`\$` and the special letters (`\o`, `\L`, ...), collapses grouping
480/// braces, and turns `--` into an en dash and TeX quotes into curly quotes.
481fn decode_value(raw: &str) -> String {
482 let chars: Vec<char> = raw.chars().collect();
483 let n = chars.len();
484 let mut out = String::new();
485 let mut i = 0usize;
486 while i < n {
487 let c = chars[i];
488 match c {
489 '\\' => {
490 let (text, next) = decode_control(&chars, i);
491 out.push_str(&text);
492 i = next;
493 }
494 '{' | '}' => {
495 // Grouping brace: drop it, keep the contents.
496 i += 1;
497 }
498 '-' if i + 1 < n && chars[i + 1] == '-' => {
499 if i + 2 < n && chars[i + 2] == '-' {
500 out.push('\u{2014}'); // em dash
501 i += 3;
502 } else {
503 out.push('\u{2013}'); // en dash
504 i += 2;
505 }
506 }
507 '`' if i + 1 < n && chars[i + 1] == '`' => {
508 out.push('\u{201C}'); // opening double quote
509 i += 2;
510 }
511 '`' => {
512 out.push('\u{2018}'); // opening single quote
513 i += 1;
514 }
515 '\'' if i + 1 < n && chars[i + 1] == '\'' => {
516 out.push('\u{201D}'); // closing double quote
517 i += 2;
518 }
519 '~' => {
520 out.push('\u{00A0}'); // non-breaking space
521 i += 1;
522 }
523 _ => {
524 out.push(c);
525 i += 1;
526 }
527 }
528 }
529 out
530}
531
532/// Decodes a control sequence beginning at `chars[i]` (a backslash). Returns the replacement text
533/// and the index past what it consumed.
534fn decode_control(chars: &[char], i: usize) -> (String, usize) {
535 let n = chars.len();
536 let mut j = i + 1; // past backslash
537 if j >= n {
538 return (String::new(), j);
539 }
540 let c = chars[j];
541 // Single-character escapes and accent markers.
542 if matches!(c, '&' | '%' | '$' | '#' | '_' | '{' | '}') {
543 return (c.to_string(), j + 1);
544 }
545 if matches!(c, '\'' | '`' | '"' | '^' | '~' | '=' | '.') {
546 // Accent taking the following letter, which may be `{x}` or bare `x`.
547 j += 1;
548 let (letter, next) = read_accent_arg(chars, j);
549 if let Some(a) = apply_accent(c, letter) {
550 return (a.to_string(), next);
551 }
552 return (letter.map(|l| l.to_string()).unwrap_or_default(), next);
553 }
554 // Alphabetic control word: `\c{c}`, `\v{s}`, `\L`, `\o`, `\ss`, ...
555 let mut word = String::new();
556 while j < n && chars[j].is_ascii_alphabetic() {
557 word.push(chars[j]);
558 j += 1;
559 }
560 // Accent commands spelled as letters take an argument.
561 if matches!(word.as_str(), "c" | "v" | "u" | "H" | "r" | "k") {
562 let (letter, next) = read_accent_arg(chars, j);
563 if let Some(a) = apply_letter_accent(&word, letter) {
564 return (a.to_string(), next);
565 }
566 return (letter.map(|l| l.to_string()).unwrap_or_default(), next);
567 }
568 if let Some(s) = special_letter(&word) {
569 return (s.to_string(), j);
570 }
571 // Unknown command: drop the backslash, keep the word.
572 (word, j)
573}
574
575/// Reads the argument of an accent: `{x}`, a bare letter, or nothing. Skips a leading space that
576/// separates a control word from its letter.
577fn read_accent_arg(chars: &[char], start: usize) -> (Option<char>, usize) {
578 let n = chars.len();
579 let mut i = start;
580 while i < n && chars[i] == ' ' {
581 i += 1;
582 }
583 if i >= n {
584 return (None, i);
585 }
586 if chars[i] == '{' {
587 i += 1;
588 let letter = if i < n && chars[i] != '}' { Some(chars[i]) } else { None };
589 if letter.is_some() {
590 i += 1;
591 }
592 if i < n && chars[i] == '}' {
593 i += 1;
594 }
595 (letter, i)
596 } else {
597 let letter = Some(chars[i]);
598 (letter, i + 1)
599 }
600}
601
602fn apply_accent(mark: char, letter: Option<char>) -> Option<char> {
603 let l = letter?;
604 let r = match (mark, l) {
605 ('\'', 'a') => 'á', ('\'', 'e') => 'é', ('\'', 'i') => 'í', ('\'', 'o') => 'ó',
606 ('\'', 'u') => 'ú', ('\'', 'y') => 'ý', ('\'', 'n') => 'ń', ('\'', 'c') => 'ć',
607 ('\'', 's') => 'ś', ('\'', 'z') => 'ź', ('\'', 'A') => 'Á', ('\'', 'E') => 'É',
608 ('\'', 'I') => 'Í', ('\'', 'O') => 'Ó', ('\'', 'U') => 'Ú',
609 ('`', 'a') => 'à', ('`', 'e') => 'è', ('`', 'i') => 'ì', ('`', 'o') => 'ò',
610 ('`', 'u') => 'ù', ('`', 'A') => 'À', ('`', 'E') => 'È', ('`', 'O') => 'Ò',
611 ('"', 'a') => 'ä', ('"', 'e') => 'ë', ('"', 'i') => 'ï', ('"', 'o') => 'ö',
612 ('"', 'u') => 'ü', ('"', 'y') => 'ÿ', ('"', 'A') => 'Ä', ('"', 'O') => 'Ö',
613 ('"', 'U') => 'Ü',
614 ('^', 'a') => 'â', ('^', 'e') => 'ê', ('^', 'i') => 'î', ('^', 'o') => 'ô',
615 ('^', 'u') => 'û', ('^', 'A') => 'Â', ('^', 'O') => 'Ô',
616 ('~', 'a') => 'ã', ('~', 'n') => 'ñ', ('~', 'o') => 'õ', ('~', 'A') => 'Ã',
617 ('~', 'N') => 'Ñ', ('~', 'O') => 'Õ',
618 ('=', 'a') => 'ā', ('=', 'e') => 'ē', ('=', 'i') => 'ī', ('=', 'o') => 'ō',
619 ('=', 'u') => 'ū',
620 ('.', 'z') => 'ż', ('.', 'e') => 'ė',
621 _ => return None,
622 };
623 Some(r)
624}
625
626fn apply_letter_accent(cmd: &str, letter: Option<char>) -> Option<char> {
627 let l = letter?;
628 let r = match (cmd, l) {
629 ("c", 'c') => 'ç', ("c", 'C') => 'Ç', ("c", 's') => 'ş', ("c", 'S') => 'Ş',
630 ("v", 's') => 'š', ("v", 'S') => 'Š', ("v", 'c') => 'č', ("v", 'C') => 'Č',
631 ("v", 'z') => 'ž', ("v", 'Z') => 'Ž', ("v", 'r') => 'ř', ("v", 'e') => 'ě',
632 ("v", 'n') => 'ň',
633 ("u", 'a') => 'ă', ("u", 'g') => 'ğ', ("u", 'G') => 'Ğ',
634 ("H", 'o') => 'ő', ("H", 'u') => 'ű',
635 ("r", 'a') => 'å', ("r", 'A') => 'Å', ("r", 'u') => 'ů',
636 ("k", 'a') => 'ą', ("k", 'e') => 'ę',
637 _ => return None,
638 };
639 Some(r)
640}
641
642fn special_letter(word: &str) -> Option<char> {
643 let r = match word {
644 "L" => 'Ł', "l" => 'ł', "o" => 'ø', "O" => 'Ø',
645 "ss" => 'ß', "ae" => 'æ', "AE" => 'Æ', "oe" => 'œ', "OE" => 'Œ',
646 "aa" => 'å', "AA" => 'Å', "i" => 'ı', "j" => 'ȷ', "dh" => 'ð', "DH" => 'Ð',
647 "th" => 'þ', "TH" => 'Þ',
648 _ => return None,
649 };
650 Some(r)
651}
652
653// ---------------------------------------------------------------------------------------------
654// Name parsing.
655// ---------------------------------------------------------------------------------------------
656
657/// Parses a BibTeX author/editor field into names. Names are separated by ` and `; a name wrapped
658/// in an extra brace group (`{{...}}` in the field) is corporate and kept whole. A personal name is
659/// either `Family, Given` (comma form) or `Given ... Family` (natural form), the surname being the
660/// text after the comma or the final whitespace-separated token.
661fn parse_names(field: &str) -> Vec<Name> {
662 let mut names: Vec<Name> = Vec::new();
663 for part in split_top(field) {
664 let trimmed = part.trim();
665 if trimmed.is_empty() {
666 continue;
667 }
668 // Corporate: the whole part is one brace group.
669 if is_braced_whole(trimmed) {
670 let inner = &trimmed[1..trimmed.len() - 1];
671 names.push(Name {
672 family: decode_value(inner),
673 given: String::new(),
674 corporate: true,
675 });
676 continue;
677 }
678 if let Some(comma) = trimmed.find(',') {
679 let family = decode_value(trimmed[..comma].trim());
680 let given = decode_value(trimmed[comma + 1..].trim());
681 names.push(Name { family, given, corporate: false });
682 } else {
683 // Natural order: last token is the surname.
684 let toks: Vec<&str> = trimmed.split_whitespace().collect();
685 if toks.len() <= 1 {
686 names.push(Name {
687 family: decode_value(trimmed),
688 given: String::new(),
689 corporate: false,
690 });
691 } else {
692 let family = decode_value(toks[toks.len() - 1]);
693 let given = decode_value(&toks[..toks.len() - 1].join(" "));
694 names.push(Name { family, given, corporate: false });
695 }
696 }
697 }
698 names
699}
700
701/// Splits an author field on top-level ` and ` (not inside braces).
702fn split_top(field: &str) -> Vec<String> {
703 let chars: Vec<char> = field.chars().collect();
704 let n = chars.len();
705 let mut parts: Vec<String> = Vec::new();
706 let mut cur = String::new();
707 let mut depth = 0i32;
708 let mut i = 0usize;
709 while i < n {
710 let c = chars[i];
711 if c == '{' {
712 depth += 1;
713 cur.push(c);
714 i += 1;
715 } else if c == '}' {
716 depth -= 1;
717 cur.push(c);
718 i += 1;
719 } else if depth == 0
720 && c == ' '
721 && i + 4 < n
722 && chars[i + 1] == 'a'
723 && chars[i + 2] == 'n'
724 && chars[i + 3] == 'd'
725 && chars[i + 4] == ' '
726 {
727 parts.push(cur.clone());
728 cur.clear();
729 i += 5;
730 } else {
731 cur.push(c);
732 i += 1;
733 }
734 }
735 if !cur.trim().is_empty() {
736 parts.push(cur);
737 }
738 parts
739}
740
741fn is_braced_whole(s: &str) -> bool {
742 let chars: Vec<char> = s.chars().collect();
743 if chars.len() < 2 || chars[0] != '{' || chars[chars.len() - 1] != '}' {
744 return false;
745 }
746 let mut depth = 0i32;
747 for (k, &c) in chars.iter().enumerate() {
748 if c == '{' {
749 depth += 1;
750 } else if c == '}' {
751 depth -= 1;
752 if depth == 0 && k != chars.len() - 1 {
753 return false; // the first group closes early: not one whole group
754 }
755 }
756 }
757 depth == 0
758}
759
760// ---------------------------------------------------------------------------------------------
761// In-text citation label.
762// ---------------------------------------------------------------------------------------------
763
764/// The author part of an in-text label, e.g. `Scott`, `Kahneman and Tversky`, `Acemoglu et al.`. Three
765/// or more names collapse to the first plus `et al.`; an authorless work falls back to its title. This
766/// is the key two references share when they must be disambiguated by a year suffix.
767fn cite_who(entry: &Entry) -> String {
768 let names = entry.credited();
769 match names.len() {
770 0 => entry.field("title").map(|t| chicago_title_case(t)).unwrap_or_default(),
771 1 => names[0].family().to_string(),
772 2 => fmt!("{} and {}", names[0].family(), names[1].family()),
773 _ => fmt!("{} et al.", names[0].family()),
774 }
775}
776
777/// The author-year label for one entry, e.g. `Scott 1976`, `Kahneman and Tversky 1979`,
778/// `Acemoglu et al. 2001`. `suffix` is the year-disambiguation letter (`a`, `b`, ...) when this entry
779/// shares an author and a year with another cited work, and empty otherwise, so `Zuboff 2019` becomes
780/// `Zuboff 2019a`.
781fn cite_label(entry: &Entry, suffix: &str) -> String {
782 let year = entry.field("year").map(|y| year_display(y)).unwrap_or_else(|| "n.d.".to_string());
783 let year = fmt!("{}{}", year, suffix);
784 let who = cite_who(entry);
785 if who.is_empty() {
786 year
787 } else {
788 fmt!("{} {}", who, year)
789 }
790}
791
792/// The Chicago year-disambiguation suffixes for a reference list: when two or more cited works share an
793/// author label and a year, each takes a letter -- `a`, `b`, `c` ... -- in the order it stands in the
794/// sorted list, so an in-text `(Zuboff 2019a)` matches its reference. A lone author-year takes none. The
795/// returned map is keyed by entry key; a key absent from it has no suffix. `sorted` must already be in
796/// reference-list order, since the letters follow it.
797fn year_suffixes(sorted: &[&Entry]) -> BTreeMap<String, String> {
798 // Group the entries by (author label, year), keeping each group's members in the sorted order they
799 // arrived in; a group of two or more is ambiguous and its members take letters.
800 let mut order: Vec<Vec<String>> = Vec::new();
801 let mut seen: BTreeMap<String, usize> = BTreeMap::new();
802 for e in sorted {
803 let year = e.field("year").map(|y| year_display(y)).unwrap_or_else(|| "n.d.".to_string());
804 let gid = fmt!("{}\u{0}{}", cite_who(e), year);
805 let pos = match seen.get(&gid) {
806 Some(&p) => p,
807 None => {
808 let p = order.len();
809 order.push(Vec::new());
810 seen.insert(gid, p);
811 p
812 },
813 };
814 order[pos].push(e.key().to_string());
815 }
816 let mut out: BTreeMap<String, String> = BTreeMap::new();
817 for group in &order {
818 if group.len() > 1 {
819 for (n, key) in group.iter().enumerate() {
820 out.insert(key.clone(), suffix_letter(n));
821 }
822 }
823 }
824 out
825}
826
827/// The `n`-th disambiguation letter: `0 -> a`, `25 -> z`, `26 -> aa`, and so on. A bibliography never
828/// runs past a handful, but the base-26 roll-over keeps a pathological run well-formed rather than wrapping.
829fn suffix_letter(mut n: usize) -> String {
830 let mut s = String::new();
831 loop {
832 let d = (n % 26) as u8;
833 s.insert(0, (b'a' + d) as char);
834 if n < 26 {
835 break;
836 }
837 n = n / 26 - 1;
838 }
839 s
840}
841
842/// The visible year: the leading run of digits (drops a BibTeX `{2024}` note or a trailing letter).
843fn year_display(y: &str) -> String {
844 let digits: String = y.chars().take_while(|c| c.is_ascii_digit()).collect();
845 if digits.is_empty() { y.trim().to_string() } else { digits }
846}
847
848// ---------------------------------------------------------------------------------------------
849// Reference list formatting (Chicago author-date).
850// ---------------------------------------------------------------------------------------------
851
852/// A small builder that accumulates runs, merging consecutive same-style text.
853struct RunBuilder {
854 runs: Vec<RefRun>,
855}
856
857impl RunBuilder {
858 fn new() -> Self { Self { runs: Vec::new() } }
859
860 fn push(&mut self, text: &str, style: RefStyle) {
861 if text.is_empty() {
862 return;
863 }
864 if let Some(last) = self.runs.last_mut() {
865 if last.style == style {
866 last.text.push_str(text);
867 return;
868 }
869 }
870 self.runs.push(RefRun { text: text.to_string(), style });
871 }
872
873 fn normal(&mut self, text: &str) { self.push(text, RefStyle::Normal); }
874 fn italic(&mut self, text: &str) { self.push(text, RefStyle::Italic); }
875
876 fn finish(mut self, key: &str) -> Reference {
877 smartquote_runs(&mut self.runs);
878 Reference { key: key.to_string(), runs: self.runs }
879 }
880}
881
882/// Formats one entry as a Chicago author-date reference. `suffix` is the year-disambiguation letter
883/// (`a`, `b`, ...) when this entry shares an author and a year with another in the list, empty otherwise.
884fn format_reference(entry: &Entry, suffix: &str) -> Reference {
885 let mut b = RunBuilder::new();
886 // Author. Year.
887 let author = author_list(&entry.credited(), entry.field("author").is_none());
888 if !author.is_empty() {
889 b.normal(&author);
890 // A name ending in an initial already carries its full stop.
891 if author.ends_with('.') {
892 b.normal(" ");
893 } else {
894 b.normal(". ");
895 }
896 }
897 if let Some(y) = entry.field("year") {
898 b.normal(&fmt!("{}{}", year_display(y), suffix));
899 b.normal(". ");
900 }
901 match entry.kind {
902 EntryKind::Article => format_article(&mut b, entry),
903 EntryKind::InCollection | EntryKind::InProceedings => format_incollection(&mut b, entry),
904 EntryKind::TechReport | EntryKind::Report => format_report(&mut b, entry),
905 EntryKind::Online => format_online(&mut b, entry),
906 _ => format_book(&mut b, entry),
907 }
908 b.finish(entry.key())
909}
910
911/// The author string for the list: first name inverted, the rest natural, `and` before the last,
912/// with an `, ed.`/`, eds.` tag when the credited names are editors.
913fn author_list(names: &[Name], is_editor: bool) -> String {
914 let body = match names.len() {
915 0 => String::new(),
916 1 => names[0].inverted(),
917 2 => fmt!("{}, and {}", names[0].inverted(), names[1].natural()),
918 _ => {
919 let mut s = names[0].inverted();
920 for name in &names[1..names.len() - 1] {
921 s.push_str(", ");
922 s.push_str(&name.natural());
923 }
924 s.push_str(", and ");
925 s.push_str(&names[names.len() - 1].natural());
926 s
927 }
928 };
929 if is_editor && !body.is_empty() {
930 let tag = if names.len() > 1 { ", eds." } else { ", ed." };
931 fmt!("{}{}", body, tag)
932 } else {
933 body
934 }
935}
936
937fn format_book(b: &mut RunBuilder, entry: &Entry) {
938 if let Some(t) = entry.field("title") {
939 b.italic(&chicago_title_case(t));
940 b.normal(". ");
941 }
942 // Chicago author-date omits the place for a book with a named publisher.
943 if let Some(p) = entry.field("publisher") {
944 b.normal(p);
945 b.normal(".");
946 } else if let Some(a) = entry.field("address") {
947 b.normal(a);
948 b.normal(".");
949 }
950 append_url(b, entry);
951}
952
953fn format_article(b: &mut RunBuilder, entry: &Entry) {
954 if let Some(t) = entry.field("title") {
955 b.normal("\u{201C}");
956 b.normal(&chicago_title_case(t));
957 b.normal(".\u{201D} ");
958 }
959 if let Some(j) = entry.field("journal").or_else(|| entry.field("journaltitle")) {
960 b.italic(&chicago_title_case(j));
961 match entry.field("volume") {
962 Some(v) => {
963 b.normal(" ");
964 b.normal(v);
965 if let Some(num) = entry.field("number") {
966 b.normal(&fmt!(" ({})", num));
967 }
968 if let Some(p) = entry.field("pages") {
969 b.normal(&fmt!(": {}", compress_pages(p)));
970 }
971 b.normal(".");
972 }
973 // Typst sets a bare comma after the journal when there is no volume.
974 None => b.normal(",."),
975 }
976 }
977 append_url(b, entry);
978}
979
980fn format_incollection(b: &mut RunBuilder, entry: &Entry) {
981 if let Some(t) = entry.field("title") {
982 b.normal("\u{201C}");
983 b.normal(&chicago_title_case(t));
984 b.normal(".\u{201D} ");
985 }
986 b.normal("In ");
987 if let Some(bt) = entry.field("booktitle") {
988 b.italic(&chicago_title_case(bt));
989 }
990 if let Some(ed) = entry.field("editor") {
991 let eds = parse_names(ed);
992 b.normal(&fmt!(", edited by {}", natural_join(&eds)));
993 }
994 if let Some(p) = entry.field("pages") {
995 b.normal(&fmt!(", {}", compress_pages(p)));
996 }
997 b.normal(". ");
998 if let Some(p) = entry.field("publisher") {
999 b.normal(p);
1000 b.normal(".");
1001 } else if let Some(a) = entry.field("address") {
1002 b.normal(a);
1003 b.normal(".");
1004 }
1005 append_url(b, entry);
1006}
1007
1008fn format_report(b: &mut RunBuilder, entry: &Entry) {
1009 if let Some(t) = entry.field("title") {
1010 b.italic(&chicago_title_case(t));
1011 b.normal(". ");
1012 }
1013 let kind = entry.field("type").unwrap_or("Working Paper");
1014 match entry.field("number") {
1015 Some(num) => b.normal(&fmt!("{} No. {}. ", kind, num)),
1016 None => {
1017 b.normal(kind);
1018 b.normal(". ");
1019 }
1020 }
1021 if let Some(inst) = entry.field("institution") {
1022 // The address stands in for the imprint when present; otherwise the institution.
1023 match entry.field("address") {
1024 Some(a) => {
1025 b.normal(a);
1026 b.normal(".");
1027 }
1028 None => {
1029 b.normal(inst);
1030 b.normal(".");
1031 }
1032 }
1033 } else if let Some(a) = entry.field("address") {
1034 b.normal(a);
1035 b.normal(".");
1036 }
1037 append_url(b, entry);
1038}
1039
1040fn format_online(b: &mut RunBuilder, entry: &Entry) {
1041 if let Some(t) = entry.field("title") {
1042 b.italic(&chicago_title_case(t));
1043 b.normal(". ");
1044 }
1045 if let Some(pubr) = entry.field("publisher").or_else(|| entry.field("organization")) {
1046 b.normal(pubr);
1047 b.normal(". ");
1048 }
1049 if let Some(u) = entry.field("url") {
1050 b.normal(u);
1051 b.normal(".");
1052 }
1053}
1054
1055/// Appends a DOI (as an `https://doi.org/` URL) or a bare URL, whichever is present.
1056fn append_url(b: &mut RunBuilder, entry: &Entry) {
1057 if let Some(doi) = entry.field("doi") {
1058 b.normal(&fmt!(" https://doi.org/{}.", doi.trim()));
1059 } else if let Some(u) = entry.field("url") {
1060 b.normal(&fmt!(" {}.", u.trim()));
1061 }
1062}
1063
1064/// Joins names in natural order with commas and a final `and`.
1065fn natural_join(names: &[Name]) -> String {
1066 match names.len() {
1067 0 => String::new(),
1068 1 => names[0].natural(),
1069 2 => fmt!("{} and {}", names[0].natural(), names[1].natural()),
1070 _ => {
1071 let mut s = String::new();
1072 for name in &names[..names.len() - 1] {
1073 s.push_str(&name.natural());
1074 s.push_str(", ");
1075 }
1076 s.push_str("and ");
1077 s.push_str(&names[names.len() - 1].natural());
1078 s
1079 }
1080 }
1081}
1082
1083// ---------------------------------------------------------------------------------------------
1084// Sorting.
1085// ---------------------------------------------------------------------------------------------
1086
1087/// Sorts references Chicago-style. Works are ordered by the first author's surname; among works by
1088/// the same first author, a work with fewer authors comes first (a solo work before coauthored
1089/// ones), then by the coauthors' surnames, then by year, then by title. This author-count tie-break
1090/// is Typst's behaviour and was verified against a render.
1091fn sort_entries(entries: &mut [&Entry]) {
1092 entries.sort_by(|a, b| sort_key(a).cmp(&sort_key(b)));
1093}
1094
1095fn sort_key(entry: &Entry) -> (String, usize, Vec<String>, i64, String) {
1096 let names = entry.credited();
1097 let first = names.first().map(|n| fold_key(n.family())).unwrap_or_default();
1098 let count = names.len();
1099 let rest: Vec<String> = names.iter().skip(1).map(|n| fold_key(n.family())).collect();
1100 let year = entry.year().unwrap_or(i64::MAX);
1101 let title = entry.field("title").map(|t| fold_key(t)).unwrap_or_default();
1102 (first, count, rest, year, title)
1103}
1104
1105/// A case- and accent-folded sort key for a surname or title.
1106fn fold_key(s: &str) -> String {
1107 s.chars()
1108 .filter(|c| c.is_alphanumeric() || *c == ' ')
1109 .flat_map(|c| c.to_lowercase())
1110 .collect()
1111}
1112
1113// ---------------------------------------------------------------------------------------------
1114// Chicago page-range abbreviation.
1115// ---------------------------------------------------------------------------------------------
1116
1117/// Abbreviates a page range by the Chicago rule and sets the separator as an en dash. `263--291`
1118/// becomes `263-91`, `1369--1401` becomes `1369-401`, and `488--500` stays `488-500`. A range whose
1119/// ends differ in length, or a non-numeric range, keeps both ends whole.
1120fn compress_pages(pages: &str) -> String {
1121 let norm = pages.replace("--", "-").replace('\u{2013}', "-");
1122 let bits: Vec<&str> = norm.splitn(2, '-').collect();
1123 if bits.len() != 2 {
1124 return norm.trim().to_string();
1125 }
1126 let a = bits[0].trim();
1127 let z = bits[1].trim();
1128 let en = '\u{2013}';
1129 let (an, zn) = (a.parse::<i64>(), z.parse::<i64>());
1130 match (an, zn) {
1131 (Ok(av), Ok(_)) if a.len() == z.len() => {
1132 if av < 100 || av % 100 == 0 {
1133 fmt!("{}{}{}", a, en, z)
1134 } else {
1135 let ac: Vec<char> = a.chars().collect();
1136 let zc: Vec<char> = z.chars().collect();
1137 let mut common = 0usize;
1138 while common < ac.len() && ac[common] == zc[common] {
1139 common += 1;
1140 }
1141 let min_keep = if av % 100 <= 9 { 1 } else { 2 };
1142 let keep = std::cmp::max(zc.len() - common, min_keep);
1143 let tail: String = zc[zc.len() - keep..].iter().collect();
1144 fmt!("{}{}{}", a, en, tail)
1145 }
1146 }
1147 _ => fmt!("{}{}{}", a, en, z),
1148 }
1149}
1150
1151// ---------------------------------------------------------------------------------------------
1152// Chicago headline-style title casing.
1153// ---------------------------------------------------------------------------------------------
1154
1155/// Downcases the minor words of an already-capitalised title to Typst's Chicago headline style,
1156/// e.g. `Decision Under Risk` becomes `Decision under Risk`. The first word, and the first word
1157/// after a colon, stay capitalised; a minor word anywhere else is downcased, including in final
1158/// position (`Bringing the State Back In` becomes `... Back in`). Other words keep their source
1159/// capitalisation, so a deliberately capitalised acronym in the source is preserved.
1160fn chicago_title_case(title: &str) -> String {
1161 let words: Vec<&str> = title.split(' ').collect();
1162 let mut out: Vec<String> = Vec::with_capacity(words.len());
1163 let mut start_of_clause = true; // first word, or first after a colon
1164 for (k, w) in words.iter().enumerate() {
1165 let lowered = w.to_lowercase();
1166 let bare: String = lowered.chars().filter(|c| c.is_alphabetic()).collect();
1167 let is_minor = MINOR_WORDS.contains(&bare.as_str());
1168 // Typst downcases a minor word wherever it falls, save the start of a clause -- so even
1169 // a trailing "In" becomes "in" ("Bringing the State Back in").
1170 let downcase = is_minor && !start_of_clause && k != 0;
1171 if downcase {
1172 out.push(lowered);
1173 } else {
1174 out.push(w.to_string());
1175 }
1176 // The next word starts a clause if this word ends with a colon.
1177 start_of_clause = w.ends_with(':');
1178 }
1179 out.join(" ")
1180}
1181
1182/// The articles, coordinating conjunctions and short prepositions Chicago sets lower case when they
1183/// fall inside a title.
1184const MINOR_WORDS: &[&str] = &[
1185 "a", "an", "the",
1186 "and", "but", "or", "nor", "for", "so", "yet",
1187 "as", "at", "by", "in", "of", "off", "on", "per", "to", "up", "via",
1188 "from", "into", "like", "near", "onto", "over", "than", "that", "till",
1189 "unto", "upon", "with", "about", "above", "after", "among", "under",
1190];
1191
1192// ---------------------------------------------------------------------------------------------
1193// Smart quotes.
1194// ---------------------------------------------------------------------------------------------
1195
1196/// Turns straight apostrophes and straight double quotes in the runs into curly quotes, matching
1197/// Typst's smart-quote pass. TeX quote pairs were already converted in [`decode_value`].
1198fn smartquote_runs(runs: &mut [RefRun]) {
1199 for run in runs.iter_mut() {
1200 run.text = smartquote(&run.text);
1201 }
1202}
1203
1204fn smartquote(s: &str) -> String {
1205 let chars: Vec<char> = s.chars().collect();
1206 let n = chars.len();
1207 let mut out = String::with_capacity(n);
1208 let mut dq_open = false;
1209 for (i, &c) in chars.iter().enumerate() {
1210 match c {
1211 '\'' => {
1212 let prev_alnum = i > 0 && chars[i - 1].is_alphanumeric();
1213 if prev_alnum {
1214 out.push('\u{2019}'); // apostrophe / closing single
1215 } else {
1216 out.push('\u{2018}');
1217 }
1218 }
1219 '"' => {
1220 if dq_open {
1221 out.push('\u{201D}');
1222 dq_open = false;
1223 } else {
1224 out.push('\u{201C}');
1225 dq_open = true;
1226 }
1227 }
1228 _ => out.push(c),
1229 }
1230 }
1231 out
1232}
1233
1234#[cfg(test)]
1235mod tests {
1236 use super::*;
1237
1238 const SAMPLE: &str = r#"
1239% A comment line that must be skipped.
1240@book{scott1976moral,
1241 author = {Scott, James C.},
1242 title = {The Moral Economy of the Peasant: Rebellion and Subsistence in Southeast Asia},
1243 year = {1976},
1244 publisher = {Yale University Press},
1245 address = {New Haven}
1246}
1247
1248@article{kahneman1979prospect,
1249 author = {Kahneman, Daniel and Tversky, Amos},
1250 title = {Prospect Theory: An Analysis of Decision Under Risk},
1251 journal = {Econometrica},
1252 volume = {47},
1253 number = {2},
1254 pages = {263--291},
1255 year = {1979},
1256 doi = {10.2307/1914185}
1257}
1258
1259@article{acemoglu2001colonial,
1260 author = {Acemoglu, Daron and Johnson, Simon and Robinson, James A.},
1261 title = {The Colonial Origins of Comparative Development: An Empirical Investigation},
1262 journal = {American Economic Review},
1263 volume = {91},
1264 number = {5},
1265 pages = {1369--1401},
1266 year = {2001},
1267 doi = {10.1257/aer.91.5.1369}
1268}
1269
1270@techreport{acemoglu2024simple,
1271 author = {Acemoglu, Daron},
1272 title = {The Simple Macroeconomics of {AI}},
1273 institution = {National Bureau of Economic Research},
1274 type = {Working Paper},
1275 number = {32487},
1276 year = {2024},
1277 address = {Cambridge, MA}
1278}
1279
1280@book{worldbank2024,
1281 author = {World Bank},
1282 title = {Education Finance Watch 2024},
1283 year = {2024},
1284 institution = {World Bank Group}
1285}
1286
1287@incollection{tilly1985war,
1288 author = {Tilly, Charles},
1289 title = {War Making and State Making as Organized Crime},
1290 booktitle = {Bringing the State Back In},
1291 editor = {Evans, Peter B. and Rueschemeyer, Dietrich and Skocpol, Theda},
1292 publisher = {Cambridge University Press},
1293 address = {Cambridge},
1294 year = {1985},
1295 pages = {169--191}
1296}
1297
1298@book{kornai1992socialist,
1299 author = {Kornai, J{\'a}nos},
1300 title = {The Socialist System},
1301 year = {1992},
1302 publisher = {Princeton University Press}
1303}
1304"#;
1305
1306 fn bib() -> Bibliography {
1307 match Bibliography::parse(SAMPLE) {
1308 Ok(b) => b,
1309 Err(e) => panic!("parse failed: {}", e),
1310 }
1311 }
1312
1313 #[test]
1314 fn parses_all_entries() {
1315 let b = bib();
1316 assert_eq!(b.len(), 7);
1317 let scott = b.entry("scott1976moral").expect("scott present");
1318 assert_eq!(scott.kind(), EntryKind::Book);
1319 assert_eq!(scott.field("year"), Some("1976"));
1320 assert_eq!(scott.field("publisher"), Some("Yale University Press"));
1321 }
1322
1323 #[test]
1324 fn decodes_accent_and_brace() {
1325 let b = bib();
1326 let k = b.entry("kornai1992socialist").expect("kornai present");
1327 let names = k.credited();
1328 assert_eq!(names[0].family(), "Kornai");
1329 assert_eq!(names[0].given(), "János"); // {\'a} decoded
1330 // The braced {AI} keeps its letters, braces dropped.
1331 let tr = b.entry("acemoglu2024simple").expect("report present");
1332 assert_eq!(tr.field("title"), Some("The Simple Macroeconomics of AI"));
1333 }
1334
1335 #[test]
1336 fn parses_multiple_authors() {
1337 let b = bib();
1338 let a = b.entry("acemoglu2001colonial").expect("present");
1339 let names = a.credited();
1340 assert_eq!(names.len(), 3);
1341 assert_eq!(names[1].family(), "Johnson");
1342 assert_eq!(names[1].given(), "Simon");
1343 }
1344
1345 #[test]
1346 fn corporate_single_brace_is_inverted_as_personal() {
1347 // Typst reads `{World Bank}` as First=World, Last=Bank and inverts it in the list.
1348 let b = bib();
1349 let refr = format_reference(b.entry("worldbank2024").expect("present"), "");
1350 assert!(refr.plain().starts_with("Bank, World. 2024."),
1351 "got: {}", refr.plain());
1352 }
1353
1354 #[test]
1355 fn corporate_double_brace_is_not_inverted() {
1356 // A BibTeX corporate author is written `{{...}}`; after `read_value` strips the outer delimiter the
1357 // surviving brace group marks it an organisation, which Chicago prints verbatim -- never inverted to
1358 // "Agency, International Energy". Regression for the decode-before-parse bug that erased the group.
1359 let src = r#"
1360@report{iea2024,
1361 author = {{International Energy Agency}},
1362 title = {Electricity 2024},
1363 year = {2024},
1364 publisher = {IEA}
1365}
1366"#;
1367 let b = match Bibliography::parse(src) { Ok(b) => b, Err(e) => panic!("parse: {}", e) };
1368 let r = format_reference(b.entry("iea2024").expect("present"), "");
1369 assert!(r.plain().starts_with("International Energy Agency. 2024."),
1370 "corporate author must print verbatim; got: {}", r.plain());
1371 let names = b.entry("iea2024").expect("present").credited();
1372 assert_eq!(names.len(), 1);
1373 assert_eq!(names[0].family(), "International Energy Agency");
1374 assert!(names[0].given().is_empty());
1375 }
1376
1377 #[test]
1378 fn same_author_same_year_takes_letter_suffix() {
1379 // Two cited works by one author in one year disambiguate as 2019a / 2019b, and an in-text citation
1380 // reads the same letter its reference does. A lone author-year (2020) takes no suffix.
1381 let src = r#"
1382@book{zuboffA,
1383 author = {Zuboff, Shoshana},
1384 title = {The Age of Surveillance Capitalism},
1385 year = {2019},
1386 publisher = {PublicAffairs}
1387}
1388@book{zuboffB,
1389 author = {Zuboff, Shoshana},
1390 title = {Big Other},
1391 year = {2019},
1392 publisher = {Profile Books}
1393}
1394@book{zuboffC,
1395 author = {Zuboff, Shoshana},
1396 title = {In the Age of the Smart Machine},
1397 year = {1988},
1398 publisher = {Basic Books}
1399}
1400"#;
1401 let mut b = match Bibliography::parse(src) { Ok(b) => b, Err(e) => panic!("parse: {}", e) };
1402 let _ = b.cite(&["zuboffA", "zuboffB", "zuboffC"]).expect("cite");
1403 let la = b.label("zuboffA").expect("label a");
1404 let lb = b.label("zuboffB").expect("label b");
1405 let lc = b.label("zuboffC").expect("label c");
1406 // The 2019 pair each takes a distinct letter; the 1988 lone work takes none.
1407 assert!(la.ends_with("2019a") || la.ends_with("2019b"), "got {}", la);
1408 assert!(lb.ends_with("2019a") || lb.ends_with("2019b"), "got {}", lb);
1409 assert_ne!(la, lb);
1410 assert_eq!(lc, "Zuboff 1988");
1411 // The reference list carries both suffixes, and each in-text year matches its own reference.
1412 let list = b.reference_list();
1413 let all: String = list.iter().map(|r| r.plain()).collect::<Vec<_>>().join("\n");
1414 assert!(all.contains("2019a."), "list missing 2019a: {}", all);
1415 assert!(all.contains("2019b."), "list missing 2019b: {}", all);
1416 assert!(!all.contains("1988a."), "lone year must not take a suffix: {}", all);
1417 }
1418
1419 #[test]
1420 fn in_text_labels() {
1421 let b = bib();
1422 assert_eq!(b.label("scott1976moral").expect("ok"), "Scott 1976");
1423 assert_eq!(b.label("kahneman1979prospect").expect("ok"), "Kahneman and Tversky 1979");
1424 assert_eq!(b.label("acemoglu2001colonial").expect("ok"), "Acemoglu et al. 2001");
1425 }
1426
1427 #[test]
1428 fn citation_single_and_multiple() {
1429 let mut b = bib();
1430 assert_eq!(b.cite(&["scott1976moral"]).expect("ok"), "(Scott 1976)");
1431 let multi = b.cite(&["scott1976moral", "kahneman1979prospect"]).expect("ok");
1432 assert_eq!(multi, "(Scott 1976; Kahneman and Tversky 1979)");
1433 }
1434
1435 #[test]
1436 fn unknown_key_errors() {
1437 let b = bib();
1438 assert!(b.format_citation(&["nope"]).is_err());
1439 }
1440
1441 #[test]
1442 fn book_reference_form() {
1443 let b = bib();
1444 let r = format_reference(b.entry("scott1976moral").expect("present"), "");
1445 assert_eq!(
1446 r.plain(),
1447 "Scott, James C. 1976. The Moral Economy of the Peasant: Rebellion and Subsistence in Southeast Asia. Yale University Press.");
1448 // The title is one italic run.
1449 assert!(r.runs.iter().any(|run| run.style == RefStyle::Italic
1450 && run.text.starts_with("The Moral Economy")));
1451 }
1452
1453 #[test]
1454 fn article_reference_form_with_page_compression() {
1455 let b = bib();
1456 let r = format_reference(b.entry("kahneman1979prospect").expect("present"), "");
1457 // Title down-cased "Under" -> "under"; pages 263--291 -> 263-91; DOI as a URL.
1458 assert_eq!(
1459 r.plain(),
1460 "Kahneman, Daniel, and Amos Tversky. 1979. \u{201C}Prospect Theory: An Analysis of Decision under Risk.\u{201D} Econometrica 47 (2): 263\u{2013}91. https://doi.org/10.2307/1914185.");
1461 }
1462
1463 #[test]
1464 fn three_author_article_and_long_page_range() {
1465 let b = bib();
1466 let r = format_reference(b.entry("acemoglu2001colonial").expect("present"), "");
1467 assert_eq!(
1468 r.plain(),
1469 "Acemoglu, Daron, Simon Johnson, and James A. Robinson. 2001. \u{201C}The Colonial Origins of Comparative Development: An Empirical Investigation.\u{201D} American Economic Review 91 (5): 1369\u{2013}401. https://doi.org/10.1257/aer.91.5.1369.");
1470 }
1471
1472 #[test]
1473 fn report_reference_form() {
1474 let b = bib();
1475 let r = format_reference(b.entry("acemoglu2024simple").expect("present"), "");
1476 assert_eq!(
1477 r.plain(),
1478 "Acemoglu, Daron. 2024. The Simple Macroeconomics of AI. Working Paper No. 32487. Cambridge, MA.");
1479 }
1480
1481 #[test]
1482 fn incollection_reference_form() {
1483 let b = bib();
1484 let r = format_reference(b.entry("tilly1985war").expect("present"), "");
1485 assert_eq!(
1486 r.plain(),
1487 "Tilly, Charles. 1985. \u{201C}War Making and State Making as Organized Crime.\u{201D} In Bringing the State Back in, edited by Peter B. Evans, Dietrich Rueschemeyer, and Theda Skocpol, 169\u{2013}91. Cambridge University Press.");
1488 }
1489
1490 #[test]
1491 fn page_compression_rule() {
1492 assert_eq!(compress_pages("263--291"), "263\u{2013}91");
1493 assert_eq!(compress_pages("1369--1401"), "1369\u{2013}401");
1494 assert_eq!(compress_pages("488--500"), "488\u{2013}500");
1495 assert_eq!(compress_pages("855--857"), "855\u{2013}57");
1496 assert_eq!(compress_pages("224--232"), "224\u{2013}32");
1497 assert_eq!(compress_pages("97--112"), "97\u{2013}112");
1498 assert_eq!(compress_pages("1--42"), "1\u{2013}42");
1499 }
1500
1501 #[test]
1502 fn reference_list_is_cited_only_and_sorted() {
1503 let mut b = bib();
1504 // Cite three works by, or beginning with, Acemoglu plus Scott, out of order.
1505 let _ = b.cite(&["scott1976moral"]);
1506 let _ = b.cite(&["acemoglu2001colonial"]);
1507 let _ = b.cite(&["acemoglu2024simple"]);
1508 let list = b.reference_list();
1509 assert_eq!(list.len(), 3);
1510 // Acemoglu solo (2024) sorts before Acemoglu-and-coauthors (2001); Scott last.
1511 assert_eq!(list[0].key, "acemoglu2024simple");
1512 assert_eq!(list[1].key, "acemoglu2001colonial");
1513 assert_eq!(list[2].key, "scott1976moral");
1514 }
1515
1516 #[test]
1517 fn empty_cited_list_is_empty() {
1518 let b = bib();
1519 assert!(b.reference_list().is_empty());
1520 }
1521
1522 #[test]
1523 #[ignore]
1524 fn smoke_real_file() {
1525 let path = match std::env::var("REAL_BIB") { Ok(p) => p, Err(_) => return };
1526 let src = std::fs::read_to_string(&path).expect("read real bib");
1527 let b = Bibliography::parse(&src).expect("parse real bib");
1528 println!("REAL entries parsed: {}", b.len());
1529 let mut authored = 0usize;
1530 let mut yearless = 0usize;
1531 for e in &b.entries {
1532 if !e.credited().is_empty() { authored += 1; }
1533 if e.field("year").is_none() { yearless += 1; }
1534 }
1535 println!("with credited names: {} ; without year: {}", authored, yearless);
1536 // Spot-check a few keys against the oracle page 678.
1537 for key in ["scott1976moral", "kahneman1979prospect", "hirschman1970exit"] {
1538 if let Some(e) = b.entry(key) {
1539 println!("[{}] cite = {}", key, cite_label(e, ""));
1540 println!("[{}] ref = {}", key, format_reference(e, "").plain());
1541 }
1542 }
1543 }
1544
1545 #[test]
1546 fn author_count_tie_break() {
1547 // A solo work sorts before a coauthored one by the same first author.
1548 let src = r#"
1549@book{a2012,author={Acemoglu, Daron and Robinson, James A.},title={Why Nations Fail},publisher={Crown},year={2012}}
1550@book{a2023,author={Acemoglu, Daron and Johnson, Simon},title={Power and Progress},publisher={PublicAffairs},year={2023}}
1551@techreport{a2024,author={Acemoglu, Daron},title={Simple},institution={NBER},year={2024}}
1552"#;
1553 let b = match Bibliography::parse(src) { Ok(b) => b, Err(e) => panic!("{}", e) };
1554 let all = b.all_references();
1555 assert_eq!(all[0].key, "a2024"); // solo first
1556 assert_eq!(all[1].key, "a2023"); // Johnson before Robinson
1557 assert_eq!(all[2].key, "a2012");
1558 }
1559}