use alloc::{
format,
string::{String, ToString},
vec,
vec::Vec,
};
use edtf_core::{Date, DateField, Edtf, Interval, IntervalEndpoint, Qualifier, Year, YearKind};
use crate::{
Ambiguous, Interpretation, NoMatchReason, Normalized, Note, NumericOrder, Options, Outcome,
tables::{Lang, Span, lang_for, season_name},
};
#[derive(Debug, Clone, Copy)]
enum Expr {
Date(Date),
Interval(Interval),
}
#[derive(Debug, Clone)]
struct Candidate {
expr: Expr,
reading: String,
notes: Vec<Note>,
}
#[derive(Debug, Clone)]
enum Single {
One(Expr, Vec<Note>),
Many(Vec<Candidate>),
}
#[allow(
clippy::cast_possible_truncation,
reason = "callers reduce mod 10 (or divide down to one digit): always < 10"
)]
const fn digit(v: u32) -> u8 {
(v % 10) as u8
}
fn quiet(v: u8) -> DateField {
DateField {
digits: [Some(digit(u32::from(v) / 10)), Some(digit(u32::from(v)))],
qualifier: Qualifier::default(),
}
}
fn year_of(y: i32) -> Option<Year> {
if !(-9999..=9999).contains(&y) {
return None;
}
let a = y.unsigned_abs();
Some(Year {
kind: YearKind::Standard {
negative: y < 0,
digits: [
Some(digit(a / 1000)),
Some(digit(a / 100)),
Some(digit(a / 10)),
Some(digit(a)),
],
},
significant_digits: None,
qualifier: Qualifier::default(),
})
}
fn date_y(y: i32) -> Option<Date> {
Some(Date {
year: year_of(y)?,
month: None,
day: None,
})
}
fn date_ym(y: i32, m: u8) -> Option<Date> {
Some(Date {
month: Some(quiet(m)),
..date_y(y)?
})
}
fn date_ymd(y: i32, m: u8, d: u8) -> Option<Date> {
Some(Date {
day: Some(quiet(d)),
..date_ym(y, m)?
})
}
fn masked_year() -> Year {
Year {
kind: YearKind::Standard {
negative: false,
digits: [None; 4],
},
significant_digits: None,
qualifier: Qualifier::default(),
}
}
fn decade_date(prefix3: u16) -> Date {
Date {
year: Year {
kind: YearKind::Standard {
negative: false,
digits: [
Some(digit(u32::from(prefix3) / 100)),
Some(digit(u32::from(prefix3) / 10)),
Some(digit(u32::from(prefix3))),
None,
],
},
significant_digits: None,
qualifier: Qualifier::default(),
},
month: None,
day: None,
}
}
fn century_date(prefix2: u8, negative: bool) -> Date {
Date {
year: Year {
kind: YearKind::Standard {
negative,
digits: [Some(prefix2 / 10), Some(prefix2 % 10), None, None],
},
significant_digits: None,
qualifier: Qualifier::default(),
},
month: None,
day: None,
}
}
const fn interval(a: Date, b: Date) -> Expr {
Expr::Interval(Interval {
start: IntervalEndpoint::Date(a),
end: IntervalEndpoint::Date(b),
})
}
fn num(s: &str) -> Option<u32> {
if s.is_empty() || s.len() > 5 || !s.bytes().all(|b| b.is_ascii_digit()) {
return None;
}
s.parse().ok()
}
fn strip_dot(t: &str) -> &str {
t.trim_end_matches('.')
}
fn eq_dotless(token: &str, word: &str) -> bool {
let mut w = word.chars();
for c in token.chars() {
if c == '.' {
continue;
}
if w.next() != Some(c) {
return false;
}
}
w.next().is_none()
}
fn table_u8(t: &str, table: &[(&str, u8)]) -> Option<u8> {
let t = strip_dot(t);
table.iter().find(|(n, _)| *n == t).map(|&(_, v)| v)
}
fn ordinal_num(t: &str, lang: &Lang) -> Option<u32> {
if let Some(v) = num(t) {
return Some(v);
}
for suffix in lang.ordinal_suffixes {
if let Some(v) = t.strip_suffix(suffix).and_then(num) {
return Some(v);
}
}
lang.ordinal_words
.iter()
.find(|(w, _)| *w == t)
.map(|&(_, v)| v)
}
fn roman_num(t: &str) -> Option<u32> {
let mut vals = Vec::new();
for c in t.chars() {
vals.push(match c {
'i' | '\u{456}' => 1, 'v' => 5,
'x' | '\u{445}' => 10, 'l' | '\u{43b}' => 50, 'c' | '\u{441}' => 100, 'd' | '\u{434}' => 500, 'm' | '\u{43c}' => 1000, _ => return None,
});
}
let mut total: i64 = 0;
for (i, &v) in vals.iter().enumerate() {
if vals[i + 1..].iter().any(|&n| n > v) {
total -= i64::from(v);
} else {
total += i64::from(v);
}
}
u32::try_from(total).ok().filter(|t| (1..=99).contains(t))
}
fn century_ordinal(t: &str, lang: &Lang) -> Option<(u32, bool)> {
if let Some(n) = ordinal_num(t, lang) {
return Some((n, false));
}
if lang.roman_centuries {
return Some((roman_num(t)?, true));
}
None
}
fn day_num(t: &str, lang: &Lang) -> Option<u8> {
let digits = lang
.ordinal_suffixes
.iter()
.find_map(|s| t.strip_suffix(s))
.unwrap_or(t);
if digits.len() > 2 {
return None;
}
let v = num(digits)?;
u8::try_from(v).ok().filter(|d| (1..=31).contains(d))
}
fn year_num(t: &str) -> Option<i32> {
if t.len() < 3 || t.len() > 4 {
return None;
}
let v = num(t)?;
(v >= 1).then(|| i32::try_from(v).ok())?
}
fn preprocess(input: &str) -> String {
let mut s = String::with_capacity(input.len());
for ch in input.chars() {
match ch {
'\u{2013}' | '\u{2014}' => s.push('-'),
'\u{2019}' => s.push('\''),
',' => s.push(' '),
c => s.extend(c.to_lowercase()),
}
}
let mut out = String::with_capacity(s.len());
for tok in s.split_whitespace() {
if !out.is_empty() {
out.push(' ');
}
out.push_str(tok);
}
out
}
fn strip_qualifiers(s: &str, lang: &Lang) -> (String, Qualifier) {
let mut toks: Vec<&str> = s.split(' ').filter(|t| !t.is_empty()).collect();
let mut q = Qualifier::default();
'outer: loop {
if toks.len() > 1 {
let head = strip_dot(toks[0].trim_end_matches(':'));
if lang.noise_leading.contains(&head) {
toks.remove(0);
continue;
}
if lang.approx_leading.contains(&head) {
q.approximate = true;
toks.remove(0);
continue;
}
if lang.uncertain_leading.contains(&head) {
q.uncertain = true;
toks.remove(0);
continue;
}
}
if let Some(&t0) = toks.first() {
for p in lang.approx_attached {
if let Some(rest) = t0.strip_prefix(p) {
if rest.starts_with(|c: char| c.is_ascii_digit()) {
q.approximate = true;
toks[0] = rest;
continue 'outer;
}
}
}
}
if toks.len() > 1 {
let tail = strip_dot(toks[toks.len() - 1]);
if lang.noise_trailing.contains(&tail) {
toks.pop();
continue;
}
if lang.approx_trailing.contains(&tail) {
q.approximate = true;
toks.pop();
continue;
}
if lang.uncertain_trailing.contains(&tail) {
q.uncertain = true;
toks.pop();
continue;
}
}
break;
}
(toks.join(" "), q)
}
fn split_era<'a>(toks: &[&'a str], lang: &Lang) -> Option<(bool, Vec<&'a str>)> {
for (phrase, bc) in lang.eras {
if toks.len() > phrase.len() {
let tail = &toks[toks.len() - phrase.len()..];
if tail.iter().zip(*phrase).all(|(t, w)| eq_dotless(t, w)) {
return Some((*bc, toks[..toks.len() - phrase.len()].to_vec()));
}
}
}
None
}
#[derive(Clone, Copy)]
enum DecadeParse {
Exact(u16),
CenturyAmbig(u16, u8),
Bare(u8),
}
fn decade_tok(t: &str, lang: &Lang) -> Option<DecadeParse> {
let t = t.strip_prefix('\'').unwrap_or(t);
let digits = lang
.decade_suffixes
.iter()
.find_map(|s| t.strip_suffix(s))?;
let v = num(digits)?;
if v % 10 != 0 {
return None;
}
match digits.len() {
4 => {
let prefix3 = u16::try_from(v / 10).ok()?;
if v % 100 == 0 {
Some(DecadeParse::CenturyAmbig(
prefix3,
u8::try_from(v / 100).ok()?,
))
} else {
Some(DecadeParse::Exact(prefix3))
}
},
2 => Some(DecadeParse::Bare(u8::try_from(v / 10).ok()?)),
_ => None,
}
}
fn decade_single(parse: DecadeParse, opts: Options) -> Single {
match parse {
DecadeParse::Exact(p3) => Single::One(Expr::Date(decade_date(p3)), Vec::new()),
DecadeParse::CenturyAmbig(p3, p2) => Single::Many(vec![
Candidate {
expr: Expr::Date(decade_date(p3)),
reading: format!("decade ({p3}X)"),
notes: vec![Note::DecadeAmbiguity],
},
Candidate {
expr: Expr::Date(century_date(p2, false)),
reading: format!("century ({p2}XX)"),
notes: vec![Note::DecadeAmbiguity],
},
]),
DecadeParse::Bare(tens) => {
opts.default_century.filter(|c| *c <= 9999).map_or_else(
|| {
let mk = |p3: u16| Candidate {
expr: Expr::Date(decade_date(p3)),
reading: format!("the {p3}0s"),
notes: vec![Note::DecadeAmbiguity],
};
Single::Many(vec![mk(180 + u16::from(tens)), mk(190 + u16::from(tens))])
},
|century| {
let p3 = century / 100 * 10 + u16::from(tens);
Single::One(
Expr::Date(decade_date(p3)),
vec![Note::DefaultCenturyApplied],
)
},
)
},
}
}
fn century_toks(toks: &[&str], lang: &Lang) -> Option<(u32, bool)> {
let word = |t: &str| lang.century_words.contains(&strip_dot(t));
let attached = |t: &str| -> Option<(u32, bool)> {
let t = strip_dot(t);
let d = lang
.century_words
.iter()
.filter(|w| w.chars().count() == 1)
.find_map(|w| t.strip_suffix(w))?;
century_ordinal(d, lang)
};
let (n, roman) = match toks {
[t] => attached(t).or_else(|| {
(lang.roman_centuries && t.chars().count() >= 2)
.then(|| roman_num(t).map(|n| (n, true)))
.flatten()
})?,
[o, w] if word(w) => century_ordinal(o, lang)?,
_ => return None,
};
(1..=99).contains(&n).then_some((n, roman))
}
#[allow(
clippy::cast_possible_wrap,
reason = "n <= 99 (century_ordinal range filter); i32::try_from is not usable in const fn"
)]
const fn century_span(n: u32, bc: bool) -> (i32, i32) {
let n = n as i32;
if bc {
(-(100 * n - 1), -(100 * (n - 1)))
} else {
(100 * (n - 1) + 1, 100 * n)
}
}
fn century_expr(n: u32, bc: bool) -> Option<(Expr, Vec<Note>)> {
if bc {
let notes = vec![Note::AstronomicalYear, Note::BcCenturyInterval];
let (s, e) = century_span(n, true);
return Some((interval(date_y(s)?, date_y(e)?), notes));
}
let prefix2 = u8::try_from(n - 1).ok()?;
Some((
Expr::Date(century_date(prefix2, bc)),
vec![Note::CenturyMask],
))
}
#[allow(
clippy::many_single_char_names,
reason = "s/e/a/b are interval-endpoint arithmetic; longer names obscure the span algebra"
)]
fn century_part_expr(span: Span, n: u32, bc: bool) -> Option<(Expr, Vec<Note>)> {
let (s, e) = century_span(n, bc);
let (a, b) = match span {
Span::Early => (s, s + 29),
Span::Mid => (s + 30, s + 69),
Span::Late => (s + 70, e),
Span::FirstHalf => (s, s + 49),
Span::SecondHalf => (s + 50, e),
};
let mut notes = vec![Note::CenturyPartInterval];
if bc {
notes.push(Note::AstronomicalYear);
}
Some((interval(date_y(a)?, date_y(b)?), notes))
}
fn numeric_sep(t: &str) -> Option<char> {
let mut sep = None;
for c in ['/', '-', '.'] {
if t.contains(c) {
if sep.is_some() {
return None; }
sep = Some(c);
}
}
sep
}
fn dmy(d: u32, m: u32, y: i32) -> Option<Expr> {
if !(1..=12).contains(&m) || !(1..=31).contains(&d) {
return None;
}
let (m, d) = (u8::try_from(m).ok()?, u8::try_from(d).ok()?);
Some(Expr::Date(date_ymd(y, m, d)?))
}
#[allow(
clippy::many_single_char_names,
clippy::too_many_lines,
reason = "y/m/d/a/b are date-field names; the arms ARE the grammar"
)]
fn numeric_token(t: &str, opts: Options, lang: &Lang) -> Option<Single> {
let sep = numeric_sep(t)?;
let parts: Vec<&str> = t.split(sep).collect();
let vals: Vec<u32> = parts.iter().map(|p| num(p)).collect::<Option<_>>()?;
match (parts.as_slice(), vals.as_slice()) {
([py, _, _], &[y, m, d]) if py.len() == 4 => Some(Single::One(
dmy(d, m, i32::try_from(y).ok()?)?,
vec![Note::NumericUnambiguous],
)),
([pa, pb, py], &[a, b, y]) if py.len() == 4 && pa.len() <= 2 && pb.len() <= 2 => {
let y = i32::try_from(y).ok()?;
let day_first = dmy(a, b, y);
let month_first = dmy(b, a, y);
match (a > 12, b > 12) {
(true, false) => Some(Single::One(day_first?, vec![Note::NumericUnambiguous])),
(false, true) => Some(Single::One(month_first?, vec![Note::NumericUnambiguous])),
(true, true) => None,
(false, false) if a == b => {
Some(Single::One(day_first?, vec![Note::NumericOrderIrrelevant]))
},
(false, false) => {
let (order, note) = match (opts.numeric_order, lang.implied_numeric_order) {
(Some(o), _) => (Some(o), Note::NumericResolvedByOption),
(None, Some(o)) => (Some(o), Note::NumericResolvedByLocale),
(None, None) => (None, Note::NumericOrderAmbiguous),
};
match order {
Some(NumericOrder::DayFirst) => Some(Single::One(day_first?, vec![note])),
Some(NumericOrder::MonthFirst) => {
Some(Single::One(month_first?, vec![note]))
},
None => {
let cand = |e: Option<Expr>, reading: &str| {
e.map(|expr| Candidate {
expr,
reading: String::from(reading),
notes: vec![Note::NumericOrderAmbiguous],
})
};
let readings: Vec<Candidate> = [
cand(day_first, "day-month-year"),
cand(month_first, "month-day-year"),
]
.into_iter()
.flatten()
.collect();
(!readings.is_empty()).then_some(Single::Many(readings))
},
}
},
}
},
([pa, py], &[m, y]) if pa.len() <= 2 && py.len() == 4 && (1..=12).contains(&m) => {
let (y, m) = (i32::try_from(y).ok()?, u8::try_from(m).ok()?);
Some(Single::One(Expr::Date(date_ym(y, m)?), Vec::new()))
},
([py, pb], &[y, m]) if py.len() == 4 && pb.len() <= 2 && (1..=12).contains(&m) => {
let (y, m) = (i32::try_from(y).ok()?, u8::try_from(m).ok()?);
Some(Single::One(Expr::Date(date_ym(y, m)?), Vec::new()))
},
([py, pb], &[y, b]) if py.len() == 4 && sep == '-' => {
let y = i32::try_from(y).ok()?;
match pb.len() {
4 => {
let e = i32::try_from(b).ok()?;
if e > y {
Some(Single::One(interval(date_y(y)?, date_y(e)?), Vec::new()))
} else {
None
}
},
2 => {
let b_u8 = u8::try_from(b).ok()?;
let elided = y / 100 * 100 + i32::from(b_u8);
if elided <= y || b <= 12 {
return None;
}
if (21..=41).contains(&b) {
Some(Single::Many(vec![
Candidate {
expr: Expr::Date(Date {
month: Some(quiet(b_u8)),
..date_y(y)?
}),
reading: format!("sub-year code {b} ({})", season_name(b_u8)),
notes: vec![Note::SeasonRangeCollision],
},
Candidate {
expr: interval(date_y(y)?, date_y(elided)?),
reading: format!("year range {y}/{elided}"),
notes: vec![Note::ElidedEndYear, Note::SeasonRangeCollision],
},
]))
} else {
Some(Single::One(
interval(date_y(y)?, date_y(elided)?),
vec![Note::ElidedEndYear],
))
}
},
_ => None,
}
},
_ => None,
}
}
#[allow(
clippy::too_many_lines,
reason = "the ordered arms ARE the grammar (N11); order carries meaning"
)]
fn parse_single(s: &str, opts: Options, lang: &Lang, in_range: bool) -> Option<Single> {
let toks: Vec<&str> = s.split(' ').filter(|t| !t.is_empty()).collect();
if toks.is_empty() {
return None;
}
let (era, mut core_toks) = match split_era(&toks, lang) {
Some((bc, rest)) => (Some(bc), rest),
None => (None, toks.clone()),
};
while core_toks.len() > 1
&& lang
.noise_trailing
.contains(&strip_dot(core_toks[core_toks.len() - 1]))
{
core_toks.pop();
}
let bc = era == Some(true);
if let Some((span, rest)) = split_modifier(&core_toks, lang) {
if era.is_none() {
if let [t] = rest.as_slice() {
if let Some(d) = decade_tok(t, lang) {
return Some(with_note(decade_single(d, opts), Note::ModifierDropped));
}
}
}
if let Some((n, roman)) = century_toks(&rest, lang) {
let (expr, mut notes) = if in_range {
let (e, mut ns) = century_expr(n, bc)?;
ns.push(Note::ModifierDropped);
(e, ns)
} else {
century_part_expr(span, n, bc)?
};
if roman {
notes.push(Note::RomanCentury);
}
return Some(Single::One(expr, notes));
}
return None;
}
if let Some((n, roman)) = century_toks(&core_toks, lang) {
let (expr, mut notes) = century_expr(n, bc)?;
if roman {
notes.push(Note::RomanCentury);
}
return Some(Single::One(expr, notes));
}
if era.is_none() && core_toks.len() >= 2 {
if let Some(DecadeParse::Bare(tens)) = decade_tok(core_toks[0], lang) {
let mut rest = core_toks[1..].to_vec();
while rest.len() > 1 && lang.noise_trailing.contains(&strip_dot(rest[0])) {
rest.remove(0);
}
if let Some((n, roman)) = century_toks(&rest, lang) {
let prefix3 = (u16::try_from(n).ok()? - 1) * 10 + u16::from(tens);
let mut notes = vec![Note::DecadeOfCentury];
if roman {
notes.push(Note::RomanCentury);
}
return Some(Single::One(Expr::Date(decade_date(prefix3)), notes));
}
}
}
if let Some(bc) = era {
if let [t] = core_toks.as_slice() {
let v = num(t)?;
if v >= 1 && t.len() <= if bc { 5 } else { 4 } {
let y = i32::try_from(if bc { 1 - i64::from(v) } else { i64::from(v) }).ok()?;
let notes = if bc {
vec![Note::AstronomicalYear]
} else {
Vec::new()
};
return Some(Single::One(Expr::Date(date_y(y)?), notes));
}
}
return None;
}
match core_toks.as_slice() {
[t] => {
if let Some(y) = year_num(t) {
return Some(Single::One(Expr::Date(date_y(y)?), Vec::new()));
}
if let Some(d) = decade_tok(t, lang) {
return Some(decade_single(d, opts));
}
if let Some(m) = table_u8(t, lang.months) {
return Some(Single::One(
Expr::Date(Date {
year: masked_year(),
month: Some(quiet(m)),
day: None,
}),
vec![Note::MissingYearMasked],
));
}
numeric_token(t, opts, lang)
},
[a, b] => {
for (st, yt) in [(a, b), (b, a)] {
if let (Some(code), Some(y)) = (table_u8(st, lang.seasons), year_num(yt)) {
return Some(Single::One(
Expr::Date(Date {
month: Some(quiet(code)),
..date_y(y)?
}),
vec![Note::SeasonCode],
));
}
}
for (mt, yt) in [(a, b), (b, a)] {
if let (Some(m), Some(y)) = (table_u8(mt, lang.months), year_num(yt)) {
return Some(Single::One(Expr::Date(date_ym(y, m)?), Vec::new()));
}
}
for (mt, dt) in [(a, b), (b, a)] {
if let (Some(m), Some(d)) = (table_u8(mt, lang.months), day_num(dt, lang)) {
return Some(Single::One(
Expr::Date(Date {
year: masked_year(),
month: Some(quiet(m)),
day: Some(quiet(d)),
}),
vec![Note::MissingYearMasked],
));
}
}
None
},
[a, b, c] => {
for (dt, mt) in [(a, b), (b, a)] {
if let (Some(d), Some(m), Some(y)) =
(day_num(dt, lang), table_u8(mt, lang.months), year_num(c))
{
return Some(Single::One(Expr::Date(date_ymd(y, m, d)?), Vec::new()));
}
}
None
},
_ => None,
}
}
fn split_modifier<'a>(toks: &[&'a str], lang: &Lang) -> Option<(Span, Vec<&'a str>)> {
let matched = lang.modifiers.iter().find_map(|(phrase, span)| {
(toks.len() > phrase.len() && toks.iter().zip(*phrase).all(|(t, w)| strip_dot(t) == *w))
.then(|| (*span, toks[phrase.len()..].to_vec()))
});
let (span, mut rest) = matched.or_else(|| {
let (head, tail_toks) = toks.split_first()?;
let (word, tail) = head.split_once('-')?;
let span = lang
.modifiers
.iter()
.find(|(p, _)| p.len() == 1 && p[0] == word)
.map(|&(_, s)| s)?;
if tail.is_empty() {
return None;
}
let mut out = vec![tail];
out.extend_from_slice(tail_toks);
Some((span, out))
})?;
while rest.len() > 1 && lang.noise_leading.contains(&strip_dot(rest[0])) {
rest.remove(0);
}
Some((span, rest))
}
fn with_note(s: Single, note: Note) -> Single {
match s {
Single::One(e, mut notes) => {
notes.push(note);
Single::One(e, notes)
},
Single::Many(mut cands) => {
for c in &mut cands {
c.notes.push(note);
}
Single::Many(cands)
},
}
}
fn match_head<'a>(toks: &[&'a str], phrases: &[&[&str]]) -> Option<Vec<&'a str>> {
for phrase in phrases {
if toks.len() > phrase.len() && toks.iter().zip(*phrase).all(|(t, w)| strip_dot(t) == *w) {
return Some(toks[phrase.len()..].to_vec());
}
}
None
}
fn parse_open(toks: &[&str], opts: Options, lang: &Lang) -> Option<Single> {
let (before, rest) = match_head(toks, lang.before_phrases)
.map(|rest| (true, rest))
.or_else(|| match_head(toks, lang.after_phrases).map(|rest| (false, rest)))?;
let (inner, q) = strip_qualifiers(&rest.join(" "), lang);
let single = parse_single(&inner, opts, lang, true)?;
let wrap = |expr: Expr| -> Option<Expr> {
let Expr::Date(mut d) = expr else { return None };
qualify_date(&mut d, q);
let (start, end) = if before {
(IntervalEndpoint::Open, IntervalEndpoint::Date(d))
} else {
(IntervalEndpoint::Date(d), IntervalEndpoint::Open)
};
Some(Expr::Interval(Interval { start, end }))
};
map_single(single, wrap, Note::OpenInterval)
}
fn parse_or(toks: &[&str], opts: Options, lang: &Lang) -> Option<Single> {
let pos = toks.iter().position(|t| lang.or_words.contains(t))?;
if pos == 0 || pos == toks.len() - 1 {
return None;
}
let sides = [toks[..pos].join(" "), toks[pos + 1..].join(" ")];
let mut cands = Vec::new();
for side in sides {
let (inner, q) = strip_qualifiers(&side, lang);
match parse_single(&inner, opts, lang, true)? {
Single::One(mut expr, mut notes) => {
qualify_expr(&mut expr, q);
notes.push(Note::OrAlternatives);
cands.push(Candidate {
expr,
reading: String::from("alternative"),
notes,
});
},
Single::Many(_) => return None, }
}
Some(Single::Many(cands))
}
fn parse_range(s: &str, toks: &[&str], opts: Options, lang: &Lang) -> Option<Single> {
let word_split = |toks: &[&str], sep: &str| -> Option<(String, String)> {
let pos = toks.iter().position(|t| *t == sep)?;
(pos > 0 && pos < toks.len() - 1)
.then(|| (toks[..pos].join(" "), toks[pos + 1..].join(" ")))
};
for (lead, sep) in lang.range_pairs {
let inner = match lead {
Some(l) if toks.first() == Some(l) => &toks[1..],
Some(_) => continue,
None => toks,
};
if let Some((l, r)) = word_split(inner, sep) {
if let Some(single) = endpoint_pair(&l, &r, opts, lang) {
return Some(single);
}
}
}
for (i, c) in s.char_indices() {
if c != '-' {
continue;
}
let (l, r) = (s[..i].trim(), s[i + 1..].trim());
if l.is_empty() || r.is_empty() {
continue;
}
if let Some(single) = endpoint_pair(l, r, opts, lang) {
return Some(single);
}
}
None
}
#[allow(
clippy::too_many_lines,
clippy::many_single_char_names,
reason = "endpoint pairing is one decision table; l/r/a/b/e its algebra"
)]
fn endpoint_pair(left: &str, right: &str, opts: Options, lang: &Lang) -> Option<Single> {
let (ls, lq) = strip_qualifiers(left, lang);
let (rs, rq) = strip_qualifiers(right, lang);
let lres = parse_single(&ls, opts, lang, true);
let rres = parse_single(&rs, opts, lang, true);
let (lres, rres) = match (lres, rres) {
(Some(l), Some(r)) => (l, r),
(None, Some(r)) => {
let Single::One(Expr::Date(rd), rnotes) = &r else {
return None;
};
if !rnotes.contains(&Note::CenturyMask) || ls.contains(' ') {
return None;
}
let (n, roman) = century_ordinal(&ls, lang)?;
let bc = matches!(rd.year.kind, YearKind::Standard { negative: true, .. });
let (expr, mut ns) = century_expr(n, bc)?;
if roman {
ns.push(Note::RomanCentury);
}
(Single::One(expr, ns), r)
},
(Some(l), None) => {
let Single::One(Expr::Date(ld), _) = &l else {
return None;
};
let y = i32::try_from(ld.year.value()?).ok()?;
if y < 100 {
return None;
}
let v = num(&rs).filter(|_| rs.len() <= 2)?;
let v_u8 = u8::try_from(v).ok()?;
let elided = y / 100 * 100 + i32::from(v_u8);
if elided <= y || v <= 12 {
return None;
}
if (21..=41).contains(&v) && ld.month.is_none() && ld.day.is_none() {
let mut season = *ld;
season.month = Some(quiet(v_u8));
qualify_date(&mut season, lq);
let (mut a, mut b) = (*ld, date_y(elided)?);
qualify_date(&mut a, lq);
qualify_date(&mut b, rq);
return Some(Single::Many(vec![
Candidate {
expr: Expr::Date(season),
reading: format!("sub-year code {v} ({})", season_name(v_u8)),
notes: vec![Note::SeasonRangeCollision],
},
Candidate {
expr: interval(a, b),
reading: format!("year range {y}/{elided}"),
notes: vec![Note::ElidedEndYear, Note::SeasonRangeCollision],
},
]));
}
let r = Single::One(Expr::Date(date_y(elided)?), vec![Note::ElidedEndYear]);
(l, r)
},
(None, None) => return None,
};
let build = |ld: &Date, rd: &Date| -> Option<Expr> {
let (mut ld, mut rd) = (*ld, *rd);
qualify_date(&mut ld, lq);
qualify_date(&mut rd, rq);
if let (Some(a), Some(b)) = (ld.year.value(), rd.year.value()) {
if a > b {
return None; }
}
Some(interval(ld, rd))
};
match (lres, rres) {
(Single::One(Expr::Date(ld), ln), Single::One(Expr::Date(rd), rn)) => {
let (ld, ln, rd, rn) = distribute_year(ld, ln, rd, rn)?;
if let Some(many) = cross_year_season(&ld, &ln, &rd, &rn, lq, &build) {
return Some(many);
}
let expr = build(&ld, &rd)?;
let mut notes = ln;
notes.extend(rn);
Some(Single::One(expr, notes))
},
(Single::One(Expr::Date(ld), ln), Single::Many(cands)) => {
let total = cands.len();
let out: Vec<Candidate> = cands
.into_iter()
.filter_map(|c| {
let Expr::Date(cd) = c.expr else { return None };
let expr = build(&ld, &cd)?;
let mut notes = ln.clone();
notes.extend(c.notes);
Some(Candidate {
expr,
reading: c.reading,
notes,
})
})
.collect();
(out.len() == total && !out.is_empty()).then_some(Single::Many(out))
},
(Single::Many(cands), Single::One(Expr::Date(rd), rn)) => {
let total = cands.len();
let out: Vec<Candidate> = cands
.into_iter()
.filter_map(|c| {
let Expr::Date(cd) = c.expr else { return None };
let expr = build(&cd, &rd)?;
let mut notes = c.notes;
notes.extend(rn.clone());
Some(Candidate {
expr,
reading: c.reading,
notes,
})
})
.collect();
(out.len() == total && !out.is_empty()).then_some(Single::Many(out))
},
_ => None,
}
}
type Endpoints = (Date, Vec<Note>, Date, Vec<Note>);
fn distribute_year(
mut ld: Date,
mut ln: Vec<Note>,
mut rd: Date,
mut rn: Vec<Note>,
) -> Option<Endpoints> {
let l_masked = ln.contains(&Note::MissingYearMasked);
let r_masked = rn.contains(&Note::MissingYearMasked);
if l_masked != r_masked {
let (masked_d, masked_n, source_d) = if l_masked {
(&mut ld, &mut ln, &rd)
} else {
(&mut rd, &mut rn, &ld)
};
source_d.year.value()?;
masked_d.year.kind = source_d.year.kind;
masked_n.retain(|n| *n != Note::MissingYearMasked);
masked_n.push(Note::EndpointYearDistributed);
}
Some((ld, ln, rd, rn))
}
fn cross_year_season(
ld: &Date,
ln: &[Note],
rd: &Date,
rn: &[Note],
lq: Qualifier,
build: &impl Fn(&Date, &Date) -> Option<Expr>,
) -> Option<Single> {
const WINTER: u8 = 24;
if ld.month.and_then(DateField::value) != Some(WINTER) || rd.month.is_some() || rd.day.is_some()
{
return None;
}
let (a, b) = (ld.year.value()?, rd.year.value()?);
if b != a + 1 {
return None;
}
let mut season = *ld;
qualify_date(&mut season, lq);
let mut season_notes = ln.to_vec();
season_notes.push(Note::CrossYearSeason);
let range = build(ld, rd)?;
let mut range_notes = ln.to_vec();
range_notes.extend(rn.iter().copied());
range_notes.push(Note::CrossYearSeason);
Some(Single::Many(vec![
Candidate {
expr: Expr::Date(season),
reading: String::from("one winter spanning the year boundary"),
notes: season_notes,
},
Candidate {
expr: range,
reading: format!("winter {a} through the whole of {b}"),
notes: range_notes,
},
]))
}
fn map_single(single: Single, wrap: impl Fn(Expr) -> Option<Expr>, note: Note) -> Option<Single> {
match single {
Single::One(expr, mut notes) => {
let expr = wrap(expr)?;
notes.push(note);
Some(Single::One(expr, notes))
},
Single::Many(cands) => {
let out: Vec<Candidate> = cands
.into_iter()
.filter_map(|mut c| {
c.expr = wrap(c.expr)?;
c.notes.push(note);
Some(c)
})
.collect();
(!out.is_empty()).then_some(Single::Many(out))
},
}
}
fn merge(into: &mut Qualifier, q: Qualifier) {
into.uncertain |= q.uncertain;
into.approximate |= q.approximate;
}
fn qualify_date(d: &mut Date, q: Qualifier) {
if !q.uncertain && !q.approximate {
return;
}
merge(&mut d.year.qualifier, q);
if let Some(m) = &mut d.month {
merge(&mut m.qualifier, q);
}
if let Some(day) = &mut d.day {
merge(&mut day.qualifier, q);
}
}
fn qualify_expr(e: &mut Expr, q: Qualifier) {
match e {
Expr::Date(d) => qualify_date(d, q),
Expr::Interval(iv) => {
for ep in [&mut iv.start, &mut iv.end] {
if let IntervalEndpoint::Date(d)
| IntervalEndpoint::OnOrBefore(d)
| IntervalEndpoint::OnOrAfter(d) = ep
{
qualify_date(d, q);
}
}
},
}
}
fn render(expr: Expr) -> Option<(Edtf, String)> {
let value = match expr {
Expr::Date(d) => Edtf::Date(d),
Expr::Interval(iv) => Edtf::Interval(iv),
};
let s = value.to_string();
let reparsed = Edtf::parse(&s).ok()?;
Some((reparsed, s))
}
const fn no_match(reason: NoMatchReason) -> Outcome {
Outcome::NoMatch { reason }
}
fn outcome_from(single: Single, q: Qualifier, base_notes: Vec<Note>) -> Outcome {
match single {
Single::One(mut expr, mut notes) => {
let distributed = q.is_qualified() && matches!(expr, Expr::Interval(_));
qualify_expr(&mut expr, q);
match render(expr) {
Some((value, edtf)) => {
let mut all = base_notes;
all.append(&mut notes);
if distributed {
all.push(Note::QualifierDistributed);
}
Outcome::Normalized(Normalized {
edtf,
value,
notes: all,
})
},
None => no_match(NoMatchReason::ImpossibleDate),
}
},
Single::Many(cands) => {
let total = cands.len();
let mut interps: Vec<Interpretation> = cands
.into_iter()
.filter_map(|mut c| {
qualify_expr(&mut c.expr, q);
let (value, edtf) = render(c.expr)?;
let mut notes = base_notes.clone();
notes.extend(c.notes);
Some(Interpretation {
edtf,
value,
reading: c.reading,
notes,
})
})
.collect();
if interps.len() < total {
return no_match(NoMatchReason::ImpossibleDate);
}
match interps.len() {
0 => no_match(NoMatchReason::ImpossibleDate),
1 => {
let i = interps.remove(0);
Outcome::Normalized(Normalized {
edtf: i.edtf,
value: i.value,
notes: i.notes,
})
},
_ => Outcome::Ambiguous(Ambiguous {
interpretations: interps,
}),
}
},
}
}
pub(crate) fn run(input: &str, opts: Options) -> Outcome {
let lang = lang_for(opts.language);
let trimmed = input.trim();
if trimmed.is_empty() {
return no_match(NoMatchReason::OutOfGrammar);
}
let dashed: String = trimmed
.chars()
.map(|c| match c {
'\u{2013}' | '\u{2014}' => '-',
c => c,
})
.collect();
if let Some(outcome) = collision_check(&dashed, opts, lang) {
return outcome;
}
if let Ok(v) = Edtf::parse(&dashed) {
let edtf = v.to_string();
let value = Edtf::parse(&edtf).unwrap_or(v);
return Outcome::Normalized(Normalized {
edtf,
value,
notes: vec![Note::AlreadyValidEdtf],
});
}
let mut pre = preprocess(trimmed);
let mut q = Qualifier::default();
if pre.ends_with('?') {
q.uncertain = true;
pre.pop();
while pre.ends_with(' ') {
pre.pop();
}
}
let (rest, q2) = strip_qualifiers(&pre, lang);
merge(&mut q, q2);
if rest.is_empty() {
return no_match(NoMatchReason::OutOfGrammar);
}
if lang.explicit_no_date.contains(&rest.as_str()) {
return no_match(NoMatchReason::ExplicitNoDate);
}
let toks: Vec<&str> = rest.split(' ').collect();
if let Some(single) = parse_single(&rest, opts, lang, false) {
return outcome_from(single, q, Vec::new());
}
if let Some(single) = parse_open(&toks, opts, lang) {
return outcome_from(single, q, Vec::new());
}
if let Some(single) = parse_or(&toks, opts, lang) {
return outcome_from(single, q, Vec::new());
}
if let Some(single) = parse_range(&rest, &toks, opts, lang) {
return outcome_from(single, q, Vec::new());
}
no_match(NoMatchReason::OutOfGrammar)
}
fn collision_check(s: &str, opts: Options, lang: &Lang) -> Option<Outcome> {
let b = s.as_bytes();
if b.len() != 7 || b[4] != b'-' {
return None;
}
if !b[..4].iter().all(u8::is_ascii_digit) || !b[5..].iter().all(u8::is_ascii_digit) {
return None;
}
let code: u32 = s[5..].parse().ok()?;
let year: i32 = s[..4].parse().ok()?;
if !(21..=41).contains(&code) || year / 100 * 100 + i32::try_from(code).ok()? <= year {
return None;
}
match numeric_token(s, opts, lang)? {
many @ Single::Many(_) => Some(outcome_from(many, Qualifier::default(), Vec::new())),
_ => None,
}
}