use std::sync::Arc;
use fusevm::{Op, Value, VM};
use crate::clock_locale::Catalog;
use crate::compiler::{CompileError, Compiler};
use crate::parser::Word;
use crate::runtime::{tcl_str, to_tcl_string, Num};
pub mod ext {
pub use crate::compiler::ext::CLOCK_BASE as BASE;
pub const NOW: u16 = BASE;
pub const FORMAT: u16 = BASE + 1;
pub const SCAN: u16 = BASE + 2;
pub const ADD: u16 = BASE + 3;
}
pub const COMMANDS: &[&str] = &["clock"];
pub const SUBCOMMANDS: &[&str] = &[
"add",
"clicks",
"format",
"microseconds",
"milliseconds",
"scan",
"seconds",
];
pub(crate) fn compile(c: &mut Compiler, args: &[Word]) -> Result<(), CompileError> {
let Some(first) = args.first() else {
return c.error("wrong # args: should be \"clock subcommand ?arg ...?\"");
};
let given = c.literal_of(first, "subcommand")?.to_string();
let Some(sub) = resolve(&given, SUBCOMMANDS) else {
return c.error(format!(
"unknown or ambiguous subcommand \"{given}\": must be {}",
listing(SUBCOMMANDS)
));
};
let rest = &args[1..];
match sub {
"seconds" | "milliseconds" | "microseconds" => {
if !rest.is_empty() {
return c.error(format!("wrong # args: should be \"clock {sub}\""));
}
let unit = match sub {
"seconds" => 0,
"milliseconds" => 1,
_ => 2,
};
c.emit(Op::Extended(ext::NOW, unit), 1);
Ok(())
}
"clicks" => {
if rest.len() > 1 {
return c.error("wrong # args: should be \"clock clicks ?-switch?\"");
}
match rest.first() {
Some(w) => c.word(w)?,
None => c.push_str(""),
}
c.emit(Op::Extended(ext::NOW, 3), 0);
Ok(())
}
other => {
let id = match other {
"format" => ext::FORMAT,
"scan" => ext::SCAN,
_ => ext::ADD,
};
let Ok(argc) = u8::try_from(rest.len()) else {
return c.error("too many arguments for one command");
};
for w in rest {
c.word(w)?;
}
c.emit(Op::Extended(id, argc), 1 - rest.len() as i32);
Ok(())
}
}
}
fn resolve<'t>(name: &str, table: &[&'t str]) -> Option<&'t str> {
if let Some(exact) = table.iter().find(|c| **c == name) {
return Some(exact);
}
let mut hit = None;
for candidate in table {
if candidate.starts_with(name) {
if hit.is_some() {
return None;
}
hit = Some(*candidate);
}
}
hit
}
fn listing(table: &[&str]) -> String {
let mut out = String::new();
for (i, name) in table.iter().enumerate() {
if i > 0 {
out.push_str(", ");
}
if i + 1 == table.len() {
out.push_str("or ");
}
out.push_str(name);
}
out
}
const EARLIEST: i64 = -6_857_222_400;
fn too_early() -> String {
"clock: dates before the Gregorian changeover of 1752-09-14 are not supported yet".to_string()
}
#[derive(Clone, Copy, PartialEq, Eq, Debug)]
struct Civil {
year: i64,
month: u32,
day: u32,
hour: u32,
minute: u32,
second: u32,
epoch_day: i64,
}
fn is_leap(year: i64) -> bool {
(year % 4 == 0 && year % 100 != 0) || year % 400 == 0
}
const MONTH_LENGTHS: [u32; 12] = [31, 28, 31, 30, 31, 30, 31, 31, 30, 31, 30, 31];
fn month_length(year: i64, month: u32) -> u32 {
if month == 2 && is_leap(year) {
29
} else {
MONTH_LENGTHS[(month - 1) as usize]
}
}
fn days_from_civil(year: i64, month: u32, day: u32) -> i64 {
let y = if month <= 2 { year - 1 } else { year };
let era = if y >= 0 { y } else { y - 399 } / 400;
let yoe = y - era * 400;
let m = month as i64;
let doy = (153 * (if m > 2 { m - 3 } else { m + 9 }) + 2) / 5 + day as i64 - 1;
let doe = yoe * 365 + yoe / 4 - yoe / 100 + doy;
era * 146097 + doe - 719468
}
fn civil_from_days(days: i64) -> (i64, u32, u32) {
let z = days + 719468;
let era = if z >= 0 { z } else { z - 146096 } / 146097;
let doe = z - era * 146097;
let yoe = (doe - doe / 1460 + doe / 36524 - doe / 146096) / 365;
let y = yoe + era * 400;
let doy = doe - (365 * yoe + yoe / 4 - yoe / 100);
let mp = (5 * doy + 2) / 153;
let d = (doy - (153 * mp + 2) / 5 + 1) as u32;
let m = (if mp < 10 { mp + 3 } else { mp - 9 }) as u32;
(if m <= 2 { y + 1 } else { y }, m, d)
}
fn civil_of(local: i64) -> Civil {
let days = local.div_euclid(86400);
let secs = local.rem_euclid(86400);
let (year, month, day) = civil_from_days(days);
Civil {
year,
month,
day,
hour: (secs / 3600) as u32,
minute: (secs / 60 % 60) as u32,
second: (secs % 60) as u32,
epoch_day: days,
}
}
impl Civil {
fn iso_weekday(&self) -> u32 {
(self.epoch_day + 3).rem_euclid(7) as u32 + 1
}
fn day_of_year(&self) -> i64 {
self.epoch_day - days_from_civil(self.year, 1, 1) + 1
}
fn iso_week(&self) -> (i64, i64) {
let thursday = self.epoch_day + 4 - self.iso_weekday() as i64;
let (year, _, _) = civil_from_days(thursday);
let week = (thursday - days_from_civil(year, 1, 1)) / 7 + 1;
(year, week)
}
fn week_of_year(&self, start: u32) -> i64 {
let weekday = self.iso_weekday() % 7; let shifted = (weekday + 7 - start) % 7;
(self.day_of_year() + 6 - shifted as i64) / 7
}
fn julian_day(&self) -> i64 {
self.epoch_day + 2440588
}
fn second_of_day(&self) -> i64 {
self.hour as i64 * 3600 + self.minute as i64 * 60 + self.second as i64
}
}
const SECONDS_PER_DAY: i64 = 86_400;
struct Zone {
transitions: Vec<(i64, State)>,
initial: State,
}
#[derive(Clone)]
struct State {
offset: i32,
abbreviation: String,
}
impl Zone {
fn fixed(offset: i32, name: &str) -> Zone {
Zone {
transitions: Vec::new(),
initial: State {
offset,
abbreviation: name.to_string(),
},
}
}
fn at(&self, utc: i64) -> &State {
match self.transitions.partition_point(|(when, _)| *when <= utc) {
0 => &self.initial,
n => &self.transitions[n - 1].1,
}
}
fn for_local(&self, local: i64) -> &State {
let guess = self.at(local - self.at(local).offset as i64);
self.at(local - guess.offset as i64)
}
}
fn parse_tzif(bytes: &[u8]) -> Option<Zone> {
if bytes.len() < 44 || &bytes[..4] != b"TZif" {
return None;
}
if bytes[4] >= b'2' {
let second = block_length(bytes, 4)?;
let rest = bytes.get(second..)?;
if rest.len() >= 44 && &rest[..4] == b"TZif" {
return read_block(rest, 8);
}
}
read_block(bytes, 4)
}
fn block_length(bytes: &[u8], width: usize) -> Option<usize> {
let (isutc, isstd, leaps, times, types, chars) = counts_of(bytes)?;
Some(44 + times * (width + 1) + types * 6 + chars + leaps * (width + 4) + isstd + isutc)
}
fn counts_of(bytes: &[u8]) -> Option<(usize, usize, usize, usize, usize, usize)> {
if bytes.len() < 44 {
return None;
}
let at = |i: usize| -> usize {
u32::from_be_bytes([bytes[i], bytes[i + 1], bytes[i + 2], bytes[i + 3]]) as usize
};
Some((at(20), at(24), at(28), at(32), at(36), at(40)))
}
fn read_block(block: &[u8], width: usize) -> Option<Zone> {
let (_, _, _, times, types, chars) = counts_of(block)?;
if types == 0 {
return None;
}
let body = block.get(44..)?;
let mut at = 0usize;
let mut when = Vec::with_capacity(times);
for _ in 0..times {
when.push(read_int(body, &mut at, width)?);
}
let mut index = Vec::with_capacity(times);
for _ in 0..times {
index.push(*body.get(at)? as usize);
at += 1;
}
let mut infos = Vec::with_capacity(types);
for _ in 0..types {
let offset = read_int(body, &mut at, 4)? as i32;
at += 1; let abbreviation = *body.get(at)? as usize;
at += 1;
infos.push((offset, abbreviation));
}
let names = body.get(at..at + chars)?;
let state = |i: usize| -> State {
let (offset, start) = infos[i];
let start = start.min(names.len());
let end = names[start..]
.iter()
.position(|b| *b == 0)
.map_or(names.len(), |n| start + n);
State {
offset,
abbreviation: String::from_utf8_lossy(&names[start..end]).into_owned(),
}
};
let transitions: Vec<(i64, State)> = when
.into_iter()
.zip(index)
.filter(|(_, i)| *i < infos.len())
.map(|(w, i)| (w, state(i)))
.collect();
Some(Zone {
initial: state(0),
transitions,
})
}
fn read_int(body: &[u8], at: &mut usize, width: usize) -> Option<i64> {
let slice = body.get(*at..*at + width)?;
*at += width;
Some(match width {
4 => i32::from_be_bytes(slice.try_into().ok()?) as i64,
_ => i64::from_be_bytes(slice.try_into().ok()?),
})
}
const ZONE_DIRECTORIES: &[&str] = &[
"/usr/share/zoneinfo",
"/usr/share/lib/zoneinfo",
"/usr/lib/zoneinfo",
"/usr/local/etc/zoneinfo",
];
fn load_zone(name: &str) -> Result<Zone, String> {
let trimmed = name.strip_prefix(':').unwrap_or(name);
if trimmed.is_empty() {
return Ok(Zone::fixed(0, "GMT"));
}
if trimmed.eq_ignore_ascii_case("utc") || trimmed.eq_ignore_ascii_case("gmt") {
return Ok(Zone::fixed(0, trimmed));
}
if trimmed == "localtime" {
return system_zone();
}
if let Some(offset) = fixed_offset(name) {
return Ok(Zone::fixed(offset, name));
}
if trimmed.starts_with('/') || trimmed.split('/').any(|part| part == "..") {
return Err(format!("time zone \"{name}\" not found"));
}
for directory in ZONE_DIRECTORIES {
let path = std::path::Path::new(directory).join(trimmed);
if let Ok(bytes) = std::fs::read(&path) {
if let Some(zone) = parse_tzif(&bytes) {
return Ok(zone);
}
}
}
Err(format!(
"time zone \"{name}\" not found: no zone file names it, and a POSIX time zone rule is not supported yet"
))
}
fn fixed_offset(text: &str) -> Option<i32> {
let chars: Vec<char> = text.chars().collect();
let sign = match chars.first()? {
'+' => 1,
'-' => -1,
_ => return None,
};
let two = |from: usize| -> Option<i32> {
let a = chars.get(from)?.to_digit(10)?;
let b = chars.get(from + 1)?.to_digit(10)?;
Some((a * 10 + b) as i32)
};
let hours = two(1)?;
let mut at = 3;
let field = |at: &mut usize| -> Option<i32> {
let start = if chars.get(*at) == Some(&':') {
*at + 1
} else {
*at
};
let value = two(start)?;
*at = start + 2;
Some(value)
};
let minutes = match field(&mut at) {
Some(m) => m,
None => return (at == chars.len()).then_some(sign * hours * 3600),
};
let seconds = field(&mut at).unwrap_or(0);
if at != chars.len() {
return None;
}
Some(sign * ((hours * 60 + minutes) * 60 + seconds))
}
fn system_zone() -> Result<Zone, String> {
if let Ok(tz) = std::env::var("TZ") {
if !tz.is_empty() {
return load_zone(&tz);
}
}
match std::fs::read("/etc/localtime") {
Ok(bytes) => parse_tzif(&bytes)
.ok_or_else(|| "clock: /etc/localtime is not a time zone file".to_string()),
Err(_) => Ok(Zone::fixed(0, "GMT")),
}
}
fn format_map(cat: &Catalog) -> Vec<(String, String)> {
let mut map = vec![
("%%".to_string(), "%%".to_string()),
("%D".to_string(), "%m/%d/%Y".to_string()),
("%+".to_string(), "%a %b %e %H:%M:%S %Z %Y".to_string()),
];
for (key, value) in [
("%EY", &cat.locale_year_format),
("%T", &cat.time_format_24_secs),
("%R", &cat.time_format_24),
("%r", &cat.time_format_12),
("%X", &cat.time_format),
("%EX", &cat.locale_time_format),
("%x", &cat.date_format),
("%Ex", &cat.locale_date_format),
("%c", &cat.date_time_format),
("%Ec", &cat.locale_date_time_format),
] {
let expanded = string_map(&map, value);
map.push((key.to_string(), expanded));
}
map
}
fn string_map(map: &[(String, String)], text: &str) -> String {
let mut out = String::with_capacity(text.len());
let mut rest = text;
'outer: while !rest.is_empty() {
for (from, to) in map {
if rest.starts_with(from.as_str()) {
out.push_str(to);
rest = &rest[from.len()..];
continue 'outer;
}
}
let ch = rest.chars().next().expect("not empty");
out.push(ch);
rest = &rest[ch.len_utf8()..];
}
out
}
type Substitutions = Arc<Vec<(String, String)>>;
thread_local! {
static LOC_FMT_MAP: std::cell::RefCell<std::collections::HashMap<String, Substitutions>> =
std::cell::RefCell::new(std::collections::HashMap::new());
}
fn localize(format: &str, cat: &Catalog) -> String {
let map = LOC_FMT_MAP.with(|cache| {
if let Some(hit) = cache.borrow().get(&cat.name) {
return hit.clone();
}
let built = Arc::new(format_map(cat));
cache.borrow_mut().insert(cat.name.clone(), built.clone());
built
});
string_map(&map, format)
}
const DEFAULT_FORMAT: &str = "%a %b %d %H:%M:%S %Z %Y";
fn pad(value: i64, width: usize, fill: char) -> String {
let digits = value.unsigned_abs().to_string();
let sign = if value < 0 { 1 } else { 0 };
let mut out = String::with_capacity(width.max(digits.len() + sign));
if sign == 1 {
out.push('-');
}
for _ in digits.len() + sign..width {
out.push(fill);
}
out.push_str(&digits);
out
}
fn offset_text(offset: i32) -> String {
let sign = if offset < 0 { '-' } else { '+' };
let total = offset.unsigned_abs();
let (hours, minutes, seconds) = (total / 3600, total / 60 % 60, total % 60);
if seconds == 0 {
format!("{sign}{hours:02}{minutes:02}")
} else {
format!("{sign}{hours:02}{minutes:02}{seconds:02}")
}
}
fn format_time(seconds: i64, format: &str, zone: &Zone, cat: &Catalog) -> Result<String, String> {
if seconds < EARLIEST {
return Err(too_early());
}
let state = zone.at(seconds);
let local = seconds
.checked_add(state.offset as i64)
.ok_or_else(overflow)?;
let civil = civil_of(local);
let expanded = localize(format, cat);
let mut out = String::with_capacity(expanded.len() + 16);
let mut rest = expanded.as_str();
while let Some(at) = rest.find('%') {
out.push_str(&rest[..at]);
rest = &rest[at + 1..];
let modifier = rest.chars().next().filter(|c| matches!(c, 'E' | 'O'));
let after = match modifier {
Some(m) => &rest[m.len_utf8()..],
None => rest,
};
let Some(token) = after.chars().next() else {
out.push('%');
if let Some(m) = modifier {
out.push(m);
}
return Ok(out);
};
match one_token(modifier, token, &civil, seconds, local, state, cat)? {
Some(text) => {
out.push_str(&text);
rest = &after[token.len_utf8()..];
}
None => out.push('%'),
}
}
out.push_str(rest);
Ok(out)
}
fn one_token(
modifier: Option<char>,
token: char,
civil: &Civil,
seconds: i64,
local: i64,
state: &State,
cat: &Catalog,
) -> Result<Option<String>, String> {
let weekday = civil.iso_weekday();
let hour12 = match civil.hour % 12 {
0 => 12,
other => other,
};
Ok(match modifier {
Some('E') => Some(match token {
'E' => if civil.year <= 0 { &cat.bce } else { &cat.ce }.clone(),
'J' => julian_fraction(civil.julian_day(), civil.second_of_day(), 0),
'j' => julian_fraction(
civil.julian_day(),
civil.second_of_day(),
SECONDS_PER_DAY / 2,
),
'y' | 'C' => era_year(token, civil, local, cat),
's' => local.to_string(),
_ => return Ok(None),
}),
Some('O') => {
let value = match token {
'd' | 'e' => civil.day as i64,
'm' => civil.month as i64,
'y' => civil.year.rem_euclid(100),
'H' | 'k' => civil.hour as i64,
'I' | 'l' => hour12 as i64,
'M' => civil.minute as i64,
'S' => civil.second as i64,
'u' => weekday as i64,
'w' => (weekday % 7) as i64,
_ => return Ok(None),
};
Some(indexed(&cat.numerals, value)?)
}
_ => Some(match token {
'%' => "%".to_string(),
'd' => pad(civil.day as i64, 2, '0'),
'e' => pad(civil.day as i64, 2, ' '),
'm' => pad(civil.month as i64, 2, '0'),
'N' => pad(civil.month as i64, 2, ' '),
'b' | 'h' => indexed(&cat.months_abbrev, civil.month as i64 - 1)?,
'B' => indexed(&cat.months_full, civil.month as i64 - 1)?,
'y' => pad(civil.year.rem_euclid(100), 2, '0'),
'Y' => pad(civil.year, 4, '0'),
'C' => pad(civil.year.div_euclid(100), 2, '0'),
'H' => pad(civil.hour as i64, 2, '0'),
'M' => pad(civil.minute as i64, 2, '0'),
'S' => pad(civil.second as i64, 2, '0'),
'I' => pad(hour12 as i64, 2, '0'),
'k' => pad(civil.hour as i64, 2, ' '),
'l' => pad(hour12 as i64, 2, ' '),
'p' => meridiem(civil, cat).to_uppercase(),
'P' => meridiem(civil, cat).to_string(),
'a' => indexed(&cat.days_abbrev, (weekday % 7) as i64)?,
'A' => indexed(&cat.days_full, (weekday % 7) as i64)?,
'u' => weekday.to_string(),
'w' => (weekday % 7).to_string(),
'U' => pad(civil.week_of_year(0), 2, '0'),
'W' => pad(civil.week_of_year(1), 2, '0'),
'V' => pad(civil.iso_week().1, 2, '0'),
'g' => pad(civil.iso_week().0.rem_euclid(100), 2, '0'),
'G' => pad(civil.iso_week().0, 4, '0'),
'j' => pad(civil.day_of_year(), 3, '0'),
'J' => pad(civil.julian_day(), 7, '0'),
's' => seconds.to_string(),
'n' => "\n".to_string(),
't' => "\t".to_string(),
'z' => offset_text(state.offset),
'Z' => state.abbreviation.clone(),
'Q' => stardate(civil),
_ => return Ok(None),
}),
})
}
fn indexed(list: &[String], at: i64) -> Result<String, String> {
usize::try_from(at)
.ok()
.and_then(|i| list.get(i))
.cloned()
.ok_or_else(String::new)
}
fn meridiem<'c>(civil: &Civil, cat: &'c Catalog) -> &'c str {
if civil.hour < 12 {
&cat.am
} else {
&cat.pm
}
}
fn era_year(token: char, civil: &Civil, local: i64, cat: &Catalog) -> String {
let Some(era) = cat.era_at(local) else {
return if token == 'C' {
pad(civil.year.div_euclid(100), 2, '0')
} else {
pad(civil.year.rem_euclid(100), 2, '0')
};
};
if token == 'C' {
return era.name.clone();
}
let year = civil.year - era.year;
match usize::try_from(year).ok().and_then(|y| cat.numerals.get(y)) {
Some(numeral) => numeral.clone(),
None => pad(year, 2, '0'),
}
}
fn julian_fraction(julian_day: i64, second_of_day: i64, offset: i64) -> String {
let mut day = julian_day;
let mut fraction = second_of_day - offset;
if fraction < 0 {
day -= 1;
fraction += SECONDS_PER_DAY;
}
let mut sign = "";
if fraction != 0 && day < 0 {
day += 1;
if day == 0 {
sign = "-";
}
fraction = SECONDS_PER_DAY - fraction;
}
if fraction == 0 || fraction == SECONDS_PER_DAY / 2 {
let half = if fraction == 0 { '0' } else { '5' };
return format!("{sign}{day}.{half}");
}
let scaled = (fraction as f64 * 100_000_000.0 / SECONDS_PER_DAY as f64 + 0.5) as i64;
let digits = pad(scaled, 8, '0');
format!("{sign}{day}.{}", digits.trim_end_matches('0'))
}
fn stardate(civil: &Civil) -> String {
let day = civil.day_of_year() - 1;
let year_length = if is_leap(civil.year) { 366 } else { 365 };
let fraction_of_year = 1000 * day / year_length;
let tenth = civil.second_of_day() / (SECONDS_PER_DAY / 10);
format!(
"Stardate {}{}.{}",
pad(civil.year - 1946, 2, '0'),
pad(fraction_of_year, 3, '0'),
pad(if tenth < 0 { 10 + tenth } else { tenth }, 1, '0')
)
}
#[derive(Default)]
struct Scanned {
year: Option<i64>,
century: Option<i64>,
year_in_century: Option<i64>,
month: Option<u32>,
day: Option<u32>,
day_of_year: Option<i64>,
hour: Option<u32>,
minute: Option<u32>,
second: Option<u32>,
pm: Option<bool>,
hour_is_12: bool,
epoch: Option<i64>,
offset: Option<i32>,
weekday: Option<u32>,
julian_day: Option<i64>,
local_seconds: Option<i64>,
bce: Option<bool>,
}
fn no_match() -> String {
"input string does not match supplied format".to_string()
}
fn take_digits(text: &[char], at: &mut usize, max: usize) -> Option<i64> {
let start = *at;
let mut value: i64 = 0;
while *at < text.len() && *at - start < max && text[*at].is_ascii_digit() {
value = value * 10 + text[*at].to_digit(10)? as i64;
*at += 1;
}
(*at != start).then_some(value)
}
fn lower(c: char) -> char {
c.to_lowercase().next().unwrap_or(c)
}
fn take_prefix(text: &[char], at: &mut usize, tables: &[&[String]]) -> Option<usize> {
let matches = |entry: &str, len: usize| {
entry.chars().count() >= len
&& entry
.chars()
.take(len)
.enumerate()
.all(|(i, e)| text.get(*at + i).is_some_and(|&c| lower(c) == lower(e)))
};
let mut len = 0;
for entry in tables.iter().flat_map(|t| t.iter()) {
let reached = entry
.chars()
.enumerate()
.take_while(|(i, e)| text.get(*at + i).is_some_and(|&c| lower(c) == lower(*e)))
.count();
len = len.max(reached);
}
if len == 0 {
return None;
}
let mut value = None;
for table in tables {
for (index, entry) in table.iter().enumerate() {
if !matches(entry, len) {
continue;
}
if value.is_some_and(|found| found != index) {
return None;
}
value = Some(index);
}
}
*at += len;
value
}
fn take_name<S: AsRef<str>>(text: &[char], at: &mut usize, table: &[S]) -> Option<usize> {
let mut best: Option<(usize, usize)> = None;
for (i, name) in table.iter().enumerate() {
let chars: Vec<char> = name.as_ref().chars().collect();
if text.len() - *at >= chars.len()
&& text[*at..*at + chars.len()]
.iter()
.zip(&chars)
.all(|(a, b)| a.eq_ignore_ascii_case(b))
&& best.is_none_or(|(_, len)| chars.len() > len)
{
best = Some((i, chars.len()));
}
}
let (index, len) = best?;
*at += len;
Some(index)
}
const NUMERIC_TOKENS: &str = "deEmNyYCHkIlMSjsUWVGgu w";
fn scan_time(
input: &str,
format: &str,
zone: &Zone,
cat: &Catalog,
base_at: i64,
) -> Result<i64, String> {
let text: Vec<char> = input.chars().collect();
let pattern: Vec<char> = localize(format, cat).chars().collect();
let mut got = Scanned::default();
let mut at = 0usize;
let mut p = 0usize;
while p < pattern.len() {
let ch = pattern[p];
if ch != '%' {
if ch.is_whitespace() {
p += 1;
while at < text.len() && text[at].is_whitespace() {
at += 1;
}
continue;
}
if text.get(at) != Some(&ch) {
return Err(no_match());
}
at += 1;
p += 1;
continue;
}
p += 1;
let modifier = pattern.get(p).copied().filter(|c| matches!(c, 'E' | 'O'));
if modifier.is_some() {
p += 1;
}
let Some(token) = pattern.get(p).copied() else {
return Err(no_match());
};
p += 1;
if let Some(m) = modifier {
if scan_modified(m, token, &text, &mut at, &mut got, cat)? {
continue;
}
return Err(format!(
"clock scan: the format token \"%{m}{token}\" is not supported yet"
));
}
if NUMERIC_TOKENS.contains(token) {
while at < text.len() && text[at] == ' ' {
at += 1;
}
}
let digits = |at: &mut usize, max: usize| take_digits(&text, at, max).ok_or_else(no_match);
match token {
'%' => {
if text.get(at) != Some(&'%') {
return Err(no_match());
}
at += 1;
}
'n' | 't' => {
if !text.get(at).is_some_and(|c| c.is_whitespace()) {
return Err(no_match());
}
at += 1;
}
'd' | 'e' => got.day = Some(digits(&mut at, 2)? as u32),
'm' | 'N' => got.month = Some(digits(&mut at, 2)? as u32),
'b' | 'h' | 'B' => {
let index = take_prefix(&text, &mut at, &[&cat.months_full, &cat.months_abbrev])
.ok_or_else(no_match)?;
got.month = Some(index as u32 + 1);
}
'a' | 'A' => {
let index = take_prefix(&text, &mut at, &[&cat.days_full, &cat.days_abbrev])
.ok_or_else(no_match)?;
got.weekday = Some(if index == 0 { 7 } else { index as u32 });
}
'y' => got.year_in_century = Some(digits(&mut at, 2)?),
'Y' => got.year = Some(digits(&mut at, 4)?),
'C' => got.century = Some(digits(&mut at, 2)?),
'H' | 'k' => got.hour = Some(digits(&mut at, 2)? as u32),
'I' | 'l' => {
got.hour = Some(digits(&mut at, 2)? as u32);
got.hour_is_12 = true;
}
'M' => got.minute = Some(digits(&mut at, 2)? as u32),
'S' => got.second = Some(digits(&mut at, 2)? as u32),
'j' => got.day_of_year = Some(digits(&mut at, 3)?),
'p' | 'P' => {
let index = take_prefix(&text, &mut at, &[&[cat.am.clone(), cat.pm.clone()]])
.ok_or_else(no_match)?;
got.pm = Some(index == 1);
}
's' => got.epoch = Some(signed(&text, &mut at)?),
'u' | 'w' => {
let day = digits(&mut at, 1)?;
if day > 7 {
return Err("day of week is greater than 7".to_string());
}
got.weekday = Some(if day == 0 { 7 } else { day as u32 });
}
'U' | 'W' | 'V' => {
digits(&mut at, 2)?;
}
'G' => {
digits(&mut at, 4)?;
}
'g' => {
digits(&mut at, 2)?;
}
'z' | 'Z' => got.offset = Some(scan_zone(&text, &mut at)?),
'J' => got.julian_day = Some(signed(&text, &mut at)?),
'Q' => scan_stardate(&text, &mut at, &mut got)?,
other => {
return Err(format!(
"clock scan: the format token \"%{other}\" is not supported yet"
))
}
}
}
while at < text.len() && text[at].is_whitespace() {
at += 1;
}
if at != text.len() {
return Err(no_match());
}
assemble(got, zone, base_at)
}
fn signed(text: &[char], at: &mut usize) -> Result<i64, String> {
let negative = text.get(*at) == Some(&'-');
if negative || text.get(*at) == Some(&'+') {
*at += 1;
}
let value = take_digits(text, at, 19).ok_or_else(no_match)?;
Ok(if negative { -value } else { value })
}
fn scan_modified(
modifier: char,
token: char,
text: &[char],
at: &mut usize,
got: &mut Scanned,
cat: &Catalog,
) -> Result<bool, String> {
if modifier == 'O' {
let numeral = |at: &mut usize| {
take_prefix(text, at, &[&cat.numerals])
.map(|n| n as i64)
.ok_or_else(no_match)
};
match token {
'd' | 'e' => got.day = Some(numeral(at)? as u32),
'm' => got.month = Some(numeral(at)? as u32),
'y' => got.year_in_century = Some(numeral(at)?),
'H' | 'k' => got.hour = Some(numeral(at)? as u32),
'I' | 'l' => {
got.hour = Some(numeral(at)? as u32);
got.hour_is_12 = true;
}
'M' => got.minute = Some(numeral(at)? as u32),
'S' => got.second = Some(numeral(at)? as u32),
'u' | 'w' => {
let day = numeral(at)?;
if day > 7 {
return Err("day of week is greater than 7".to_string());
}
got.weekday = Some(if day == 0 { 7 } else { day as u32 });
}
_ => return Ok(false),
}
return Ok(true);
}
match token {
'E' => got.bce = Some(!scan_era(text, at, cat).ok_or_else(no_match)?),
'J' | 'j' => {
let offset = if token == 'j' { SECONDS_PER_DAY / 2 } else { 0 };
let day = signed(text, at)?;
let Some(fraction) = scan_day_fraction(text, at) else {
if token == 'J' {
got.julian_day = Some(day);
return Ok(true);
}
got.epoch = Some((day - 2440588) * SECONDS_PER_DAY + offset);
return Ok(true);
};
let mut seconds = offset + fraction;
let mut day = day;
if seconds >= SECONDS_PER_DAY {
seconds -= SECONDS_PER_DAY;
day += 1;
}
got.epoch = Some((day - 2440588) * SECONDS_PER_DAY + seconds);
}
'y' => {
take_prefix(text, at, &[&cat.numerals]).ok_or_else(no_match)?;
}
's' => got.local_seconds = Some(signed(text, at)?),
_ => return Ok(false),
}
Ok(true)
}
fn scan_day_fraction(text: &[char], at: &mut usize) -> Option<i64> {
if text.get(*at) != Some(&'.') {
return None;
}
let start = *at + 1;
let mut end = start;
let mut divisor: i64 = 1;
while text.get(end).is_some_and(|c| c.is_ascii_digit()) {
divisor = divisor.saturating_mul(10);
end += 1;
}
let mut value: i64 = 0;
for c in &text[start..end] {
value = value * 10 + c.to_digit(10).expect("a digit") as i64;
}
*at = end;
Some(SECONDS_PER_DAY * value / divisor)
}
fn scan_era(text: &[char], at: &mut usize, cat: &Catalog) -> Option<bool> {
let table = [
(cat.bce.as_str(), false),
(cat.ce.as_str(), true),
("b.c.e.", false),
("c.e.", true),
("b.c.", false),
("a.d.", true),
];
let mut len = 0;
for (word, _) in table {
let reached = word
.chars()
.enumerate()
.take_while(|(i, w)| text.get(*at + i).is_some_and(|c| lower(*c) == lower(*w)))
.count();
len = len.max(reached);
}
if len == 0 {
return None;
}
let mut era = None;
for (word, common) in table {
let matches = word.chars().count() >= len
&& word
.chars()
.take(len)
.enumerate()
.all(|(i, w)| text.get(*at + i).is_some_and(|c| lower(*c) == lower(w)));
if matches {
if era.is_some_and(|found| found != common) {
return None;
}
era = Some(common);
}
}
*at += len;
era
}
fn scan_stardate(text: &[char], at: &mut usize, got: &mut Scanned) -> Result<(), String> {
let prefix: Vec<char> = "stardate ".chars().collect();
if text.len() < *at + prefix.len()
|| !text[*at..*at + prefix.len()]
.iter()
.zip(&prefix)
.all(|(c, p)| lower(*c) == *p)
{
return Err(no_match());
}
let mut cursor = *at + prefix.len();
while text.get(cursor).is_some_and(|c| c.is_whitespace()) {
cursor += 1;
}
if text.get(cursor) == Some(&'+') {
cursor += 1;
}
let start = cursor;
while text.get(cursor).is_some_and(|c| c.is_ascii_digit()) {
cursor += 1;
}
if cursor - start < 4 {
return Err(no_match());
}
let number = |slice: &[char]| -> i64 {
slice
.iter()
.fold(0, |n, c| n * 10 + c.to_digit(10).expect("a digit") as i64)
};
let year = number(&text[start..cursor - 3]) + 1946;
let elapsed = number(&text[cursor - 3..cursor]);
if text.get(cursor) != Some(&'.') {
return Err(no_match());
}
*at = cursor;
let fraction = scan_day_fraction(text, at).ok_or_else(no_match)?;
let length = if is_leap(year) { 366 } else { 365 };
let scaled = elapsed * length;
let day_of_year = scaled / 1000 + 1 + i64::from(scaled % 1000 >= 500);
let day = days_from_civil(year, 1, 1) + day_of_year - 1;
got.local_seconds = Some(day * SECONDS_PER_DAY + fraction);
Ok(())
}
fn scan_zone(text: &[char], at: &mut usize) -> Result<i32, String> {
if matches!(text.get(*at), Some('+') | Some('-')) {
let start = *at;
*at += 1;
while text
.get(*at)
.is_some_and(|c| c.is_ascii_digit() || *c == ':')
{
*at += 1;
}
let candidate: String = text[start..*at].iter().collect();
return fixed_offset(&candidate).ok_or_else(no_match);
}
match take_name(text, at, &["GMT", "UTC", "Z"]) {
Some(_) => Ok(0),
None => {
Err("clock scan: reading a time zone by abbreviation is not supported yet".to_string())
}
}
}
fn assemble(got: Scanned, zone: &Zone, base_at: i64) -> Result<i64, String> {
if let Some(epoch) = got.epoch {
return Ok(epoch);
}
let direct = got
.local_seconds
.or_else(|| got.julian_day.map(|day| (day - 2440588) * SECONDS_PER_DAY));
if let Some(local) = direct {
return Ok(match got.offset {
Some(offset) => local - offset as i64,
None => local - zone.for_local(local).offset as i64,
});
}
let base = civil_of(base_at + zone.at(base_at).offset as i64);
let year = match (got.year, got.century, got.year_in_century) {
(Some(year), _, _) => year,
(None, Some(century), Some(year)) => century * 100 + year,
(None, None, Some(year)) => year + if year < 69 { 2000 } else { 1900 },
(None, Some(_), None) | (None, None, None) => base.year,
};
let year = if got.bce == Some(true) {
1 - year
} else {
year
};
let mut hour = got.hour.unwrap_or(0);
let bad = |what: &str| Err(format!("unable to convert input string: invalid {what}"));
if let Some(month) = got.month {
if !(1..=12).contains(&month) {
return bad("month");
}
}
if let Some(day) = got.day {
let month = got.month.unwrap_or(base.month);
if day < 1 || day > month_length(year, month) {
return bad("day");
}
}
let hour_limit = if got.hour_is_12 { 12 } else { 24 };
if hour > hour_limit {
return bad("time (hour)");
}
if got.minute.is_some_and(|m| m > 59) {
return bad("time (minutes)");
}
if got.second.is_some_and(|s| s > 59) {
return bad("time");
}
if got.hour_is_12 {
hour %= 12;
if got.pm == Some(true) {
hour += 12;
}
} else if got.pm == Some(true) && hour < 12 {
hour += 12;
}
let days = match got.day_of_year {
Some(day) => {
let length = if is_leap(year) { 366 } else { 365 };
if day < 1 || day > length {
return bad("day of year");
}
days_from_civil(year, 1, 1) + day - 1
}
None => {
let month = got.month.unwrap_or(base.month);
let day = got.day.unwrap_or(base.day);
days_from_civil(year, month, day)
}
};
if let Some(named) = got.weekday {
let year_given = got.year.is_some() || got.year_in_century.is_some();
let dated_day = year_given && (got.day.is_some() || got.day_of_year.is_some());
if dated_day && civil_of(days * 86400).iso_weekday() != named {
return Err("unable to convert input string: invalid day of week".to_string());
}
}
let local = days * 86400
+ hour as i64 * 3600
+ got.minute.unwrap_or(0) as i64 * 60
+ got.second.unwrap_or(0) as i64;
let seconds = match got.offset {
Some(offset) => local - offset as i64,
None => local - zone.for_local(local).offset as i64,
};
if seconds < EARLIEST {
return Err(too_early());
}
Ok(seconds)
}
const UNITS: &[&str] = &[
"years", "months", "week", "weeks", "days", "weekdays", "hours", "minutes", "seconds",
];
fn add_units(seconds: i64, count: i64, unit: &str, zone: &Zone) -> Result<i64, String> {
let scale = match unit {
"seconds" => Some(1),
"minutes" => Some(60),
"hours" => Some(3600),
_ => None,
};
if let Some(scale) = scale {
return seconds
.checked_add(count.checked_mul(scale).ok_or_else(overflow)?)
.ok_or_else(overflow);
}
let local = seconds
.checked_add(zone.at(seconds).offset as i64)
.ok_or_else(overflow)?;
let civil = civil_of(local);
let days = match unit {
"days" => civil.epoch_day.checked_add(count).ok_or_else(overflow)?,
"week" | "weeks" => civil
.epoch_day
.checked_add(count.checked_mul(7).ok_or_else(overflow)?)
.ok_or_else(overflow)?,
"weekdays" => weekday_walk(civil.epoch_day, count),
_ => {
let months = if unit == "years" {
count.checked_mul(12).ok_or_else(overflow)?
} else {
count
};
let total = (civil.year * 12 + civil.month as i64 - 1)
.checked_add(months)
.ok_or_else(overflow)?;
let year = total.div_euclid(12);
let month = total.rem_euclid(12) as u32 + 1;
days_from_civil(year, month, civil.day.min(month_length(year, month)))
}
};
let moved = days * 86400 + local.rem_euclid(86400);
let result = moved - zone.for_local(moved).offset as i64;
if result < EARLIEST {
return Err(too_early());
}
Ok(result)
}
fn weekday_walk(start: i64, count: i64) -> i64 {
let step = if count < 0 { -1 } else { 1 };
let mut day = start;
let mut left = count.abs();
while left > 0 {
day += step;
if (day + 3).rem_euclid(7) < 5 {
left -= 1;
}
}
day
}
fn overflow() -> String {
"integer value too large to represent".to_string()
}
fn bad_unit(unit: &str) -> String {
format!("bad unit \"{unit}\": must be {}", listing(UNITS))
}
struct Options {
format: Option<String>,
gmt: Option<bool>,
timezone: Option<String>,
base: Option<i64>,
locale: Option<String>,
}
impl Options {
fn catalog(&self) -> Result<Arc<Catalog>, String> {
crate::clock_locale::enter(self.locale.as_deref().unwrap_or("current"))
}
fn zone(&self) -> Result<Zone, String> {
if self.gmt.is_some() && self.timezone.is_some() {
return Err("cannot use -gmt and -timezone in same call".to_string());
}
match (&self.timezone, self.gmt) {
(Some(name), _) => load_zone(name),
(None, Some(true)) => Ok(Zone::fixed(0, "GMT")),
_ => system_zone(),
}
}
}
fn options(words: &[Value], allowed: &[&str], usage: &str) -> Result<Options, String> {
let mut out = Options {
format: None,
gmt: None,
timezone: None,
base: None,
locale: None,
};
let mut i = 0;
while i < words.len() {
let name = to_tcl_string(&words[i]);
let Some(option) = resolve(&name, allowed) else {
return Err(format!(
"bad option \"{name}\": must be {}",
listing(allowed)
));
};
let Some(value) = words.get(i + 1) else {
return Err(usage.to_string());
};
match option {
"-format" => out.format = Some(to_tcl_string(value)),
"-gmt" => out.gmt = Some(crate::runtime::tcl_bool(value)?),
"-timezone" => out.timezone = Some(to_tcl_string(value)),
"-base" => out.base = Some(seconds_of(value)?),
"-locale" => out.locale = Some(to_tcl_string(value)),
_ => unreachable!("the option table and this match are one list"),
}
i += 2;
}
Ok(out)
}
fn seconds_of(v: &Value) -> Result<i64, String> {
let text = tcl_str(v);
if text.trim() == "now" {
return Ok(current_seconds());
}
match crate::runtime::parse_number(text.trim()) {
Ok(Num::Int(i)) => Ok(i),
_ => Err(format!("bad seconds \"{text}\": must be now or integer")),
}
}
fn current_micros() -> i64 {
std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.map(|d| d.as_micros() as i64)
.unwrap_or(0)
}
fn current_seconds() -> i64 {
current_micros().div_euclid(1_000_000)
}
const FORMAT_USAGE: &str = "wrong # args: should be \"clock format clockval|now ?-format string? ?-gmt boolean? ?-locale LOCALE? ?-timezone ZONE?\"";
const SCAN_USAGE: &str = "wrong # args: should be \"clock scan string ?-base seconds? ?-format string? ?-gmt boolean? ?-locale LOCALE? ?-timezone ZONE?\"";
const ADD_USAGE: &str = "wrong # args: should be \"clock add clockval ?number units?... ?-gmt boolean? ?-locale LOCALE? ?-timezone ZONE?\"";
pub(crate) fn extension(vm: &mut VM, id: u16, arg: u8) -> Result<(), String> {
if id == ext::NOW {
let switch = if arg == 3 { Some(vm.pop()) } else { None };
let value = now(arg, switch.as_ref())?;
vm.push(value);
return Ok(());
}
let mut words = Vec::with_capacity(arg as usize);
for _ in 0..arg {
words.push(vm.pop());
}
words.reverse();
let value = match id {
ext::FORMAT => run_format(&words)?,
ext::SCAN => run_scan(&words)?,
_ => run_add(&words)?,
};
vm.push(value);
Ok(())
}
fn now(unit: u8, switch: Option<&Value>) -> Result<Value, String> {
if let Some(switch) = switch {
return match to_tcl_string(switch).as_str() {
"-milliseconds" => Ok(Value::Int(current_micros() / 1000)),
"-microseconds" | "" => Ok(Value::Int(current_micros())),
other => Err(format!(
"bad option \"{other}\": must be -microseconds or -milliseconds"
)),
};
}
Ok(Value::Int(match unit {
0 => current_seconds(),
1 => current_micros() / 1000,
_ => current_micros(),
}))
}
fn run_format(words: &[Value]) -> Result<Value, String> {
let Some(clock) = words.first() else {
return Err(FORMAT_USAGE.to_string());
};
let seconds = seconds_of(clock)?;
let opts = options(
&words[1..],
&["-format", "-gmt", "-locale", "-timezone"],
FORMAT_USAGE,
)?;
let zone = opts.zone()?;
let format = opts.format.as_deref().unwrap_or(DEFAULT_FORMAT);
let cat = opts.catalog()?;
Ok(Value::Str(Arc::new(format_time(
seconds, format, &zone, &cat,
)?)))
}
fn run_scan(words: &[Value]) -> Result<Value, String> {
let Some(input) = words.first() else {
return Err(SCAN_USAGE.to_string());
};
let opts = options(
&words[1..],
&["-base", "-format", "-gmt", "-locale", "-timezone"],
SCAN_USAGE,
)?;
let zone = opts.zone()?;
let Some(format) = opts.format.as_deref() else {
return Err(
"clock scan: the free-form parser is not supported yet; use -format".to_string(),
);
};
let cat = opts.catalog()?;
let base_at = opts.base.unwrap_or_else(current_seconds);
Ok(Value::Int(scan_time(
&to_tcl_string(input),
format,
&zone,
&cat,
base_at,
)?))
}
fn run_add(words: &[Value]) -> Result<Value, String> {
let Some(clock) = words.first() else {
return Err(ADD_USAGE.to_string());
};
let mut seconds = seconds_of(clock)?;
let rest = &words[1..];
let split = rest
.iter()
.position(|w| {
let text = to_tcl_string(w);
text.starts_with('-') && text[1..].starts_with(|c: char| c.is_ascii_alphabetic())
})
.unwrap_or(rest.len());
let (offsets, tail) = rest.split_at(split);
let opts = options(tail, &["-base", "-gmt", "-locale", "-timezone"], ADD_USAGE)?;
let zone = opts.zone()?;
let mut i = 0;
while i < offsets.len() {
let count = match crate::runtime::parse_number(tcl_str(&offsets[i]).trim()) {
Ok(Num::Int(n)) => n,
_ => {
return Err(format!(
"expected integer but got \"{}\"",
to_tcl_string(&offsets[i])
))
}
};
let Some(unit) = offsets.get(i + 1) else {
return Err(ADD_USAGE.to_string());
};
let unit = to_tcl_string(unit);
let Some(resolved) = resolve(&unit, UNITS) else {
return Err(bad_unit(&unit));
};
seconds = add_units(seconds, count, resolved, &zone)?;
i += 2;
}
Ok(Value::Int(seconds))
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn the_calendar_round_trips() {
for day in [-79366i64, -1, 0, 1, 14288, 100000, 2932896] {
let (y, m, d) = civil_from_days(day);
assert_eq!(days_from_civil(y, m, d), day, "day {day} -> {y}-{m}-{d}");
}
}
#[test]
fn a_known_instant_formats() {
let utc = Zone::fixed(0, "GMT");
let out =
format_time(1234567890, DEFAULT_FORMAT, &utc, &Catalog::default()).expect("formats");
assert_eq!(out, "Fri Feb 13 23:31:30 GMT 2009");
let iso = format_time(1234567890, "%G-W%V-%u %j %U %W", &utc, &Catalog::default())
.expect("formats");
assert_eq!(iso, "2009-W07-5 044 06 06");
}
#[test]
fn early_dates_are_refused() {
let utc = Zone::fixed(0, "GMT");
let err = format_time(EARLIEST - 1, "%Y", &utc, &Catalog::default()).expect_err("refused");
assert!(err.contains("Gregorian changeover"), "{err}");
}
#[test]
fn numeric_zones_parse() {
assert_eq!(fixed_offset("+0530"), Some(19800));
assert_eq!(fixed_offset("-05:30"), Some(-19800));
assert_eq!(fixed_offset("+01"), Some(3600));
assert_eq!(fixed_offset("+01:02:03"), Some(3723));
assert_eq!(fixed_offset("CET"), None);
assert_eq!(fixed_offset("+abc"), None);
}
}