greg-tz 0.2.8

greg timezone data
Documentation
//! Build script for `greg-tz`, adapted from `chrono-tz`

use std::borrow::Cow;
use std::collections::BTreeMap;
use std::env;
use std::fs::File;
use std::io::{self, BufRead, BufReader, Write};
use std::path::PathBuf;

use parse_zoneinfo::line::Line;
use parse_zoneinfo::table::{Table, TableBuilder};
use parse_zoneinfo::transitions::{
	FixedTimespan,
	FixedTimespanSet,
	TableTransitions
};

// This function is needed until zoneinfo_parse handles comments correctly.
// Technically a '#' symbol could occur between double quotes and should be
// ignored in this case, however this never happens in the tz database as it
// stands.
// credit: chrono_tz
fn strip_comments(mut line: String) -> String {
	if let Some(pos) = line.find('#') {
		line.truncate(pos);
	};
	line
}

// Convert all '/' to '__', all '+' to 'Plus' and '-' to 'Minus', unless
// it's a hyphen, in which case remove it. This is so the names can be used
// as rust identifiers.
// credit: chrono_tz as convert_bad_chars
fn tz_identifier(name: &str) -> String {
	let name = name.replace('/', "__").replace('+', "Plus");
	if let Some(pos) = name.find('-') {
		if name[pos + 1..].chars().next().map(char::is_numeric).unwrap_or(false) {
			name.replace('-', "Minus")
		} else {
			name.replace('-', "")
		}
	} else {
		name
	}
}

/// Manually create short names like "+11" or "-02" from the offset in seconds, if the short name is "%z".
///
/// Since 2024b, the TZ DB contains "%z" instead of names like "+11".
///
/// credit: chrono_tz as Display impl for FixedTimeSpan
/// <https://github.com/chronotope/chrono-tz/blob/a07STR204f87e42c728588843238dae2d8a1bee3cee/chrono-tz/src/timezone_impl.rs#L39>
fn offset_short_name(name: &'_ str, offset: i64) -> Cow<'_, str>  {
	if name != "%z" {
		return Cow::Borrowed(name)
	}
	let sign = if offset.is_negative() {"-"} else {"+"};

	let off = offset.abs();
	let minutes = off / 60;
	let secs = (off % 60) as u8;
	let mins = (minutes % 60) as u8;
	let hours = (minutes / 60) as u8;
	assert!(
		secs == 0,
		"numeric names are not used if the offset has fractional minutes"
	);

	if mins != 0 {
		Cow::Owned(format!("{sign}{hours:02}{mins:02}"))
	}
	else {
		Cow::Owned(format!("{sign}{hours:02}"))
	}
}

struct Definitions {
	names: Vec<String>,
	zones: Vec<String>,
	offsets: Vec<String>
}

impl Definitions {
	fn collect(table: &Table) -> Self {
		enum Entry {
			Zone,
			Link(String)
		}
		let zones = table.zonesets
			.keys()
			.map(|name| (name.clone(), Entry::Zone));
		let links = table.links
			.iter()
			.map(|(name, target)| (name.clone(), Entry::Link(target.clone())));
		let entries: BTreeMap<String, Entry> = zones.chain(links).collect();

		let mut names = Vec::new();
		let mut zones = Vec::new();
		let mut offsets = Vec::new();

		for (name, entry) in entries {
			names.push(name.clone());
			let set = table.timespans(&name).unwrap();
			match entry {
				Entry::Zone => {
					zones.push(Self::define_zone(&name, &set));
					if set.rest.is_empty() {
						offsets.push(Self::define_offset(&name, &set.first));
					}
				},
				Entry::Link(target) => {
					zones.push(Self::define_link(&name, &target));
					if set.rest.is_empty() {
						offsets.push(Self::define_offset_link(&name, &target));
					}
				}
			}
		}
		Self {names, zones, offsets}
	}

	fn define_offset(name: &str, span: &FixedTimespan) -> String {
		let ident = tz_identifier(name);

		let offset = span.utc_offset + span.dst_offset;
		let short_name = offset_short_name(&span.name, offset);

		format!(
			"\t/// {name}\n\
			\tpub const {ident}: Self = Self::new({short_name:?}, {offset});\n"
		)
	}

	fn define_offset_link(name: &str, target: &str) -> String {
		let ident = tz_identifier(name);
		let target_ident = tz_identifier(target);

		let doc = format!("/// {name} (an alias for {target})");
		format!("\t{doc}\n\tpub const {ident}: Self = Self::{target_ident};\n")
	}
	fn define_zone(name: &str, set: &FixedTimespanSet) -> String {
		let ident = tz_identifier(name);

		let doc = format!("/// {name}");
		let decl = format!("pub const {ident}: Self = Self::define(");
		let lmt_offset = set.first.utc_offset + set.first.dst_offset;
		let lmt_short_name = offset_short_name(&set.first.name, lmt_offset);

		let lmt = format!("Offset::new({lmt_short_name:?}, {lmt_offset})");
		let offsets = Self::make_offsets(&set.rest);
		format!(
			"\t{doc}\n\
			\t{decl}\n\
			\t\t{name:?},\n\
			\t\t{lmt},\n\
			\t\t{offsets}\n\
			\t);\n"
		)
	}
	fn define_link(name: &str, target: &str) -> String {
		let ident = tz_identifier(name);
		let target_ident = tz_identifier(target);

		format!(
			"\t/// {name} (an alias for {target})\n\
			\tpub const {ident}: Self = Self::alias(\n\
			\t\t{name:?},\n\
			\t\tSelf::{target_ident}\n\
			\t);\n"
		)
	}
	// adapted version of chrono_tz fn format_rest
	fn make_offsets(offsets: &[(i64, FixedTimespan)]) -> String {
		if offsets.is_empty() {
			return String::from("&[]");
		}
		let mut res = String::from("&[");
		use std::fmt::Write;
		for (p, FixedTimespan { utc_offset, dst_offset, name }) in offsets {
			let seconds = utc_offset + dst_offset;
			let short_name = offset_short_name(name, seconds);
			//write!(res, "\n\t\toff!({p}, {name:?}, {seconds}),").unwrap();
			write!(
				res,
				"\n\t\t\t{},",
				format_args!("(Point{{timestamp: {p}}}, \
					Offset::new({short_name:?}, {seconds}))"
				)
			).unwrap();
		}
		write!(res, "\n\t\t]").unwrap();
		res
	}
	fn write_file(self, file: &mut File) -> io::Result<()> {
		if !self.zones.is_empty() {
			writeln!(file, "#[allow(non_upper_case_globals)]")?;
			writeln!(file, "impl Zone {{")?;
			for def in self.zones {
				file.write_all(def.as_bytes())?;
			}
			writeln!(file, "}}")?;
		}
		if !self.offsets.is_empty() {
			writeln!(file, "#[allow(non_upper_case_globals)]")?;
			writeln!(file, "impl Offset {{")?;
			for def in self.offsets {
				file.write_all(def.as_bytes())?;
			}
			writeln!(file, "}}")?;
		}
		if !self.names.is_empty() {
			writeln!(
				file,
				"/// Static map of all [`Zone`]s and their names, \
				generated at compile time"
			)?;
			writeln!(
				file,
				"pub static ZONES: phf::Map<&'static str, Zone> = \
				phf::phf_map! {{"
			)?;
			for name in self.names {
				writeln!(
					file,
					"\t{name:?} => Zone::{},",
					tz_identifier(&name)
				)?;
			}
			writeln!(file, "}};")?;
		}


		Ok(())
	}
}

// credit: chrono_tz (changed slightly)
fn main() {
	let mut table = TableBuilder::new();

	let tzfiles = [
		"tz/africa",
		"tz/antarctica",
		"tz/asia",
		"tz/australasia",
		"tz/backward",
		"tz/etcetera",
		"tz/europe",
		"tz/northamerica",
		"tz/southamerica",
	];
	let manifest_dir = match env::var("CARGO_MANIFEST_DIR") {
		Ok(p) => PathBuf::from(p),
		Err(_) => PathBuf::new()
	};

	let lines = tzfiles
		.iter()
		.map(|p| manifest_dir.join(p))
		.map(|path| File::open(&path)
			.map_err(|e| format!("cannot open {}: {e}", path.display()))
			.unwrap()
		)
		.map(BufReader::new)
		.flat_map(BufRead::lines)
		.map(Result::unwrap)
		.map(strip_comments);

	for line in lines {
		match Line::new(&line).unwrap() {
			Line::Zone(zone) => table.add_zone_line(zone).unwrap(),
			Line::Continuation(cont) => table.add_continuation_line(cont).unwrap(),
			Line::Rule(rule) => table.add_rule_line(rule).unwrap(),
			Line::Link(link) => table.add_link_line(link).unwrap(),
			Line::Space => {}
		}
	}

	let table = table.build();
	let out_dir = PathBuf::from(env::var("OUT_DIR").unwrap());

	let timezone_path = out_dir.join("timezones.rs");
	let mut timezone_file = File::create(timezone_path).unwrap();
	Definitions::collect(&table).write_file(&mut timezone_file).unwrap();
	println!("cargo:rerun-if-changed=build.rs");
}