use std::fmt;
use crate::{
datefmt::{format_instant, format_naive},
interpret, DateStyle, PosixNs, RenderZone, TzSemantics,
};
#[derive(Debug, Clone, PartialEq)]
pub struct Reading {
pub format_id: String,
pub rendered: String,
pub label: String,
pub local: bool,
pub instant: PosixNs,
pub score: f64,
pub components: Vec<(&'static str, f64)>,
}
#[must_use]
pub fn weekday(rendered: &str) -> Option<&'static str> {
let date: jiff::civil::Date = rendered.get(..10)?.parse().ok()?;
Some(match date.weekday() {
jiff::civil::Weekday::Monday => "Monday",
jiff::civil::Weekday::Tuesday => "Tuesday",
jiff::civil::Weekday::Wednesday => "Wednesday",
jiff::civil::Weekday::Thursday => "Thursday",
jiff::civil::Weekday::Friday => "Friday",
jiff::civil::Weekday::Saturday => "Saturday",
jiff::civil::Weekday::Sunday => "Sunday",
})
}
#[must_use]
pub fn confidence_pct(score: f64) -> u8 {
(score.clamp(0.0, 1.0) * 100.0).round() as u8
}
impl fmt::Display for Reading {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
write!(
f,
"{:<12} {} ({})",
self.format_id, self.rendered, self.label
)
}
}
#[derive(Debug, Clone, PartialEq)]
pub struct NumberReadings {
pub number: String,
pub readings: Vec<Reading>,
}
pub const MIN_DIGITS: usize = 8;
pub const MAX_SCAN_BYTES: usize = 1 << 20;
fn bounded(text: &str) -> &str {
if text.len() <= MAX_SCAN_BYTES {
return text;
}
let mut end = MAX_SCAN_BYTES;
while end > 0 && !text.is_char_boundary(end) {
end -= 1;
}
&text[..end]
}
#[must_use]
pub fn word_at(text: &str, utf16_offset: usize) -> Option<String> {
let chars: Vec<char> = text.chars().collect();
let mut acc = 0usize;
let mut idx = None;
for (i, ch) in chars.iter().enumerate() {
let next = acc + ch.len_utf16();
if utf16_offset < next {
idx = Some(i);
break;
}
acc = next;
}
let idx = idx?;
if chars[idx].is_whitespace() {
return None;
}
let mut start = idx;
while start > 0 && !chars[start - 1].is_whitespace() {
start -= 1;
}
let mut end = idx;
while end + 1 < chars.len() && !chars[end + 1].is_whitespace() {
end += 1;
}
Some(chars[start..=end].iter().collect())
}
#[must_use]
pub fn scan_numbers_min(text: &str, min_digits: usize) -> Vec<String> {
let mut out = Vec::new();
let mut cur = String::new();
let mut digits = 0usize;
let mut has_dot = false;
let flush =
|cur: &mut String, digits: &mut usize, has_dot: &mut bool, out: &mut Vec<String>| {
if *digits >= min_digits {
out.push(std::mem::take(cur));
} else {
cur.clear();
}
*digits = 0;
*has_dot = false;
};
let chars: Vec<char> = text.chars().collect();
for (i, &ch) in chars.iter().enumerate() {
if ch.is_ascii_digit() {
cur.push(ch);
digits += 1;
} else if ch == '.'
&& !has_dot
&& !cur.is_empty()
&& chars.get(i + 1).is_some_and(char::is_ascii_digit)
{
cur.push('.');
has_dot = true;
} else {
flush(&mut cur, &mut digits, &mut has_dot, &mut out);
}
}
flush(&mut cur, &mut digits, &mut has_dot, &mut out);
out
}
#[must_use]
pub fn scan_numbers(text: &str) -> Vec<String> {
scan_numbers_min(text, MIN_DIGITS)
}
#[must_use]
pub fn render_in_zone(
tz: TzSemantics,
instant: PosixNs,
native: &str,
zone: &RenderZone,
style: DateStyle,
) -> (String, bool) {
match tz {
TzSemantics::Utc if instant.render(zone).is_none() => (native.to_string(), false),
TzSemantics::Utc => (format_instant(instant, zone, style), false),
TzSemantics::LocalNaive => (format_naive(instant, style), true),
TzSemantics::OffsetEmbedded => (native.to_string(), false),
}
}
fn confident(c: &interpret::Candidate, include_all: bool) -> bool {
c.rendered.is_some()
&& (include_all
|| (!c.sentinel
&& c.components
.iter()
.any(|(n, v)| *n == "in_window" && *v > 0.0)))
}
#[must_use]
pub fn readings_for(number: &str, max: usize, zone: &RenderZone) -> Vec<Reading> {
readings_for_opts(number, max, false, zone, DateStyle::Iso8601)
}
#[must_use]
pub fn readings_for_opts(
number: &str,
max: usize,
include_all: bool,
zone: &RenderZone,
style: DateStyle,
) -> Vec<Reading> {
let candidates = if let Ok(value) = number.parse::<i64>() {
interpret::interpret_int(value)
} else if let Ok(value) = number.parse::<f64>() {
interpret::interpret_float(value)
} else {
return Vec::new();
};
candidates
.into_iter()
.filter(|c| confident(c, include_all))
.take(max)
.map(|c| reading_from(c, zone, style))
.collect()
}
fn reading_from(c: interpret::Candidate, zone: &RenderZone, style: DateStyle) -> Reading {
let tz = crate::format(c.format_id).map_or(TzSemantics::Utc, |f| f.tz);
let native = c.rendered.clone().unwrap_or_default();
let (rendered, local) = render_in_zone(tz, c.instant, &native, zone, style);
Reading {
format_id: c.format_id.to_string(),
rendered,
label: c.label.to_string(),
local,
instant: c.instant,
score: c.score,
components: c.components,
}
}
#[must_use]
pub fn readings_for_string(text: &str, zone: &RenderZone) -> Vec<Reading> {
string_readings_opts(text, false, zone, DateStyle::Iso8601)
}
fn string_readings_opts(
text: &str,
include_all: bool,
zone: &RenderZone,
style: DateStyle,
) -> Vec<Reading> {
interpret::interpret_string(text)
.into_iter()
.filter(|c| c.rendered.is_some() && (include_all || !c.sentinel))
.map(|c| reading_from(c, zone, style))
.collect()
}
fn datetime_candidates(text: &str) -> Vec<String> {
let mut seen = std::collections::BTreeSet::new();
let mut out = Vec::new();
let mut push = |s: &str| {
let t = s.trim();
if t.len() >= 8 && !t.bytes().all(|b| b.is_ascii_digit()) && seen.insert(t.to_string()) {
out.push(t.to_string());
}
};
push(text);
text.lines().for_each(&mut push);
text.split_whitespace().for_each(&mut push);
out
}
fn hex_candidates(text: &str) -> Vec<String> {
let mut seen = std::collections::BTreeSet::new();
let mut out = Vec::new();
for tok in text.split_whitespace() {
let is_hex = |s: &str| !s.is_empty() && s.bytes().all(|b| b.is_ascii_hexdigit());
let accept = if let Some(rest) = tok.strip_prefix("0x").or_else(|| tok.strip_prefix("0X")) {
is_hex(rest)
} else {
tok.len() >= 8
&& tok.len().is_multiple_of(2)
&& is_hex(tok)
&& tok.bytes().any(|b| b.is_ascii_alphabetic())
};
if accept && seen.insert(tok.to_string()) {
out.push(tok.to_string());
}
}
out
}
fn hex_readings_opts(
token: &str,
max: usize,
include_all: bool,
zone: &RenderZone,
style: DateStyle,
) -> Vec<Reading> {
let Ok(groups) = interpret::interpret_hex(token) else {
return Vec::new();
};
groups
.into_iter()
.flat_map(|(_layout, cands)| cands)
.filter(|c| confident(c, include_all))
.take(max)
.map(|c| reading_from(c, zone, style))
.collect()
}
#[must_use]
pub fn inspect_text_min(
text: &str,
max_per_number: usize,
min_digits: usize,
zone: &RenderZone,
) -> Vec<NumberReadings> {
inspect_text_opts(
text,
max_per_number,
min_digits,
false,
zone,
DateStyle::Iso8601,
)
}
#[must_use]
pub fn inspect_text_opts(
text: &str,
max_per_number: usize,
min_digits: usize,
include_all: bool,
zone: &RenderZone,
style: DateStyle,
) -> Vec<NumberReadings> {
let text = bounded(text);
let numbers = scan_numbers_min(text, min_digits);
let int_values: Vec<(usize, i64)> = numbers
.iter()
.enumerate()
.filter_map(|(i, s)| s.parse::<i64>().ok().map(|v| (i, v)))
.collect();
let use_neighbours = int_values.len() >= 3;
let mut out: Vec<NumberReadings> = numbers
.into_iter()
.enumerate()
.filter_map(|(i, number)| {
let candidates = if let Ok(value) = number.parse::<i64>() {
if use_neighbours {
let neighbours: Vec<i64> = int_values
.iter()
.filter(|(j, _)| *j != i)
.map(|(_, v)| *v)
.collect();
let ctx = interpret::InterpretContext {
neighbours: &neighbours,
..Default::default()
};
interpret::interpret_int_with_context(value, &ctx)
} else {
interpret::interpret_int(value)
}
} else if let Ok(value) = number.parse::<f64>() {
interpret::interpret_float(value)
} else {
return None;
};
let readings: Vec<Reading> = candidates
.into_iter()
.filter(|c| confident(c, include_all))
.take(max_per_number)
.map(|c| reading_from(c, zone, style))
.collect();
(!readings.is_empty()).then_some(NumberReadings { number, readings })
})
.collect();
for cand in datetime_candidates(text) {
let readings: Vec<Reading> = string_readings_opts(&cand, include_all, zone, style)
.into_iter()
.take(max_per_number)
.collect();
if !readings.is_empty() {
out.push(NumberReadings {
number: cand,
readings,
});
}
}
let already: std::collections::BTreeSet<&str> =
out.iter().map(|nr| nr.number.as_str()).collect();
let hex: Vec<NumberReadings> = hex_candidates(text)
.into_iter()
.filter(|tok| !already.contains(tok.as_str()))
.filter_map(|token| {
let readings = hex_readings_opts(&token, max_per_number, include_all, zone, style);
(!readings.is_empty()).then_some(NumberReadings {
number: token,
readings,
})
})
.collect();
out.extend(hex);
out
}
#[must_use]
pub fn inspect_text(text: &str, max_per_number: usize, zone: &RenderZone) -> Vec<NumberReadings> {
inspect_text_min(text, max_per_number, MIN_DIGITS, zone)
}