Files
wordcraft/crates/engine/src/cmd/citations/render.rs
T

168 lines
6.7 KiB
Rust

use super::styles::{Cite, Name, Names, style};
use super::*;
pub(super) fn authors(a: &str) -> Vec<(String, String)> {
a.split(';')
.map(str::trim)
.filter(|x| !x.is_empty())
.map(|x| match x.split_once(',') {
Some((last, first)) => (last.trim().to_string(), first.trim().to_string()),
None => match x.rsplit_once(' ') {
Some((first, last)) => (last.trim().to_string(), first.trim().to_string()),
None => (x.to_string(), String::new()),
},
})
.collect()
}
/// "Alex Mei" → "A. M." (`dots`), or "A M" without them.
fn initials(first: &str, dots: bool) -> String {
let sep = if dots { ". " } else { " " };
let mut out = first.split_whitespace().filter_map(|w| w.chars().next()).map(String::from).collect::<Vec<_>>().join(sep);
if dots && !out.is_empty() {
out.push('.');
}
out
}
fn name(form: Name, last: &str, first: &str) -> String {
let join = |a: String, sep: &str, b: String| if a.is_empty() || b.is_empty() { format!("{a}{b}") } else { format!("{a}{sep}{b}") };
match form {
Name::LastFirst => join(last.to_string(), ", ", first.to_string()),
Name::FirstLast => join(first.to_string(), " ", last.to_string()),
Name::LastInitials => join(last.to_string(), ", ", initials(first, true)),
Name::InitialsLast => join(initials(first, true), " ", last.to_string()),
Name::LastSpaceInitials => join(last.to_string(), " ", initials(first, true)),
Name::UpperLastFirst => join(last.to_uppercase(), ", ", first.to_string()),
Name::UpperLastBareInitials => join(last.to_uppercase(), " ", initials(first, false)),
}
}
fn name_list(au: &[(String, String)], form: &Names) -> String {
let shown = if form.max > 0 { au.get(..form.max).unwrap_or(au) } else { au };
let parts: Vec<String> = shown.iter().enumerate().map(|(i, (l, f))| name(if i == 0 { form.first } else { form.rest }, l, f)).collect();
let mut out = match parts.as_slice() {
[] => String::new(),
[one] => one.clone(),
[a, b] => format!("{a}{}{b}", form.two),
[init @ .., z] => format!("{}{}{z}", init.join(form.sep), form.last),
};
if shown.len() < au.len() {
out.push_str(form.et_al);
}
out
}
/// Append `s`, dropping its leading full stop when `out` already ends with one ("M." + ". ").
fn push_text(out: &mut String, s: &str) {
match s.strip_prefix('.') {
Some(rest) if out.ends_with('.') => out.push_str(rest),
_ => out.push_str(s),
}
}
/// Fill one template segment; `None` when `optional` and a field in it is empty.
fn fill_segment(seg: &str, field: &dyn Fn(&str) -> String, optional: bool) -> Option<String> {
let mut out = String::new();
let mut rest = seg;
while let Some((before, after)) = rest.split_once('{') {
push_text(&mut out, before);
let Some((key, after)) = after.split_once('}') else {
push_text(&mut out, after);
return Some(out);
};
let value = field(key);
if value.is_empty() && optional {
return None;
}
push_text(&mut out, &value);
rest = after;
}
push_text(&mut out, rest);
Some(out)
}
/// Fill an entry template (see [`CiteStyle`]).
fn render(template: &str, field: &dyn Fn(&str) -> String) -> String {
let mut out = String::new();
let mut rest = template;
while !rest.is_empty() {
let (seg, optional, after) = match rest.strip_prefix('⟦') {
Some(group) => match group.split_once('⟧') {
Some((g, after)) => (g, true, after),
None => (group, true, ""),
},
None => match rest.split_once('⟦') {
Some((plain, _)) => (plain, false, rest.get(plain.len()..).unwrap_or("")),
None => (rest, false, ""),
},
};
if let Some(text) = fill_segment(seg, field, optional) {
push_text(&mut out, &text);
}
rest = after;
}
out
}
/// In-text citation for a style; `n` is the source's number in numbered styles.
pub fn cite(src: &Source, style_name: &str, n: usize, pages: &str) -> String {
let st = style(style_name);
let au = authors(&src.author);
let lasts: Vec<&str> = au.iter().map(|a| a.0.as_str()).collect();
let who = match lasts.as_slice() {
[] => src.title.clone(),
[one] => one.to_string(),
[first, ..] if lasts.len() >= st.et_al_from.max(2) => format!("{first} et al."),
[a, b] => format!("{a} {} {b}", st.and),
[init @ .., z] => format!("{} {} {z}", init.join(", "), st.and),
};
match st.cite {
Cite::AuthorPage if pages.is_empty() => format!("({who})"),
Cite::AuthorPage => format!("({who} {pages})"),
Cite::AuthorDate { year_sep, .. } if pages.is_empty() => format!("({who}{year_sep}{})", src.year),
Cite::AuthorDate { year_sep, pages_sep } => format!("({who}{year_sep}{}{pages_sep}{pages})", src.year),
Cite::Number { open, close, pages_sep: Some(sep) } if !pages.is_empty() => format!("{open}{n}{sep}{pages}{close}"),
Cite::Number { open, close, .. } => format!("{open}{n}{close}"),
}
}
/// GB/T 7714 document type code: M book, J journal article, R report, EB/OL web page, Z other;
/// "/OL" marks a source read online.
fn gb_type(src: &Source) -> String {
let code = match src.kind.to_ascii_lowercase().as_str() {
"website" | "web" => return "EB/OL".into(),
_ if !src.journal.is_empty() => "J",
"book" | "" => "M",
"article" => "J",
"report" => "R",
_ => "Z",
};
if src.url.is_empty() { code.into() } else { format!("{code}/OL") }
}
/// Bibliography entry for a style; `n` is the source's number in numbered styles.
pub fn entry(src: &Source, style_name: &str, n: usize) -> String {
let st = style(style_name);
let au = authors(&src.author);
let field = |key: &str| -> String {
match key {
"n" => n.to_string(),
"names" => name_list(&au, &st.names),
"year" if src.year.is_empty() => "n.d.".into(),
"year" => src.year.clone(),
"title" => src.title.clone(),
"city" => src.city.clone(),
"publisher" => src.publisher.clone(),
"pub" => [src.city.as_str(), src.publisher.as_str()].into_iter().filter(|x| !x.is_empty()).collect::<Vec<_>>().join(": "),
"journal" => src.journal.clone(),
"volume" => src.volume.clone(),
"pages" => src.pages.clone(),
"url" => src.url.clone(),
"type" => gb_type(src),
_ => String::new(),
}
};
render(if src.journal.is_empty() { st.book } else { st.article }, &field)
}