rebuild the engine in Rust

Python was never the house language here: the mw-* desktop surface it came
from is 39 bash scripts and was exactly one Python script, the one this was
extracted from. What it cost was distribution, a widget dependency that
needed pipx and a Python floor, and 24ms of interpreter startup before a line
of the program ran.

The port is verified rather than asserted. Both implementations were run over
199,446 day-records, 26 jurisdictions across 21 years, with every field of
every record required to match; the JSON, the iCalendar and 480 renderings of
the text output are identical too. examples/dump.rs is the harness.

What the type system actually buys, which is less than the usual claim and
more than nothing:

  - Level is an ordered enum, strictest first, so  reads as 'a is
    stricter' and relaxed_to() is the one place a feast can lift a fast. No
    rule can get the direction backwards.
  - Date is a newtype over the Julian Day Number and JulianDate is a separate
    type, so a civil date and an ecclesiastical one cannot be mixed up. That
    confusion is the single most common way calendar code goes wrong.
  - Octets is a newtype in the iCalendar folder, because the bug it replaced
    counted characters against a limit RFC 5545 states in octets.
  - Movables is a struct, not a map, so a mistyped key is a compile error
    rather than a None at the far end of the program.

Of the six defects an adversarial review found in the Python, exactly one had
a shape a type system would have caught. The rest were logic, and the tests
came with them.

The menologion is compiled in with include_str!, so the binary answers on its
own: 930KB, no runtime, 1.2ms to start against Python's 36ms.
This commit is contained in:
funkpower
2026-09-18 11:13:15 -04:00
parent 8247b51805
commit d115616eff
44 changed files with 3367 additions and 2287 deletions
+262
View File
@@ -0,0 +1,262 @@
//! Conversion between the civil calendar and the calendar a church reckons in.
//!
//! Every date in this library exists twice. There is the **civil** date, what a
//! phone says, always Gregorian; and the **ecclesiastical** date, the day the
//! Church calls it. For a church on the new calendar those are the same date.
//! For one on the old calendar they differ by the drift between the Julian and
//! Gregorian reckonings: thirteen days from 1900 until 14 March 2100, and
//! fourteen from the 15th, the day after a Julian 29 February the Gregorian
//! calendar does not have.
//!
//! Keeping both is what makes a single menologion serve every jurisdiction. The
//! Nativity is 25 December in the Julian and the Gregorian books alike; the two
//! traditions disagree about which morning that is, not about which date it is.
//!
//! Everything here is built on the Julian Day Number, which is why this crate
//! carries its own date type instead of a calendar library: the conversions the
//! liturgical year needs *are* JDN arithmetic, and a `Date` that is a JDN in a
//! newtype makes "add a week" and "what is this in the Julian calendar" the
//! same two lines.
use std::fmt;
/// A civil (proleptic Gregorian) date, stored as a Julian Day Number.
///
/// A newtype over the JDN rather than a (y, m, d) triple, so that day
/// arithmetic cannot go wrong at a month or year boundary.
#[derive(Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash, Debug)]
pub struct Date(pub i32);
/// A date in the Julian calendar. Deliberately a *different type* from `Date`:
/// mixing the two is the single most common way calendar code goes wrong, and
/// the compiler can simply forbid it.
#[derive(Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Debug)]
pub struct JulianDate {
pub year: i32,
pub month: u32,
pub day: u32,
}
#[derive(Clone, Copy, PartialEq, Eq, Debug)]
pub enum Weekday {
Monday,
Tuesday,
Wednesday,
Thursday,
Friday,
Saturday,
Sunday,
}
pub const MONTHS: [&str; 12] = [
"January", "February", "March", "April", "May", "June", "July", "August",
"September", "October", "November", "December",
];
const MONTHS_SHORT: [&str; 12] = [
"Jan", "Feb", "Mar", "Apr", "May", "Jun", "Jul", "Aug", "Sep", "Oct", "Nov", "Dec",
];
impl Weekday {
pub fn name(self) -> &'static str {
match self {
Weekday::Monday => "Monday",
Weekday::Tuesday => "Tuesday",
Weekday::Wednesday => "Wednesday",
Weekday::Thursday => "Thursday",
Weekday::Friday => "Friday",
Weekday::Saturday => "Saturday",
Weekday::Sunday => "Sunday",
}
}
pub fn short(self) -> &'static str {
&self.name()[..3]
}
pub fn is_weekend(self) -> bool {
matches!(self, Weekday::Saturday | Weekday::Sunday)
}
}
/// Julian Day Number for a proleptic Gregorian date.
pub fn gregorian_to_jdn(year: i32, month: u32, day: u32) -> i32 {
let a = (14 - month as i32) / 12;
let y = year + 4800 - a;
let m = month as i32 + 12 * a - 3;
day as i32 + (153 * m + 2) / 5 + 365 * y + y / 4 - y / 100 + y / 400 - 32045
}
/// Julian Day Number for a proleptic Julian date.
pub fn julian_to_jdn(year: i32, month: u32, day: u32) -> i32 {
let a = (14 - month as i32) / 12;
let y = year + 4800 - a;
let m = month as i32 + 12 * a - 3;
day as i32 + (153 * m + 2) / 5 + 365 * y + y / 4 - 32083
}
fn jdn_to_gregorian(jdn: i32) -> (i32, u32, u32) {
let a = jdn + 32044;
let b = a.div_euclid(146097);
let c = a.rem_euclid(146097);
let d = (4 * c + 3) / 146097;
let e = c - (146097 * d) / 4;
let f = (4 * e + 3) / 1461;
let g = e - (1461 * f) / 4;
let h = (5 * g + 2) / 153;
let day = g - (153 * h + 2) / 5 + 1;
let month = h + 3 - 12 * (h / 10);
let year = 400 * b + 100 * d + f - 4800 + h / 10;
(year, month as u32, day as u32)
}
fn jdn_to_julian(jdn: i32) -> (i32, u32, u32) {
let c = jdn + 32082;
let d = (4 * c + 3) / 1461;
let e = c - (1461 * d) / 4;
let m = (5 * e + 2) / 153;
let day = e - (153 * m + 2) / 5 + 1;
let month = m + 3 - 12 * (m / 10);
let year = d - 4800 + m / 10;
(year, month as u32, day as u32)
}
impl Date {
pub fn from_ymd(year: i32, month: u32, day: u32) -> Option<Date> {
if !(1..=12).contains(&month) || day == 0 || day > days_in_month(year, month) {
return None;
}
Some(Date(gregorian_to_jdn(year, month, day)))
}
pub fn ymd(self) -> (i32, u32, u32) {
jdn_to_gregorian(self.0)
}
pub fn year(self) -> i32 {
self.ymd().0
}
pub fn month(self) -> u32 {
self.ymd().1
}
pub fn day(self) -> u32 {
self.ymd().2
}
pub fn weekday(self) -> Weekday {
// JDN 0 was a Monday, so the modulus lands directly on the enum.
match self.0.rem_euclid(7) {
0 => Weekday::Monday,
1 => Weekday::Tuesday,
2 => Weekday::Wednesday,
3 => Weekday::Thursday,
4 => Weekday::Friday,
5 => Weekday::Saturday,
_ => Weekday::Sunday,
}
}
/// The Julian calendar date of this civil date.
pub fn to_julian(self) -> JulianDate {
let (year, month, day) = jdn_to_julian(self.0);
JulianDate { year, month, day }
}
/// How many days the Julian reckoning currently lags the Gregorian one.
/// Computed, never assumed, so nothing here breaks at the 2100 boundary.
pub fn julian_offset(self) -> i32 {
let j = self.to_julian();
self.0 - gregorian_to_jdn(j.year, j.month, j.day)
}
pub fn iso(self) -> String {
let (y, m, d) = self.ymd();
format!("{y:04}-{m:02}-{d:02}")
}
/// "Fri 18 Sep", the width the seven-day view lines up on.
pub fn short(self) -> String {
let (_, m, d) = self.ymd();
format!("{} {} {}", self.weekday().short(), d, MONTHS_SHORT[(m - 1) as usize])
}
/// "18 September 2026"
pub fn long(self) -> String {
let (y, m, d) = self.ymd();
format!("{} {} {}", d, MONTHS[(m - 1) as usize], y)
}
pub fn parse_iso(s: &str) -> Option<Date> {
let b = s.as_bytes();
if b.len() != 10 || b[4] != b'-' || b[7] != b'-' {
return None;
}
let year = s[0..4].parse().ok()?;
let month = s[5..7].parse().ok()?;
let day = s[8..10].parse().ok()?;
Date::from_ymd(year, month, day)
}
}
impl JulianDate {
/// The civil date of a Julian calendar date.
pub fn to_civil(self) -> Date {
Date(julian_to_jdn(self.year, self.month, self.day))
}
}
impl std::ops::Add<i32> for Date {
type Output = Date;
fn add(self, days: i32) -> Date {
Date(self.0 + days)
}
}
impl std::ops::Sub<i32> for Date {
type Output = Date;
fn sub(self, days: i32) -> Date {
Date(self.0 - days)
}
}
/// Days between two dates, which is a count and not a date.
impl std::ops::Sub<Date> for Date {
type Output = i32;
fn sub(self, other: Date) -> i32 {
self.0 - other.0
}
}
impl fmt::Display for Date {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
f.write_str(&self.iso())
}
}
pub fn is_leap(year: i32) -> bool {
(year % 4 == 0 && year % 100 != 0) || year % 400 == 0
}
pub fn days_in_month(year: i32, month: u32) -> u32 {
match month {
1 | 3 | 5 | 7 | 8 | 10 | 12 => 31,
4 | 6 | 9 | 11 => 30,
2 if is_leap(year) => 29,
2 => 28,
_ => 0,
}
}
/// Is (month, day) within an inclusive fixed-date range, wrapping the year?
///
/// The Nativity Fast does not wrap; the fast-free stretch from the Nativity to
/// the eve of Theophany does. Both are written the same way here.
pub fn fixed_between(md: (u32, u32), start: (u32, u32), end: (u32, u32)) -> bool {
if start <= end {
start <= md && md <= end
} else {
md >= start || md <= end
}
}
+77
View File
@@ -0,0 +1,77 @@
//! Where the default jurisdiction is remembered.
//!
//! Choosing a reckoning is a once-in-a-lifetime decision for most people, so it
//! should not be a flag typed every morning. The first run writes the choice
//! down; every run after reads it. An explicit flag always wins over the file.
use std::path::PathBuf;
pub const ENV_VAR: &str = "TYPIKON_JURISDICTION";
#[derive(Clone, Copy, PartialEq, Eq, Debug)]
pub enum Source {
Flag,
Environment,
Config,
Default,
}
pub fn path() -> PathBuf {
let base = std::env::var_os("XDG_CONFIG_HOME")
.map(PathBuf::from)
.unwrap_or_else(|| {
let home = std::env::var_os("HOME").unwrap_or_default();
PathBuf::from(home).join(".config")
});
base.join("typikon").join("config.toml")
}
/// The one key this file holds, read without a TOML parser because a
/// dependency for `key = "value"` is a dependency too many.
fn read() -> Option<String> {
let text = std::fs::read_to_string(path()).ok()?;
for line in text.lines() {
let line = line.trim();
if line.starts_with('#') {
continue;
}
let Some((key, value)) = line.split_once('=') else { continue };
if key.trim() != "jurisdiction" {
continue;
}
let value = value.trim().trim_matches(['"', '\'']);
if !value.is_empty() {
return Some(value.to_string());
}
}
None
}
pub fn write(jurisdiction: &str) -> std::io::Result<PathBuf> {
let p = path();
if let Some(dir) = p.parent() {
std::fs::create_dir_all(dir)?;
}
std::fs::write(
&p,
format!(
"# Which reckoning typikon uses when no --jurisdiction is given.\n\
# Run `typikon --list` to see every accepted value.\n\
jurisdiction = \"{jurisdiction}\"\n"
),
)?;
Ok(p)
}
/// The environment beats the config file; both beat the built-in default.
pub fn default_jurisdiction() -> (Option<String>, Source) {
if let Ok(v) = std::env::var(ENV_VAR) {
if !v.is_empty() {
return (Some(v), Source::Environment);
}
}
match read() {
Some(v) => (Some(v), Source::Config),
None => (None, Source::Default),
}
}
+224
View File
@@ -0,0 +1,224 @@
//! A single day, fully reckoned.
//!
//! `Day` is what everything else in the crate produces and what every front end
//! consumes. It carries the civil date, the ecclesiastical date, the season,
//! the commemorations and the fast, and it carries the jurisdiction that
//! produced it, so a caller can always ask by whose reckoning.
use serde::Serialize;
use crate::calendars::{Date, Weekday, MONTHS};
use crate::fasting::{Fast, Level, Obligation};
use crate::jurisdictions::Jurisdiction;
use crate::menologion::{self, Menologion, Rank};
use crate::paschalion::{movable_cycle, Movables};
use crate::rules::{self, Context};
use crate::seasons::season_for;
#[derive(Clone, Debug)]
pub struct Day {
pub date: Date,
pub weekday: Weekday,
pub jurisdiction: &'static str,
pub calendar: &'static str,
/// The ecclesiastical date, as "25 December".
pub church_date: String,
pub season: Option<crate::seasons::Season>,
pub cycle: Option<String>,
pub feast: Option<String>,
pub rank: Option<Rank>,
pub commemorations: Vec<String>,
pub fast: Level,
pub fast_label: &'static str,
pub emoji: &'static str,
pub reason: String,
pub obligation: Option<Obligation>,
pub binds: Level,
pub pascha: Date,
pub days_to_pascha: i32,
pub julian_offset: i32,
}
impl Day {
pub fn is_obligatory(&self) -> bool {
self.obligation == Some(Obligation::Obligatory)
}
/// What to put on one line: the feast if there is one, else the day's
/// principal commemoration.
pub fn title(&self) -> &str {
match &self.feast {
Some(f) => f,
None => self.commemorations.first().map(String::as_str).unwrap_or(""),
}
}
}
/// The wire format. Flat and stable, because front ends are the thing most
/// likely to be written by someone else and a schema that shifts underneath
/// them is a bad neighbour. Field names match the shape front ends already read.
#[derive(Serialize)]
#[serde(rename_all = "camelCase")]
pub struct DayJson<'a> {
pub date: String,
pub weekday: &'a str,
pub jurisdiction: &'a str,
pub calendar: &'a str,
pub church_date: &'a str,
pub season: Option<&'a str>,
pub cycle: Option<&'a str>,
pub feast: Option<&'a str>,
pub rank: Option<&'a str>,
pub title: &'a str,
pub commemorations: &'a [String],
pub fast: &'a str,
pub fast_label: &'a str,
pub emoji: &'a str,
pub reason: &'a str,
pub obligation: Option<&'a str>,
pub binds: &'a str,
pub pascha: String,
pub days_to_pascha: i32,
pub julian_offset: i32,
}
impl Day {
pub fn to_json(&self) -> DayJson<'_> {
DayJson {
date: self.date.iso(),
weekday: self.weekday.name(),
jurisdiction: self.jurisdiction,
calendar: self.calendar,
church_date: &self.church_date,
season: self.season.map(|s| s.name()),
cycle: self.cycle.as_deref(),
feast: self.feast.as_deref(),
rank: self.rank.map(|r| r.key()),
title: self.title(),
commemorations: &self.commemorations,
fast: self.fast.key(),
fast_label: self.fast_label,
emoji: self.emoji,
reason: &self.reason,
obligation: self.obligation.map(|o| match o {
Obligation::Obligatory => "obligatory",
Obligation::Customary => "customary",
}),
binds: self.binds.key(),
pascha: self.pascha.iso(),
days_to_pascha: self.days_to_pascha,
julian_offset: self.julian_offset,
}
}
}
/// Reckon one civil date in one jurisdiction.
pub fn describe(date: Date, jur: &'static Jurisdiction) -> Day {
let men = menologion::for_jurisdiction(jur.menaia);
describe_with(date, jur, &men)
}
/// As `describe`, but with a menologion the caller already built. Reckoning a
/// year one day at a time should not rebuild the overlay 365 times.
pub fn describe_with(date: Date, jur: &'static Jurisdiction, men: &Menologion) -> Day {
let rule = rules::get(jur.rule);
let (fy, fm, fd) = jur.fixed_of_civil(date);
let mv = movable_cycle(date.year(), jur.paschalion);
let apostles_end = jur.civil_of_fixed(date.year(), 6, 28);
let season = season_for(date, (fm, fd), &mv, apostles_end);
let entry = men.get(fm, fd).cloned().unwrap_or_default();
let mut commemorations = entry.commemorations;
let mut rank = entry.rank;
// When a movable feast and a fixed one land together, the higher rank is
// the day's feast and the other joins the commemorations. Letting the
// movable one win unconditionally lost the Annunciation whenever it fell in
// Great Week: the Great Feast of the Incarnation demoted to a bullet, and
// its rank overwritten with the lesser one the fasting rules then read.
let fixed_feast = rank
.filter(|r| r.names_the_day())
.and_then(|_| commemorations.first().cloned());
let movable = menologion::movable_feast_for(date, &mv);
let feast: Option<String> = match (movable, &fixed_feast) {
(Some((mov_title, mov_rank)), Some(fixed)) if rank > mov_rank => {
let mov = mov_title.to_string();
if !commemorations.contains(&mov) {
commemorations.insert(1.min(commemorations.len()), mov);
}
Some(fixed.clone())
}
(Some((mov_title, mov_rank)), fixed) => {
if mov_rank >= rank {
rank = mov_rank;
}
if let Some(f) = fixed {
if !commemorations.contains(f) {
commemorations.insert(1.min(commemorations.len()), f.clone());
}
}
Some(mov_title.to_string())
}
(None, fixed) => fixed.clone(),
};
let ctx = Context {
civil: date,
fixed: (fm, fd),
fixed_year: fy,
weekday: date.weekday(),
mv: &mv,
season,
feast: feast
.clone()
.or_else(|| commemorations.first().cloned())
.unwrap_or_default(),
rank,
jurisdiction: jur,
};
let Fast { level, reason, obligation, binds } = (rule.fast_for)(&ctx);
Day {
date,
weekday: date.weekday(),
jurisdiction: jur.key,
calendar: jur.calendar().key(),
church_date: format!("{} {}", fd, MONTHS[(fm - 1) as usize]),
season,
cycle: entry.cycle,
feast,
rank,
commemorations,
fast: level,
fast_label: (rule.vocabulary.label)(level),
emoji: (rule.vocabulary.emoji)(level),
reason,
obligation,
binds,
pascha: mv.pascha,
days_to_pascha: mv.pascha - date,
julian_offset: date.julian_offset(),
}
}
/// `days` consecutive days from `start`.
pub fn week(start: Date, days: i32, jur: &'static Jurisdiction) -> Vec<Day> {
let men = menologion::for_jurisdiction(jur.menaia);
(0..days.max(1))
.map(|n| describe_with(start + n, jur, &men))
.collect()
}
/// Every key date of a year, for checking against a published calendar.
pub fn year_of(year: i32, jur: &'static Jurisdiction) -> (Movables, [(&'static str, Date); 4]) {
let mv = movable_cycle(year, jur.paschalion);
let fixed = [
("apostles end", jur.civil_of_fixed(year, 6, 28)),
("dormition", jur.civil_of_fixed(year, 8, 15)),
("nativity", jur.civil_of_fixed(year, 12, 25)),
("theophany", jur.civil_of_fixed(year, 1, 6)),
];
(mv, fixed)
}
+130
View File
@@ -0,0 +1,130 @@
//! iCalendar, so the calendar can live where the rest of your year does.
//!
//! Each day becomes one all-day event. The summary is the feast or the
//! principal commemoration; the description carries the fast, the season and
//! the rest of the day's commemorations. Days with nothing to say, no feast and
//! no fast, are left out, because a subscribed calendar that marks all 365 days
//! is one nobody keeps.
use crate::day::Day;
use crate::fasting::{Level, Obligation};
/// A count of octets, which is what RFC 5545 measures content lines in.
///
/// A newtype because the Python this was ported from folded on *characters*
/// instead, and an emoji is four octets: lines went eight over a limit the code
/// believed it was respecting. Counting the wrong unit is not a mistake a name
/// can prevent, but it is one a type can.
#[derive(Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Debug)]
struct Octets(usize);
/// RFC 5545: a content line SHOULD NOT be longer than 75 octets, excluding the
/// line break.
const LIMIT: Octets = Octets(75);
fn octets_of(c: char) -> Octets {
Octets(c.len_utf8())
}
fn escape(text: &str) -> String {
let mut out = String::with_capacity(text.len());
for c in text.chars() {
match c {
'\\' => out.push_str(r"\\"),
';' => out.push_str(r"\;"),
',' => out.push_str(r"\,"),
'\n' => out.push_str(r"\n"),
_ => out.push(c),
}
}
out
}
/// Break a content line on octets, continued with a leading space. The split
/// never falls inside a character: the budget is spent per codepoint, so a
/// four-octet emoji moves to the next line whole.
fn fold(line: &str) -> String {
let mut out: Vec<String> = Vec::new();
let mut cur = String::new();
let mut used = Octets(0);
for c in line.chars() {
let n = octets_of(c);
if Octets(used.0 + n.0) > LIMIT {
out.push(std::mem::take(&mut cur));
cur.push(' ');
used = Octets(1);
}
cur.push(c);
used = Octets(used.0 + n.0);
}
out.push(cur);
out.join("\r\n")
}
pub fn to_ics(days: &[Day], name: &str, stamp: &str) -> String {
let mut lines: Vec<String> = vec![
"BEGIN:VCALENDAR".into(),
"VERSION:2.0".into(),
"PRODID:-//typikon//EN".into(),
"CALSCALE:GREGORIAN".into(),
"METHOD:PUBLISH".into(),
format!("X-WR-CALNAME:{}", escape(name)),
];
for d in days {
if d.feast.is_none() && d.fast == Level::None {
continue;
}
let start = d.date.iso().replace('-', "");
let end = (d.date + 1).iso().replace('-', "");
let summary = if d.emoji.is_empty() {
d.title().to_string()
} else {
format!("{} {}", d.emoji, d.title())
};
let mut body: Vec<String> = Vec::new();
if d.fast != Level::None {
body.push(if d.reason.is_empty() {
format!("Fast: {}", d.fast_label)
} else {
format!("Fast: {} ({})", d.fast_label, d.reason)
});
}
if d.obligation == Some(Obligation::Obligatory) {
body.push("Obligatory.".into());
}
if let Some(s) = d.season {
body.push(format!("Season: {}", s.name()));
}
if let Some(c) = &d.cycle {
let mut chars = c.chars();
let capped = match chars.next() {
Some(f) => f.to_uppercase().collect::<String>() + chars.as_str(),
None => String::new(),
};
body.push(capped);
}
body.push(format!("Church date: {}", d.church_date));
for c in &d.commemorations {
body.push(c.clone());
}
lines.extend([
"BEGIN:VEVENT".into(),
format!("UID:{}-{}@typikon", start, d.jurisdiction),
format!("DTSTAMP:{stamp}"),
format!("DTSTART;VALUE=DATE:{start}"),
format!("DTEND;VALUE=DATE:{end}"),
fold(&format!("SUMMARY:{}", escape(&summary))),
fold(&format!("DESCRIPTION:{}", escape(&body.join("\n")))),
"TRANSP:TRANSPARENT".into(),
"END:VEVENT".into(),
]);
}
lines.push("END:VCALENDAR".into());
let mut s = lines.join("\r\n");
s.push_str("\r\n");
s
}
+38
View File
@@ -0,0 +1,38 @@
//! JSON, which is how every front end reads this.
//!
//! The shape is flat and stable: front ends are the thing most likely to be
//! written by someone else, and a schema that shifts underneath them is a bad
//! neighbour. One day is an object; several are `{"days": [...]}`.
use serde::Serialize;
use crate::day::{Day, DayJson};
#[derive(Serialize)]
struct Days<'a> {
days: Vec<DayJson<'a>>,
}
pub fn to_json(days: &[Day], indent: Option<usize>) -> String {
match days {
[one] => encode(&one.to_json(), indent),
many => encode(
&Days { days: many.iter().map(Day::to_json).collect() },
indent,
),
}
}
fn encode<T: Serialize>(value: &T, indent: Option<usize>) -> String {
match indent {
None => serde_json::to_string(value).expect("a day serialises"),
Some(n) => {
let pad = vec![b' '; n];
let fmt = serde_json::ser::PrettyFormatter::with_indent(&pad);
let mut buf = Vec::new();
let mut ser = serde_json::Serializer::with_formatter(&mut buf, fmt);
value.serialize(&mut ser).expect("a day serialises");
String::from_utf8(buf).expect("serde emits utf-8")
}
}
}
+5
View File
@@ -0,0 +1,5 @@
pub mod ics;
pub mod json;
pub use ics::to_ics;
pub use json::to_json;
+134
View File
@@ -0,0 +1,134 @@
//! What is kept on a day, and whether anything about it binds.
//!
//! Six levels, ordered from strictest to laxest. They are the Typikon's own
//! distinctions, not a simplification of them: the difference between a day of
//! xerophagy and a day of wine and oil is the difference the books actually
//! make, and a calendar that reports both as "fasting" has thrown away the
//! information its reader wanted.
//!
//! The same six serve a tradition that makes fewer distinctions. Ruthenian
//! particular law knows three degrees, and each maps exactly onto one of these:
//! its "strict abstinence" is `WineOil`, because it does not regulate oil or
//! wine, and its "abstinence" is `Dairy`, meat alone being set aside. So a
//! ruleset emits the subset it uses and names those levels in its own
//! vocabulary, rather than every tradition being forced through one set of
//! words.
//!
//! **Level and obligation are separate questions.** A day can be kept strictly
//! by universal custom and bind no one, and that is the ordinary case in the
//! Eastern Catholic churches, whose particular law requires very little.
use serde::Serialize;
/// Ordered strictest first, so `a < b` reads as "a is stricter than b" and the
/// derived `Ord` is the comparison the rules want.
#[derive(Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Debug, Serialize)]
#[serde(rename_all = "snake_case")]
pub enum Level {
/// No food until evening.
Strict,
/// No oil, no wine; no meat, fish, eggs or dairy.
Xerophagy,
/// Wine and oil permitted; no meat, fish, eggs or dairy.
WineOil,
/// Fish, wine and oil permitted; no meat, eggs or dairy.
Fish,
/// Dairy, eggs and fish permitted; no meat.
Dairy,
/// No fast.
None,
}
impl Level {
pub fn key(self) -> &'static str {
match self {
Level::Strict => "strict",
Level::Xerophagy => "xerophagy",
Level::WineOil => "wine_oil",
Level::Fish => "fish",
Level::Dairy => "dairy",
Level::None => "none",
}
}
pub fn is_fast(self) -> bool {
self != Level::None
}
/// The stricter of two levels.
pub fn stricter_of(self, other: Level) -> Level {
self.min(other)
}
/// The laxer of two levels.
pub fn laxer_of(self, other: Level) -> Level {
self.max(other)
}
/// Relax towards `target`, but never past `cap` and never tightening.
///
/// This is the arithmetic every "a feast lifts the fast" rule wants, in one
/// place, so no rule can get the direction backwards.
pub fn relaxed_to(self, target: Level, cap: Level) -> Level {
let lifted = self.laxer_of(target).stricter_of(cap);
if lifted < self {
self
} else {
lifted
}
}
}
/// Whether anything about the day actually binds. The Orthodox Church keeps the
/// fast as tradition rather than precept, so its rule reports `Customary`
/// throughout; the Eastern Catholic churches have law, and theirs does not.
#[derive(Clone, Copy, PartialEq, Eq, Debug, Serialize)]
#[serde(rename_all = "lowercase")]
pub enum Obligation {
Obligatory,
Customary,
}
/// How a day is kept, and what about it binds.
#[derive(Clone, Debug)]
pub struct Fast {
/// The day as the tradition keeps it.
pub level: Level,
/// The rule that decided it, so the answer can be argued with.
pub reason: String,
pub obligation: Option<Obligation>,
/// The binding minimum, where one exists. `None` for most jurisdictions,
/// which is the honest answer rather than a softening.
pub binds: Level,
}
impl Fast {
pub fn new(level: Level, reason: &str, obligation: Option<Obligation>) -> Fast {
Fast {
level,
reason: reason.to_string(),
obligation,
binds: Level::None,
}
}
/// A day kept by custom, binding no one.
pub fn customary(level: Level, reason: &str) -> Fast {
Fast::new(level, reason, Some(Obligation::Customary))
}
/// A day with no fast at all.
pub fn free(reason: &str) -> Fast {
Fast::new(Level::None, reason, None)
}
/// A day that binds, and the minimum it binds to.
pub fn binding(level: Level, reason: &str, binds: Level) -> Fast {
Fast {
level,
reason: reason.to_string(),
obligation: Some(Obligation::Obligatory),
binds,
}
}
}
+170
View File
@@ -0,0 +1,170 @@
//! Who keeps which calendar, and by whose rule.
//!
//! A jurisdiction is four independent choices, and conflating any two of them
//! is how calendar code usually goes wrong:
//!
//! - **paschalion**: which table gives Pascha
//! - **menologion**: which calendar the fixed feasts are read on
//! - **rule**: which fasting discipline is kept
//! - **menaia**: which supplementary commemorations to load, if any
//!
//! The pairings are not free. Almost every Orthodox Church keeps the Julian
//! Paschalion; what divides them is the menologion. The "old calendar" churches
//! read the fixed feasts on the Julian calendar too, the "new calendar"
//! churches on the Revised Julian, which agrees with the Gregorian for every
//! date until 2800. So a new-calendar church keeps the Nativity with the West
//! and Pascha with the East, which is not a compromise but exactly what the
//! 1923 congress decided.
//!
//! Only two bodies here use the Gregorian Paschalion: the Finnish Orthodox
//! Church, alone among the Orthodox, and the Byzantine Catholic churches.
use crate::calendars::Date;
use crate::paschalion::Paschalion;
/// Which calendar the *fixed* feasts are read on.
#[derive(Clone, Copy, PartialEq, Eq, Debug)]
pub enum MenologionKind {
Julian,
/// Revised Julian, which agrees with the Gregorian for every date until 2800.
RevisedJulian,
}
#[derive(Clone, Copy, PartialEq, Eq, Debug)]
pub enum RuleKind {
Orthodox,
Ruthenian,
}
/// The short name people use for a paschalion/menologion combination.
#[derive(Clone, Copy, PartialEq, Eq, Debug)]
pub enum Calendar {
Old,
New,
Western,
}
impl Calendar {
pub fn key(self) -> &'static str {
match self {
Calendar::Old => "old",
Calendar::New => "new",
Calendar::Western => "western",
}
}
}
#[derive(Clone, Copy, Debug)]
pub struct Jurisdiction {
pub key: &'static str,
pub name: &'static str,
pub paschalion: Paschalion,
pub menologion: MenologionKind,
pub rule: RuleKind,
/// Supplementary commemoration sets to overlay, fetched not shipped.
pub menaia: &'static [&'static str],
pub note: &'static str,
}
impl Jurisdiction {
pub fn calendar(&self) -> Calendar {
match (self.paschalion, self.menologion) {
(Paschalion::Gregorian, MenologionKind::RevisedJulian) => Calendar::Western,
(_, MenologionKind::Julian) => Calendar::Old,
_ => Calendar::New,
}
}
/// The ecclesiastical (year, month, day) a civil date is reckoned as.
pub fn fixed_of_civil(&self, date: Date) -> (i32, u32, u32) {
match self.menologion {
MenologionKind::Julian => {
let j = date.to_julian();
(j.year, j.month, j.day)
}
MenologionKind::RevisedJulian => date.ymd(),
}
}
/// The civil date of an ecclesiastical date in a given church year.
pub fn civil_of_fixed(&self, year: i32, month: u32, day: u32) -> Date {
match self.menologion {
MenologionKind::Julian => {
crate::calendars::JulianDate { year, month, day }.to_civil()
}
MenologionKind::RevisedJulian => Date::from_ymd(year, month, day)
.unwrap_or_else(|| Date::from_ymd(year, month, 28).expect("28th exists")),
}
}
}
use MenologionKind::{Julian as JulMen, RevisedJulian as RevJul};
use Paschalion::{Gregorian as GregP, Julian as JulP};
use RuleKind::{Orthodox as Orth, Ruthenian as Ruth};
const NO_MENAIA: &[&str] = &[];
const RUTHENIAN_MENAIA: &[&str] = &["ruthenian"];
/// A reckoning without naming a church. `new` is the default.
pub const CALENDARS: &[Jurisdiction] = &[
Jurisdiction { key: "old", name: "Old Calendar", paschalion: JulP, menologion: JulMen, rule: Orth, menaia: NO_MENAIA,
note: "Julian throughout, the reckoning of Moscow, Serbia, Georgia, Jerusalem and the Holy Mountain." },
Jurisdiction { key: "new", name: "New Calendar", paschalion: JulP, menologion: RevJul, rule: Orth, menaia: NO_MENAIA,
note: "Revised Julian fixed feasts with the Julian Paschalion: Constantinople, Greece, Antioch, Romania, Bulgaria, the OCA." },
Jurisdiction { key: "western", name: "Gregorian", paschalion: GregP, menologion: RevJul, rule: Orth, menaia: NO_MENAIA,
note: "Gregorian throughout, Pascha included. Kept by the Finnish Orthodox Church and by most Byzantine Catholics." },
];
pub const JURISDICTIONS: &[Jurisdiction] = &[
// old calendar
Jurisdiction { key: "moscow", name: "Russian Orthodox Church", paschalion: JulP, menologion: JulMen, rule: Orth, menaia: NO_MENAIA, note: "" },
Jurisdiction { key: "rocor", name: "Russian Orthodox Church Outside Russia", paschalion: JulP, menologion: JulMen, rule: Orth, menaia: NO_MENAIA, note: "" },
Jurisdiction { key: "serbian", name: "Serbian Orthodox Church", paschalion: JulP, menologion: JulMen, rule: Orth, menaia: NO_MENAIA, note: "" },
Jurisdiction { key: "georgian", name: "Georgian Orthodox Church", paschalion: JulP, menologion: JulMen, rule: Orth, menaia: NO_MENAIA, note: "" },
Jurisdiction { key: "jerusalem", name: "Patriarchate of Jerusalem", paschalion: JulP, menologion: JulMen, rule: Orth, menaia: NO_MENAIA, note: "" },
Jurisdiction { key: "athos", name: "The Holy Mountain", paschalion: JulP, menologion: JulMen, rule: Orth, menaia: NO_MENAIA,
note: "Athonite practice is stricter than the printed Typikon at many points." },
Jurisdiction { key: "polish", name: "Polish Orthodox Church", paschalion: JulP, menologion: JulMen, rule: Orth, menaia: NO_MENAIA,
note: "Returned to the Julian menologion in 2014 after decades on the Revised Julian; some parishes kept the new calendar." },
Jurisdiction { key: "macedonian", name: "Macedonian Orthodox Church", paschalion: JulP, menologion: JulMen, rule: Orth, menaia: NO_MENAIA, note: "" },
// new calendar
Jurisdiction { key: "constantinople", name: "Ecumenical Patriarchate", paschalion: JulP, menologion: RevJul, rule: Orth, menaia: NO_MENAIA, note: "" },
Jurisdiction { key: "goarch", name: "Greek Orthodox Archdiocese of America", paschalion: JulP, menologion: RevJul, rule: Orth, menaia: NO_MENAIA, note: "" },
Jurisdiction { key: "greece", name: "Church of Greece", paschalion: JulP, menologion: RevJul, rule: Orth, menaia: NO_MENAIA, note: "" },
Jurisdiction { key: "antiochian", name: "Antiochian Orthodox Church", paschalion: JulP, menologion: RevJul, rule: Orth, menaia: NO_MENAIA, note: "" },
Jurisdiction { key: "oca", name: "Orthodox Church in America", paschalion: JulP, menologion: RevJul, rule: Orth, menaia: NO_MENAIA,
note: "Most parishes keep the new calendar; a minority, chiefly of Carpatho-Russian and Alaskan background, keep the old." },
Jurisdiction { key: "romanian", name: "Romanian Orthodox Church", paschalion: JulP, menologion: RevJul, rule: Orth, menaia: NO_MENAIA, note: "" },
Jurisdiction { key: "bulgarian", name: "Bulgarian Orthodox Church", paschalion: JulP, menologion: RevJul, rule: Orth, menaia: NO_MENAIA, note: "" },
Jurisdiction { key: "cyprus", name: "Church of Cyprus", paschalion: JulP, menologion: RevJul, rule: Orth, menaia: NO_MENAIA, note: "" },
Jurisdiction { key: "albanian", name: "Orthodox Church of Albania", paschalion: JulP, menologion: RevJul, rule: Orth, menaia: NO_MENAIA, note: "" },
Jurisdiction { key: "ukrainian", name: "Orthodox Church of Ukraine", paschalion: JulP, menologion: RevJul, rule: Orth, menaia: NO_MENAIA,
note: "Moved the fixed feasts to the Revised Julian calendar on 1 September 2023. The Julian Paschalion is retained." },
// gregorian throughout
Jurisdiction { key: "finnish", name: "Finnish Orthodox Church", paschalion: GregP, menologion: RevJul, rule: Orth, menaia: NO_MENAIA,
note: "The only Orthodox Church keeping the Gregorian Paschalion." },
// eastern catholic
Jurisdiction { key: "ruthenian", name: "Byzantine Catholic Metropolia of Pittsburgh", paschalion: GregP, menologion: RevJul, rule: Ruth, menaia: RUTHENIAN_MENAIA,
note: "Gregorian throughout. Fasting is reported under Ruthenian particular law, which binds far less than the Typikon prescribes." },
Jurisdiction { key: "ugcc", name: "Ukrainian Greek Catholic Church", paschalion: JulP, menologion: RevJul, rule: Ruth, menaia: RUTHENIAN_MENAIA,
note: "Moved the fixed feasts to the Revised Julian calendar on 1 September 2023, keeping the Julian Paschalion. Eparchies outside Ukraine vary; several keep the Gregorian Paschalion." },
Jurisdiction { key: "melkite", name: "Melkite Greek Catholic Church", paschalion: GregP, menologion: RevJul, rule: Ruth, menaia: NO_MENAIA,
note: "Practice is not uniform: parishes in the Middle East commonly keep the Julian Paschalion for the sake of the local Orthodox." },
Jurisdiction { key: "romanian-catholic", name: "Romanian Greek Catholic Church", paschalion: GregP, menologion: RevJul, rule: Ruth, menaia: NO_MENAIA, note: "" },
];
/// A jurisdiction key, a calendar name, or `None` for the default.
pub fn resolve(name: Option<&str>) -> Result<&'static Jurisdiction, String> {
let Some(name) = name else {
return Ok(&CALENDARS[1]); // new
};
let key = name.trim().to_ascii_lowercase();
JURISDICTIONS
.iter()
.chain(CALENDARS.iter())
.find(|j| j.key == key)
.ok_or_else(|| name.to_string())
}
+11
View File
@@ -0,0 +1,11 @@
pub mod calendars;
pub mod config;
pub mod day;
pub mod export;
pub mod fasting;
pub mod jurisdictions;
pub mod menaia;
pub mod menologion;
pub mod paschalion;
pub mod rules;
pub mod seasons;
+350
View File
@@ -0,0 +1,350 @@
//! The command line.
use clap::Parser;
use typikon::calendars::Date;
use typikon::config::{self, Source};
use typikon::day::{week, year_of, Day};
use typikon::export::{to_ics, to_json};
use typikon::jurisdictions::{self, Jurisdiction, CALENDARS, JURISDICTIONS};
use typikon::menaia;
use typikon::rules;
#[derive(Parser)]
#[command(
name = "typikon",
version,
about = "The Orthodox liturgical day: feasts, seasons and the fast."
)]
struct Args {
/// A church or a reckoning; see --list
#[arg(short = 'j', long, visible_alias = "calendar", short_alias = 'c')]
jurisdiction: Option<String>,
/// YYYY-MM-DD (default: today)
#[arg(short, long)]
date: Option<String>,
/// How many days from the start date, inclusive
#[arg(short = 'n', long, default_value_t = 1)]
days: i32,
/// Machine-readable output
#[arg(long)]
json: bool,
/// An iCalendar feed
#[arg(long)]
ics: bool,
/// Indent JSON output
#[arg(long)]
indent: Option<usize>,
/// Print a year's key dates and exit
#[arg(long)]
year: Option<i32>,
/// List every jurisdiction and calendar
#[arg(long)]
list: bool,
/// Remember a jurisdiction and exit
#[arg(long, value_name = "JURISDICTION")]
set_default: Option<String>,
/// Fetch a supplementary commemoration set
#[arg(long, value_name = "NAME")]
update_menaion: Option<String>,
/// List the supplementary sets that can be fetched
#[arg(long)]
list_menaia: bool,
#[arg(short, long)]
verbose: bool,
}
fn wrap(text: &str, width: usize) -> Vec<String> {
let mut out = Vec::new();
let mut line = String::new();
for word in text.split_whitespace() {
if !line.is_empty() && line.chars().count() + word.chars().count() + 1 > width {
out.push(std::mem::take(&mut line));
}
if !line.is_empty() {
line.push(' ');
}
line.push_str(word);
}
if !line.is_empty() {
out.push(line);
}
out
}
fn list_jurisdictions() {
println!("\n Calendars, a reckoning without naming a church:\n");
for j in CALENDARS {
println!(" {:20} {}", j.key, j.name);
for line in wrap(j.note, 56) {
println!(" {:20} {}", "", line);
}
}
println!("\n Jurisdictions:\n");
let width = JURISDICTIONS.iter().map(|j| j.key.len()).max().unwrap_or(12);
for j in JURISDICTIONS {
let cal = match j.calendar() {
jurisdictions::Calendar::Old => "old calendar",
jurisdictions::Calendar::New => "new calendar",
jurisdictions::Calendar::Western => "gregorian",
};
let rule = rules::get(j.rule).name;
println!(" {:width$} {}", j.key, j.name);
println!(" {:width$} {cal}; {rule} fasting rule", "");
for line in wrap(j.note, 66) {
println!(" {:width$} {line}", "");
}
println!();
}
}
fn elide(text: &str, width: usize) -> String {
if text.chars().count() <= width {
return text.to_string();
}
let cut: String = text.chars().take(width - 1).collect();
let head = match cut[cut.len() / 2..].find(' ') {
Some(_) => match cut.trim_end().rfind(' ') {
Some(i) => &cut[..i],
None => &cut,
},
None => &cut,
};
format!("{}\u{2026}", head.trim_end_matches([' ', ',', ';']))
}
fn print_days(days: &[Day], jur: &Jurisdiction, source: Source, verbose: bool) {
let rule = rules::get(jur.rule);
if let [d] = days {
let mut head = format!(" {} {}", d.weekday.name(), d.date.long());
let civil_md = format!("{} {}", d.date.day(), typikon::calendars::MONTHS[(d.date.month() - 1) as usize]);
if d.church_date != civil_md {
head.push_str(&format!(" \u{2014} {} in the church calendar", d.church_date));
}
println!("\n{head}");
let from = match source {
Source::Flag => String::new(),
Source::Environment => " (from the environment)".into(),
Source::Config => " (from config)".into(),
Source::Default => String::new(),
};
println!(" {} \u{b7} {}{from}", jur.name, rule.title);
println!();
println!(" {} {}", d.emoji, d.title());
if let Some(c) = &d.cycle {
println!(" {c}");
}
println!();
let mark = if d.is_obligatory() { " \u{2014} obligatory" } else { "" };
println!(" {}{mark}", d.fast_label);
if !d.reason.is_empty() {
println!(" {}", d.reason);
}
if let Some(s) = d.season {
println!(" {}", s.name());
}
let rest: Vec<_> = d.commemorations.iter().filter(|c| c.as_str() != d.title()).collect();
if !rest.is_empty() {
println!();
for c in rest {
println!(" \u{b7} {c}");
}
}
if verbose {
println!();
println!(" Pascha {} \u{b7} {} days", d.pascha, d.days_to_pascha);
}
println!();
return;
}
println!("\n {} \u{b7} {}\n", jur.name, rule.title);
// The glyph is two columns wide in a terminal and one char to Rust, which
// is why the arithmetic below looks off by one.
let fixed = 2 + 2 + 10 + 1 + 1 + 32;
let cols = std::env::var("COLUMNS").ok().and_then(|c| c.parse().ok()).unwrap_or(100usize);
let room = cols.saturating_sub(fixed + 1).max(24);
for d in days {
let mark = if d.is_obligatory() { "*" } else { " " };
println!(
" {} {:>10}{mark} {:<32}{}",
d.emoji,
d.date.short(),
d.fast_label,
elide(d.title(), room)
);
}
if days.iter().any(Day::is_obligatory) {
println!("\n * obligatory. Everything unmarked is customary and does not bind.");
}
println!();
}
fn run() -> Result<i32, String> {
let args = Args::parse();
if args.list {
list_jurisdictions();
return Ok(0);
}
if args.list_menaia {
println!("\n Supplementary commemorations, fetched on request:\n");
for s in menaia::SOURCES {
println!(" {:14} {}", s.key, s.title);
println!(" {:14} {}", "", s.url);
for line in wrap(s.note, 62) {
println!(" {:14} {line}", "");
}
println!();
}
println!(" They are written to {}\n", typikon::menologion::data_dir().display());
return Ok(0);
}
if let Some(name) = &args.update_menaion {
let f = menaia::update(name)?;
println!(" {} days written to {}", f.days, f.path.display());
if let Some(s) = menaia::find(name) {
println!(" Source: {}", s.title);
}
if !f.corrections.is_empty() {
println!(" source typos corrected: {}", f.corrections.join(", "));
}
if !f.missing.is_empty() {
println!(" MISSING: {}", f.missing.join(", "));
return Ok(1);
}
return Ok(0);
}
if let Some(name) = &args.set_default {
let jur = jurisdictions::resolve(Some(name))
.map_err(|n| format!("unknown jurisdiction {n:?}; try --list"))?;
let where_ = config::write(jur.key).map_err(|e| e.to_string())?;
println!(" {} \u{2014} written to {}", jur.name, where_.display());
return Ok(0);
}
let (name, source) = match &args.jurisdiction {
Some(j) => (Some(j.clone()), Source::Flag),
None => config::default_jurisdiction(),
};
let jur = jurisdictions::resolve(name.as_deref())
.map_err(|n| format!("unknown jurisdiction {n:?}; try --list"))?;
if let Some(year) = args.year {
if !(1..=9999).contains(&year) {
return Err(format!("{year} is not a year this calendar reaches"));
}
let (mv, fixed) = year_of(year, jur);
println!("\n {year} \u{b7} {}\n", jur.name);
for (label, d) in [
("publican pharisee", mv.publican_pharisee),
("meatfare", mv.meatfare),
("cheesefare", mv.cheesefare),
("clean monday", mv.clean_monday),
("great fast end", mv.great_fast_end),
("lazarus saturday", mv.lazarus_saturday),
("palm sunday", mv.palm_sunday),
("great friday", mv.great_friday),
("pascha", mv.pascha),
("ascension", mv.ascension),
("pentecost", mv.pentecost),
("all saints", mv.all_saints),
("apostles start", mv.apostles_start),
] {
println!(" {label:20} {d} {}", d.weekday().short());
}
for (label, d) in fixed {
println!(" {label:20} {d} {}", d.weekday().short());
}
println!();
return Ok(0);
}
let start = match &args.date {
Some(s) => Date::parse_iso(s)
.ok_or_else(|| format!("{s:?} is not a date \u{2014} expected YYYY-MM-DD"))?,
None => today(),
};
let days = week(start, args.days, jur);
if args.json {
println!("{}", to_json(&days, args.indent));
return Ok(0);
}
if args.ics {
print!("{}", to_ics(&days, &format!("{} \u{2014} Typikon", jur.name), &stamp()));
return Ok(0);
}
print_days(&days, jur, source, args.verbose);
// The first run should say what it assumed, and then stop saying it.
if source == Source::Default {
if let Ok(p) = config::write(jur.key) {
println!(" Reckoning by the {}, written to {}.", jur.name.to_lowercase(), p.display());
println!(" Change it with `typikon --set-default <jurisdiction>`, or see `typikon --list`.\n");
}
}
Ok(0)
}
/// Today, from the system clock, without pulling in a date library for it.
fn today() -> Date {
let secs = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.map(|d| d.as_secs() as i64)
.unwrap_or(0);
// Local midnight, via the offset the system reports for now.
let local = secs + local_offset_secs(secs);
Date(2440588 + (local.div_euclid(86400)) as i32)
}
/// Seconds east of UTC, read from /etc/localtime through the `date` command
/// only if TZ arithmetic is needed. Falls back to UTC.
fn local_offset_secs(_now: i64) -> i64 {
if let Ok(out) = std::process::Command::new("date").arg("+%z").output() {
let s = String::from_utf8_lossy(&out.stdout);
let s = s.trim();
if s.len() == 5 {
if let (Ok(h), Ok(m)) = (s[1..3].parse::<i64>(), s[3..5].parse::<i64>()) {
let mag = h * 3600 + m * 60;
return if s.starts_with('-') { -mag } else { mag };
}
}
}
0
}
fn stamp() -> String {
let out = std::process::Command::new("date")
.args(["-u", "+%Y%m%dT%H%M%SZ"])
.output();
match out {
Ok(o) if o.status.success() => String::from_utf8_lossy(&o.stdout).trim().to_string(),
_ => "19700101T000000Z".into(),
}
}
fn main() {
match run() {
Ok(code) => std::process::exit(code),
Err(msg) => {
eprintln!("typikon: {msg}");
std::process::exit(2);
}
}
}
+272
View File
@@ -0,0 +1,272 @@
//! Supplementary commemorations, fetched rather than shipped.
//!
//! The menologion compiled into this binary is the common Byzantine one,
//! composed for this project and licensed with the rest of it. Some churches
//! also keep their own saints, and those lists are usually published by a body
//! that holds the copyright in the wording. Redistributing them would be taking
//! someone else's work; fetching them onto your own machine, for your own use,
//! is not.
//!
//! So supplementary sets are **not** in the binary. `typikon --update-menaion
//! ruthenian` fetches one into your data directory, where the menologion loader
//! picks it up automatically and overlays it on the common set.
//!
//! The fetch shells out to `curl`, deliberately. Linking an HTTP stack and a
//! TLS stack into a calendar would be several megabytes and a standing audit
//! surface, for one command most people run once.
use std::process::Command;
pub struct Source {
pub key: &'static str,
pub title: &'static str,
pub url: &'static str,
pub note: &'static str,
}
pub const SOURCES: &[Source] = &[Source {
key: "ruthenian",
title: "Byzantine Catholic Metropolia of Pittsburgh",
url: "https://mci.archpitt.org/liturgy/Calendar_of_Saints.html",
note: "Published by the Metropolitan Cantor Institute. Fetched for your own \
use; not redistributed with this program.",
}];
const MONTHS: [&str; 12] = [
"January", "February", "March", "April", "May", "June", "July", "August",
"September", "October", "November", "December",
];
pub fn find(name: &str) -> Option<&'static Source> {
SOURCES.iter().find(|s| s.key == name)
}
fn fetch(url: &str) -> Result<String, String> {
let out = Command::new("curl")
.args(["-sSL", "--max-time", "30", "-A", "typikon", url])
.output()
.map_err(|e| match e.kind() {
std::io::ErrorKind::NotFound => {
"curl is not on PATH, and the fetch needs it".to_string()
}
_ => e.to_string(),
})?;
if !out.status.success() {
return Err(String::from_utf8_lossy(&out.stderr).trim().to_string());
}
Ok(String::from_utf8_lossy(&out.stdout).into_owned())
}
fn strip_html(html: &str) -> Vec<String> {
let mut text = String::with_capacity(html.len());
let mut in_tag = false;
let mut tag = String::new();
for c in html.chars() {
match c {
'<' => {
in_tag = true;
tag.clear();
}
'>' => {
in_tag = false;
let t = tag.trim_start_matches('/').to_ascii_lowercase();
let t = t.split_whitespace().next().unwrap_or("");
if matches!(t, "br" | "p" | "div" | "li" | "h1" | "h2" | "h3" | "h4") {
text.push('\n');
}
text.push('\n');
}
_ if in_tag => tag.push(c),
_ => text.push(c),
}
}
for (from, to) in [
("&nbsp;", " "), ("&amp;", "&"), ("&#8217;", "'"),
("&rsquo;", "'"), ("&mdash;", "\u{2014}"), ("&quot;", "\""),
] {
text = text.replace(from, to);
}
text.lines()
.map(|l| l.split_whitespace().collect::<Vec<_>>().join(" "))
.filter(|l| !l.is_empty())
.collect()
}
fn month_index(word: &str) -> Option<u32> {
MONTHS.iter().position(|m| *m == word).map(|i| i as u32 + 1)
}
/// Split a line into (month, day, rest) when it opens a dated entry.
fn dated(line: &str) -> Option<(u32, u32, String)> {
let mut it = line.splitn(3, ' ');
let month = month_index(it.next()?)?;
let day: u32 = it.next()?.trim_end_matches(['.', ',']).parse().ok()?;
Some((month, day, it.next().unwrap_or("").trim().to_string()))
}
fn is_heading(line: &str) -> bool {
month_index(line.trim_end_matches('.')).is_some()
}
pub struct Fetched {
pub path: std::path::PathBuf,
pub days: usize,
pub corrections: Vec<String>,
pub missing: Vec<String>,
}
/// MCI publishes prose, not a table, so entries split on the date headings.
///
/// The page has at least one typo: 14 October is written "October 1", which
/// silently overwrites the real entry for that day and takes the Protection of
/// the Theotokos with it. A date that goes backwards inside a month is the
/// giveaway, and is corrected to follow the previous one.
pub fn update(name: &str) -> Result<Fetched, String> {
let source = find(name).ok_or_else(|| {
let known: Vec<_> = SOURCES.iter().map(|s| s.key).collect();
format!("unknown menaion: {name:?}; try one of {}", known.join(", "))
})?;
let lines = strip_html(&fetch(source.url)?);
// MCI writes the festal cycle inline, as a commemoration among the saints.
// It repeats for days at a time and is not a saint, so it becomes the day's
// cycle and leaves the list alone.
let festal = [
("pre-festive", "forefeast"), ("fore-feast", "forefeast"),
("post-festive", "afterfeast"), ("after-feast", "afterfeast"),
("leave-taking", "leave-taking"),
];
let mut out: std::collections::BTreeMap<String, serde_json::Value> = Default::default();
let mut corrections = Vec::new();
let mut cur: Option<(u32, u32)> = None;
let mut buf: Vec<String> = Vec::new();
let mut prev: Option<(u32, u32)> = None;
let flush = |cur: &Option<(u32, u32)>, buf: &mut Vec<String>,
out: &mut std::collections::BTreeMap<String, serde_json::Value>| {
let Some((m, d)) = *cur else { buf.clear(); return };
let mut items: Vec<String> = Vec::new();
let mut cycle: Option<String> = None;
for piece in buf.iter() {
for part in split_sentences(piece) {
let part = part.trim_matches([' ', '.', ';']).to_string();
if part.len() <= 3 || dated(&part).is_some() || is_heading(&part) {
continue;
}
let lower = part.to_ascii_lowercase();
if let Some((_, word)) = festal.iter().find(|(p, _)| lower.starts_with(p)) {
if cycle.is_none() {
let rest = part
.split_once(' ')
.map(|(_, tail)| tail)
.unwrap_or("")
.trim_start_matches("Day ")
.trim_start_matches("of ")
.trim_matches([' ', '.', ','])
.to_string();
cycle = Some(if rest.is_empty() {
word.to_string()
} else {
format!("{word} of {rest}")
});
}
continue;
}
items.push(part);
}
}
buf.clear();
if items.is_empty() && cycle.is_none() {
return;
}
let mut entry = serde_json::Map::new();
if !items.is_empty() {
entry.insert("commemorations".into(), serde_json::json!(items));
}
if let Some(c) = cycle {
entry.insert("cycle".into(), serde_json::json!(c));
}
out.insert(format!("{m:02}-{d:02}"), serde_json::Value::Object(entry));
};
for line in &lines {
if is_heading(line) {
continue;
}
if let Some((month, mut day, rest)) = dated(line) {
flush(&cur, &mut buf, &mut out);
if let Some((pm, pd)) = prev {
if pm == month && day <= pd {
corrections.push(format!("{month:02}-{day:02} -> {month:02}-{:02}", pd + 1));
day = pd + 1;
}
}
prev = Some((month, day));
cur = Some((month, day));
if !rest.is_empty() {
buf.push(rest);
}
} else if cur.is_some() {
buf.push(line.clone());
}
}
flush(&cur, &mut buf, &mut out);
let mut missing = Vec::new();
for m in 1..=12u32 {
for d in 1..=crate::calendars::days_in_month(2024, m) {
let key = format!("{m:02}-{d:02}");
if !out.contains_key(&key) {
missing.push(key);
}
}
}
// This is a complete calendar published by that church, so it stands in for
// the common commemorations rather than piling on top of them.
let mut payload = serde_json::Map::new();
payload.insert(
"_meta".into(),
serde_json::json!({"mode": "replace", "source": source.url, "title": source.title}),
);
for (k, v) in &out {
payload.insert(k.clone(), v.clone());
}
let dir = crate::menologion::data_dir();
std::fs::create_dir_all(&dir).map_err(|e| e.to_string())?;
let path = dir.join(format!("{name}.json"));
let text = serde_json::to_string_pretty(&serde_json::Value::Object(payload))
.map_err(|e| e.to_string())?;
std::fs::write(&path, text).map_err(|e| format!("{}: {e}", path.display()))?;
Ok(Fetched { path, days: out.len(), corrections, missing })
}
/// Split on sentence ends that begin a new capitalised clause, the way the MCI
/// page separates commemorations inside one paragraph.
fn split_sentences(text: &str) -> Vec<String> {
let chars: Vec<char> = text.chars().collect();
let mut parts = Vec::new();
let mut start = 0usize;
let mut i = 0usize;
while i + 2 < chars.len() {
if chars[i] == '.' && chars[i + 1] == ' ' {
let mut j = i + 1;
while j < chars.len() && chars[j] == ' ' {
j += 1;
}
if j < chars.len() && (chars[j].is_ascii_uppercase() || chars[j] == '(') {
parts.push(chars[start..=i].iter().collect());
start = j;
i = j;
continue;
}
}
i += 1;
}
parts.push(chars[start..].iter().collect());
parts
}
+215
View File
@@ -0,0 +1,215 @@
//! The fixed calendar, and the movable feasts that outrank it.
//!
//! The menologion ships once, compiled into the binary, and serves every
//! jurisdiction because it is indexed by **ecclesiastical** date. A church on
//! the old calendar and one on the new both keep the Nativity on 25 December;
//! they disagree about which morning that is, and that disagreement lives in
//! the calendar conversion, not here.
//!
//! Supplementary sets, a local church's own saints, are fetched rather than
//! shipped, because they are usually published by a body that holds the
//! copyright in the wording. They overlay the common set at runtime.
use std::collections::BTreeMap;
use serde::Deserialize;
use crate::calendars::Date;
use crate::paschalion::Movables;
/// The degrees of solemnity the menologion marks, and the order they rank in.
#[derive(Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Debug, Deserialize)]
#[serde(rename_all = "lowercase")]
pub enum Rank {
Polyeleos,
Vigil,
Great,
}
impl Rank {
pub fn key(self) -> &'static str {
match self {
Rank::Polyeleos => "polyeleos",
Rank::Vigil => "vigil",
Rank::Great => "great",
}
}
/// Whether a feast of this rank names the day, as against merely being
/// commemorated on it.
pub fn names_the_day(self) -> bool {
matches!(self, Rank::Great | Rank::Vigil)
}
}
#[derive(Clone, Debug, Default, Deserialize)]
pub struct Entry {
#[serde(default)]
pub commemorations: Vec<String>,
#[serde(default)]
pub rank: Option<Rank>,
/// Forefeast, afterfeast or leave-taking.
#[serde(default)]
pub cycle: Option<String>,
}
#[derive(Clone, Copy, PartialEq, Eq, Debug, Default, Deserialize)]
#[serde(rename_all = "lowercase")]
pub enum OverlayMode {
/// The set's commemorations join the common ones. Right for a local set
/// that adds a few saints of its own.
#[default]
Add,
/// The set's commemorations stand in for them, because it is itself a
/// complete calendar published by that church.
Replace,
}
#[derive(Clone, Debug, Default, Deserialize)]
struct Meta {
#[serde(default)]
mode: OverlayMode,
}
#[derive(Clone, Debug, Default)]
pub struct Menologion {
entries: BTreeMap<String, Entry>,
mode: OverlayMode,
}
/// The common Byzantine menologion, compiled in. No file to find, no path to
/// get wrong, and the binary answers for every day of the year on its own.
const COMMON: &str = include_str!("../data/menologion.json");
fn parse(text: &str) -> Result<Menologion, String> {
let raw: BTreeMap<String, serde_json::Value> =
serde_json::from_str(text).map_err(|e| e.to_string())?;
let mut entries = BTreeMap::new();
let mut mode = OverlayMode::Add;
for (key, value) in raw {
if key == "_meta" {
let meta: Meta = serde_json::from_value(value).unwrap_or_default();
mode = meta.mode;
continue;
}
let entry: Entry = serde_json::from_value(value).map_err(|e| format!("{key}: {e}"))?;
entries.insert(key, entry);
}
Ok(Menologion { entries, mode })
}
impl Menologion {
pub fn common() -> &'static Menologion {
use std::sync::OnceLock;
static ONCE: OnceLock<Menologion> = OnceLock::new();
ONCE.get_or_init(|| parse(COMMON).expect("the shipped menologion parses"))
}
pub fn get(&self, month: u32, day: u32) -> Option<&Entry> {
self.entries.get(&format!("{month:02}-{day:02}"))
}
pub fn len(&self) -> usize {
self.entries.len()
}
pub fn is_empty(&self) -> bool {
self.entries.is_empty()
}
/// Lay another set over this one. Rank and cycle survive from the common
/// set unless the overlay gives its own, since a published list of saints
/// usually carries neither and the fasting rules need the rank.
pub fn overlay(&self, other: &Menologion) -> Menologion {
let mut merged = self.entries.clone();
for (key, add) in &other.entries {
let base = merged.entry(key.clone()).or_default();
match other.mode {
OverlayMode::Replace => {
if !add.commemorations.is_empty() {
base.commemorations = add.commemorations.clone();
}
}
OverlayMode::Add => {
for c in &add.commemorations {
if !base.commemorations.contains(c) {
base.commemorations.push(c.clone());
}
}
}
}
if add.rank.is_some() {
base.rank = add.rank;
}
if add.cycle.is_some() {
base.cycle = add.cycle.clone();
}
}
Menologion { entries: merged, mode: self.mode }
}
pub fn load_file(path: &std::path::Path) -> Result<Menologion, String> {
let text = std::fs::read_to_string(path).map_err(|e| format!("{}: {e}", path.display()))?;
parse(&text)
}
}
/// Where fetched supplementary sets live.
pub fn data_dir() -> std::path::PathBuf {
let base = std::env::var_os("XDG_DATA_HOME")
.map(std::path::PathBuf::from)
.unwrap_or_else(|| {
let home = std::env::var_os("HOME").unwrap_or_default();
std::path::Path::new(&home).join(".local/share")
});
base.join("typikon")
}
/// The common menologion with a jurisdiction's supplementary sets overlaid.
/// A missing set is not an error: it simply has not been fetched.
pub fn for_jurisdiction(menaia: &[&str]) -> Menologion {
let mut m = Menologion::common().clone();
for name in menaia {
let path = data_dir().join(format!("{name}.json"));
if let Ok(extra) = Menologion::load_file(&path) {
m = m.overlay(&extra);
}
}
m
}
/// The movable feasts, named by their offset from Pascha. These outrank
/// whatever the fixed calendar holds for the day.
pub fn movable_feast_for(civil: Date, mv: &Movables) -> Option<(&'static str, Option<Rank>)> {
let table: [(Date, &'static str, Option<Rank>); 24] = [
(mv.pascha, "The Radiant and Life-giving Resurrection of our Lord \u{2014} Holy Pascha", Some(Rank::Great)),
(mv.palm_sunday, "The Entry of our Lord into Jerusalem", Some(Rank::Great)),
(mv.ascension, "The Ascension of our Lord, God and Saviour Jesus Christ", Some(Rank::Great)),
(mv.pentecost, "Holy Pentecost \u{2014} the Descent of the Holy Spirit", Some(Rank::Great)),
(mv.lazarus_saturday, "Lazarus Saturday", Some(Rank::Vigil)),
(mv.great_thursday, "Great and Holy Thursday", Some(Rank::Vigil)),
(mv.great_friday, "Great and Holy Friday", Some(Rank::Vigil)),
(mv.holy_saturday, "Great and Holy Saturday", Some(Rank::Vigil)),
(mv.thomas_sunday, "Thomas Sunday \u{2014} the Antipascha", Some(Rank::Polyeleos)),
(mv.myrrhbearers, "The Sunday of the Holy Myrrh-bearing Women", Some(Rank::Polyeleos)),
(mv.midfeast, "The Midfeast of Pentecost", Some(Rank::Polyeleos)),
(mv.all_saints, "The Sunday of All Saints", Some(Rank::Polyeleos)),
(mv.publican_pharisee, "The Sunday of the Publican and the Pharisee", None),
(mv.prodigal_son, "The Sunday of the Prodigal Son", None),
(mv.meatfare, "Meatfare Sunday \u{2014} the Sunday of the Last Judgement", None),
(mv.cheesefare, "Cheesefare Sunday \u{2014} the Expulsion of Adam, Forgiveness Sunday", None),
(mv.orthodoxy_sunday, "The Sunday of Orthodoxy", Some(Rank::Polyeleos)),
(mv.gregory_palamas, "The Sunday of St Gregory Palamas", None),
(mv.cross_veneration, "The Sunday of the Veneration of the Cross", Some(Rank::Polyeleos)),
(mv.john_climacus, "The Sunday of St John Climacus", None),
(mv.mary_of_egypt, "The Sunday of St Mary of Egypt", None),
(mv.fathers_first_council, "The Sunday of the Fathers of the First Council", None),
(mv.clean_monday, "Clean Monday \u{2014} the first day of the Great Fast", None),
// A sentinel that can never match, keeping the array a fixed size.
(Date(i32::MIN), "", None),
];
table
.iter()
.find(|(d, _, _)| *d == civil)
.map(|(_, title, rank)| (*title, *rank))
}
+142
View File
@@ -0,0 +1,142 @@
//! Pascha, and the movable cycle that hangs off it.
//!
//! Two reckonings are in use. The **Julian Paschalion** is what nearly every
//! Orthodox Church keeps: the old computus, worked in the Julian calendar and
//! then read off onto a civil date. The **Gregorian Paschalion** is the Western
//! one, kept by the Finnish Orthodox Church and by most Byzantine Catholics.
//!
//! Neither is a variant of the other. They are separate tables that happen to
//! agree in some years, 2025 falls on 20 April by both, and can diverge by as
//! much as five weeks.
use crate::calendars::{Date, JulianDate};
#[derive(Clone, Copy, PartialEq, Eq, Debug)]
pub enum Paschalion {
Julian,
Gregorian,
}
/// Pascha by the Julian Paschalion, as a civil (Gregorian) date.
///
/// Meeus' Julian algorithm yields a date in the *Julian* calendar, which is
/// then read onto the civil one. The type system enforces that step here: the
/// algorithm builds a `JulianDate`, and only `to_civil` produces a `Date`.
pub fn julian_pascha(year: i32) -> Date {
let a = year % 4;
let b = year % 7;
let c = year % 19;
let d = (19 * c + 15) % 30;
let e = (2 * a + 4 * b - d + 34) % 7;
let month = ((d + e + 114) / 31) as u32;
let day = (((d + e + 114) % 31) + 1) as u32;
JulianDate { year, month, day }.to_civil()
}
/// Pascha by the Gregorian Paschalion, the Western computus.
pub fn gregorian_pascha(year: i32) -> Date {
let a = year % 19;
let b = year / 100;
let c = year % 100;
let d = b / 4;
let e = b % 4;
let g = (8 * b + 13) / 25;
let h = (19 * a + b - d - g + 15) % 30;
let i = c / 4;
let k = c % 4;
let l = (32 + 2 * e + 2 * i - h - k) % 7;
let m = (a + 11 * h + 19 * l) / 433;
let month = ((h + l - 7 * m + 90) / 25) as u32;
let day = ((h + l - 7 * m + 33 * month as i32 + 19) % 32) as u32;
Date::from_ymd(year, month, day).expect("computus yields a real date")
}
pub fn pascha(year: i32, p: Paschalion) -> Date {
match p {
Paschalion::Julian => julian_pascha(year),
Paschalion::Gregorian => gregorian_pascha(year),
}
}
/// Every movable date the calendar needs, keyed off Pascha.
///
/// All of these are counted in real days from Pascha, so they are civil dates
/// already and need no calendar conversion: the distance from Pascha to
/// Pentecost is fifty days in every reckoning.
///
/// A struct rather than a map, so a typo in a key is a compile error instead
/// of a silent `None` at the far end of the program.
#[derive(Clone, Copy, Debug)]
pub struct Movables {
pub pascha: Date,
// the Triodion, counted backwards
pub publican_pharisee: Date,
pub prodigal_son: Date,
pub meatfare: Date,
pub cheesefare: Date,
pub cheesefare_week_start: Date,
pub clean_monday: Date,
pub orthodoxy_sunday: Date,
pub gregory_palamas: Date,
pub cross_veneration: Date,
pub john_climacus: Date,
pub mary_of_egypt: Date,
/// The Forty Days end here, on the Friday before Lazarus Saturday.
pub great_fast_end: Date,
pub lazarus_saturday: Date,
pub palm_sunday: Date,
pub great_week_start: Date,
pub great_thursday: Date,
pub great_friday: Date,
pub holy_saturday: Date,
// the Pentecostarion, counted forwards
pub bright_week_end: Date,
pub thomas_sunday: Date,
pub myrrhbearers: Date,
pub midfeast: Date,
pub ascension: Date,
pub fathers_first_council: Date,
pub pentecost: Date,
pub pentecost_week_end: Date,
pub all_saints: Date,
/// The Apostles' Fast begins the Monday after All Saints. Its end is a
/// fixed ecclesiastical date, so the jurisdiction resolves that.
pub apostles_start: Date,
}
pub fn movable_cycle(year: i32, p: Paschalion) -> Movables {
let d = pascha(year, p);
Movables {
pascha: d,
publican_pharisee: d - 70,
prodigal_son: d - 63,
meatfare: d - 56,
cheesefare_week_start: d - 55,
cheesefare: d - 49,
clean_monday: d - 48,
orthodoxy_sunday: d - 42,
gregory_palamas: d - 35,
cross_veneration: d - 28,
john_climacus: d - 21,
mary_of_egypt: d - 14,
great_fast_end: d - 9,
lazarus_saturday: d - 8,
palm_sunday: d - 7,
great_week_start: d - 6,
great_thursday: d - 3,
great_friday: d - 2,
holy_saturday: d - 1,
bright_week_end: d + 6,
thomas_sunday: d + 7,
myrrhbearers: d + 14,
midfeast: d + 24,
ascension: d + 39,
fathers_first_council: d + 42,
pentecost: d + 49,
pentecost_week_end: d + 55,
all_saints: d + 56,
apostles_start: d + 57,
}
}
+64
View File
@@ -0,0 +1,64 @@
//! Fasting disciplines. One module per tradition.
pub mod orthodox;
pub mod ruthenian;
use crate::calendars::{Date, Weekday};
use crate::fasting::{Fast, Level};
use crate::jurisdictions::{Jurisdiction, RuleKind};
use crate::menologion::Rank;
use crate::paschalion::Movables;
use crate::seasons::Season;
/// What a fasting rule is handed. Deliberately small.
pub struct Context<'a> {
pub civil: Date,
/// The ecclesiastical (month, day).
pub fixed: (u32, u32),
/// The ecclesiastical year, for resolving a fixed date back to a civil one.
pub fixed_year: i32,
pub weekday: Weekday,
pub mv: &'a Movables,
pub season: Option<Season>,
pub feast: String,
pub rank: Option<Rank>,
pub jurisdiction: &'a Jurisdiction,
}
impl Context<'_> {
/// The civil date of an ecclesiastical date in this day's church year.
pub fn civil_of_fixed(&self, month: u32, day: u32) -> Date {
self.jurisdiction.civil_of_fixed(self.fixed_year, month, day)
}
pub fn is_weekend(&self) -> bool {
self.weekday.is_weekend()
}
/// Is this day within `days` of the given start, inclusive?
pub fn within(&self, start: Date, days: i32) -> bool {
start <= self.civil && self.civil <= start + days
}
}
/// How a tradition names the levels it uses, and the glyph it shows them with.
pub struct Vocabulary {
pub label: fn(Level) -> &'static str,
pub emoji: fn(Level) -> &'static str,
}
pub struct Rule {
pub name: &'static str,
pub title: &'static str,
pub vocabulary: Vocabulary,
pub fast_for: fn(&Context) -> Fast,
/// Whether this tradition legislates the fast as precept at all.
pub has_obligation: bool,
}
pub fn get(kind: RuleKind) -> &'static Rule {
match kind {
RuleKind::Orthodox => &orthodox::RULE,
RuleKind::Ruthenian => &ruthenian::RULE,
}
}
+250
View File
@@ -0,0 +1,250 @@
//! The Typikon's fast, as the Orthodox Church keeps it.
//!
//! This is the discipline printed in the Typikon and reproduced, with small
//! variations, by every Orthodox jurisdiction's published calendar. It binds no
//! one in the canonical sense: the Orthodox Church legislates fasting as
//! tradition rather than as precept, so every day here is reported as
//! customary. That is not a softening. It is the difference between a rule of
//! life and a law, and the Eastern Catholic churches, which do have law about
//! this, are handled in their own module.
//!
//! A saint of vigil or polyeleos rank lifts the weekday discipline of a fast:
//! to wine and oil inside the Great Fast, as far as fish in the three lesser
//! fasts. That is why the printed calendars mark so many Wednesdays in the
//! Nativity Fast for wine and oil, and it is the rank in the menologion that
//! decides it.
//!
//! Where usages differ, this follows the common published rule and says so in
//! the reason. The known divergences:
//!
//! - **Great Thursday** is given wine and oil here. Some typika keep xerophagy.
//! - **The Exaltation of the Cross** and the **Beheading of the Forerunner**
//! are given wine and oil. Several jurisdictions print them as strict.
//! - **Lazarus Saturday** is given wine and oil; caviar is the traditional
//! addition, which this vocabulary has no level for.
//! - **Great Week** is kept at xerophagy on its weekdays whatever saint falls
//! in it, because the days of the Passion outrank the menologion.
//! - **Monastic practice**, and the Holy Mountain in particular, is stricter
//! than any of this throughout.
//!
//! A calendar cannot settle those. Your bishop and your spiritual father can.
use super::{Context, Rule, Vocabulary};
use crate::calendars::Weekday;
use crate::fasting::{Fast, Level};
use crate::menologion::Rank;
use crate::seasons::Season;
fn label(level: Level) -> &'static str {
match level {
Level::Strict => "no food until evening",
Level::Xerophagy => "xerophagy \u{2014} no oil or wine",
Level::WineOil => "wine and oil permitted",
Level::Fish => "fish, wine and oil",
Level::Dairy => "dairy and eggs; no meat",
Level::None => "no fast",
}
}
fn emoji(level: Level) -> &'static str {
match level {
Level::Strict => "\u{26d4}",
Level::Xerophagy => "\u{1f96c}",
Level::WineOil => "\u{1fad2}",
Level::Fish => "\u{1f41f}",
Level::Dairy => "\u{1f9c0}",
Level::None => "\u{1f356}",
}
}
/// How far a feast lifts a fast day.
fn relaxed_by(rank: Option<Rank>) -> Option<Level> {
match rank {
Some(Rank::Great) => Some(Level::Fish),
Some(Rank::Vigil) | Some(Rank::Polyeleos) => Some(Level::WineOil),
None => None,
}
}
/// Relax `level` for the day's saint, never past `cap`, never tightening.
fn for_feast(level: Level, ctx: &Context, cap: Level, reason: &str) -> Fast {
let Some(target) = relaxed_by(ctx.rank) else {
return Fast::customary(level, reason);
};
let lifted = level.relaxed_to(target, cap);
if lifted == level {
return Fast::customary(level, reason);
}
Fast::customary(lifted, &format!("{} \u{2014} the fast is relaxed", ctx.feast))
}
pub fn fast_for(ctx: &Context) -> Fast {
let md = ctx.fixed;
let mv = ctx.mv;
let weekend = ctx.is_weekend();
// The fast-free stretches, settled before anything else can override them.
// A Friday inside Bright Week is not a fast day, and the ordinary Friday
// rule further down would say otherwise.
match ctx.season {
Some(Season::BrightWeek) => return Fast::free("Bright Week \u{2014} no fasting"),
Some(Season::PentecostWeek) => {
return Fast::free("the week after Pentecost \u{2014} no fasting")
}
Some(Season::Christmastide) => {
return Fast::free("the Nativity to the eve of Theophany")
}
_ => {}
}
if ctx.within(mv.publican_pharisee, 6) {
// Fast-free precisely because the Pharisee boasted of fasting twice a week.
return Fast::free("fast-free week (the Publican and the Pharisee)");
}
// The Great Fast and Great Week.
if matches!(ctx.season, Some(Season::GreatFast) | Some(Season::GreatWeek)) {
let season = ctx.season.unwrap();
// The Annunciation is tested before the strict days, not after. It can
// land on Great Friday or Great Saturday, and when it does the feast
// still lifts the total fast to wine and oil: it does not vanish.
if md == (3, 25) {
if ctx.civil == mv.great_friday || ctx.civil == mv.holy_saturday {
return Fast::customary(
Level::WineOil,
"the Annunciation, on a day of the total fast",
);
}
return Fast::customary(Level::Fish, "the Annunciation");
}
if ctx.civil == mv.palm_sunday {
return Fast::customary(Level::Fish, "Palm Sunday");
}
if ctx.civil == mv.lazarus_saturday {
return Fast::customary(Level::WineOil, "Lazarus Saturday");
}
if ctx.civil == mv.clean_monday {
return Fast::customary(Level::Strict, "the first day of the Great Fast");
}
if ctx.civil == mv.great_friday {
return Fast::customary(Level::Strict, "Great and Holy Friday");
}
if ctx.civil == mv.holy_saturday {
// The one Saturday of the year kept without oil.
return Fast::customary(Level::Xerophagy, "Great and Holy Saturday");
}
if ctx.civil == mv.great_thursday {
return Fast::customary(Level::WineOil, "Great and Holy Thursday");
}
if season == Season::GreatWeek {
return Fast::customary(
Level::Xerophagy,
&format!("a weekday of the {}", season.name()),
);
}
if weekend {
return Fast::customary(
Level::WineOil,
&format!("Saturday and Sunday of the {}", season.name()),
);
}
return for_feast(
Level::Xerophagy,
ctx,
Level::WineOil,
&format!("a weekday of the {}", season.name()),
);
}
// Cheesefare: meat is set aside for good, everything else stays,
// Wednesday and Friday included.
if ctx.season == Some(Season::CheesefareWeek) {
return Fast::customary(Level::Dairy, "Cheesefare Week");
}
if ctx.civil == mv.meatfare {
return Fast::free("Meatfare Sunday \u{2014} the last day of meat");
}
// The Nativity Fast, which tightens for its last five days.
if ctx.season == Some(Season::NativityFast) {
if md == (12, 24) {
// The eve is relaxed to wine and oil on a Saturday or Sunday,
// because the Liturgy of St Basil is served with Vespers.
if weekend {
return Fast::customary(Level::WineOil, "the eve of the Nativity, on a weekend");
}
return Fast::customary(Level::Xerophagy, "the eve of the Nativity");
}
if md == (11, 21) {
return Fast::customary(Level::Fish, "the Entrance of the Theotokos");
}
if md >= (12, 20) {
if matches!(ctx.weekday, Weekday::Tuesday | Weekday::Thursday) || weekend {
return Fast::customary(Level::WineOil, "the forefeast of the Nativity");
}
return for_feast(Level::Xerophagy, ctx, Level::WineOil, "the forefeast of the Nativity");
}
if weekend {
return Fast::customary(Level::Fish, "Saturday and Sunday of the Nativity Fast");
}
if matches!(ctx.weekday, Weekday::Tuesday | Weekday::Thursday) {
return for_feast(Level::WineOil, ctx, Level::Fish, "Tuesday and Thursday of the Nativity Fast");
}
return for_feast(Level::Xerophagy, ctx, Level::Fish, "Monday, Wednesday and Friday of the Nativity Fast");
}
// The Apostles' Fast, kept the same way.
if ctx.season == Some(Season::Apostles) {
if weekend {
return Fast::customary(Level::Fish, "Saturday and Sunday of the Apostles' Fast");
}
if matches!(ctx.weekday, Weekday::Tuesday | Weekday::Thursday) {
return for_feast(Level::WineOil, ctx, Level::Fish, "Tuesday and Thursday of the Apostles' Fast");
}
return for_feast(Level::Xerophagy, ctx, Level::Fish, "Monday, Wednesday and Friday of the Apostles' Fast");
}
// The Dormition Fast, kept like the Great Fast.
if ctx.season == Some(Season::Dormition) {
if md == (8, 6) {
return Fast::customary(Level::Fish, "the Transfiguration");
}
if weekend {
return Fast::customary(Level::WineOil, "Saturday and Sunday of the Dormition Fast");
}
return for_feast(Level::Xerophagy, ctx, Level::WineOil, "a weekday of the Dormition Fast");
}
// The three fixed fast days outside any season.
if md == (1, 5) {
if weekend {
return Fast::customary(Level::WineOil, "the eve of Theophany, on a weekend");
}
return Fast::customary(Level::Xerophagy, "the eve of Theophany");
}
if md == (9, 14) {
return Fast::customary(Level::WineOil, "the Exaltation of the Holy Cross");
}
if md == (8, 29) {
return Fast::customary(Level::WineOil, "the Beheading of the Forerunner");
}
// Ordinary Wednesdays and Fridays, which a feast can lift.
if matches!(ctx.weekday, Weekday::Wednesday | Weekday::Friday) {
if let Some(target) = relaxed_by(ctx.rank) {
return Fast::customary(target, &format!("{} \u{2014} the fast is relaxed", ctx.feast));
}
return Fast::customary(Level::Xerophagy, "Wednesday and Friday");
}
Fast::new(Level::None, "", None)
}
pub static RULE: Rule = Rule {
name: "orthodox",
title: "the Typikon",
vocabulary: Vocabulary { label, emoji },
fast_for,
// The Orthodox Church does not legislate the fast as precept, so nothing
// here is reported as binding. See the module docs.
has_obligation: false,
};
+161
View File
@@ -0,0 +1,161 @@
//! Byzantine Catholic particular law, alongside the tradition it sits on.
//!
//! The Eastern Catholic churches differ from the Orthodox here in a way worth
//! being exact about: they have *law* on fasting, and it requires very much
//! less than the Typikon describes. Under current Ruthenian particular law the
//! Church binds only:
//!
//! - strict abstinence, no meat, fish, eggs or dairy, on the first day of the
//! Great Fast and on Great and Holy Friday;
//! - abstinence from meat on the Wednesdays and Fridays of the Great Fast.
//!
//! Everything else, the Apostles', Dormition and Nativity Fasts, the fuller
//! keeping of the Great Fast, ordinary Fridays through the year, is encouraged
//! and traditional and does **not** bind. So this module reports two things at
//! once: the level at which the day is kept by tradition, and the minimum that
//! actually obliges. A calendar that says "obligatory" where nothing obliges is
//! worse than no calendar; one that reports only the binding minimum makes the
//! obligatory days look laxer than the voluntary ones beside them.
//!
//! The three degrees this law knows map exactly onto the shared vocabulary: its
//! strict abstinence is `WineOil`, since it does not regulate oil or wine, and
//! its simple abstinence is `Dairy`, meat alone being set aside.
//!
//! **A known reading.** "The Wednesdays and Fridays of the Great Fast" is taken
//! here to mean the Forty Days, not Great and Holy Week, which the calendar
//! counts as its own season. Great and Holy Friday binds in its own right, so
//! only Great and Holy Wednesday turns on the reading. If your eparchy reads it
//! the other way, that Wednesday binds too.
//!
//! Source: the Byzantine Catholic Metropolitan Church of Pittsburgh. Other
//! Eastern Catholic churches legislate their own; where this library maps them
//! onto this ruleset it is an approximation, and the jurisdiction entry says so.
use super::{Context, Rule, Vocabulary};
use crate::calendars::{Date, Weekday};
use crate::fasting::{Fast, Level};
use crate::menologion::Rank;
use crate::seasons::Season;
fn label(level: Level) -> &'static str {
match level {
Level::WineOil => "no meat, fish, eggs or dairy",
Level::Dairy => "no meat",
Level::None => "no fast",
// Levels this law makes no distinction at, kept legible if ever emitted.
Level::Strict => "no food until evening",
Level::Xerophagy => "no meat, fish, eggs or dairy",
Level::Fish => "no meat",
}
}
fn emoji(level: Level) -> &'static str {
match level {
Level::Strict | Level::Xerophagy | Level::WineOil => "\u{1f96c}",
Level::Fish | Level::Dairy => "\u{1f41f}",
Level::None => "\u{1f356}",
}
}
/// Vigil fasts are kept on the preceding Friday when they land on a weekend.
///
/// "If the vigils of Christmas or Theophany fall on Saturday or Sunday, the
/// fasting and abstinence is done on the previous Friday."
fn off_weekend(day: Date) -> Date {
match day.weekday() {
Weekday::Saturday => day - 1,
Weekday::Sunday => day - 2,
_ => day,
}
}
pub fn fast_for(ctx: &Context) -> Fast {
let mv = ctx.mv;
// The two days the Church binds strictly.
if ctx.civil == mv.clean_monday {
return Fast::binding(Level::WineOil, "the first day of the Great Fast", Level::WineOil);
}
if ctx.civil == mv.great_friday {
return Fast::binding(Level::WineOil, "Great and Holy Friday", Level::WineOil);
}
// And the days it binds to abstinence from meat. Kept strictly by
// tradition; binding only as far as meat.
if ctx.season == Some(Season::GreatFast)
&& matches!(ctx.weekday, Weekday::Wednesday | Weekday::Friday)
{
return Fast::binding(
Level::WineOil,
"Wednesday and Friday of the Great Fast",
Level::Dairy,
);
}
// Fixed vigils and the Exaltation, moved off a weekend where the rule says.
for (when, label) in [
(ctx.civil_of_fixed(9, 14), "the Exaltation of the Holy Cross"),
(off_weekend(ctx.civil_of_fixed(12, 24)), "the vigil of the Nativity"),
(off_weekend(ctx.civil_of_fixed(1, 5)), "the vigil of Theophany"),
] {
if ctx.civil == when {
return Fast::customary(Level::WineOil, label);
}
}
// The fast-free stretches, which have to be settled before the ordinary
// Wednesday and Friday rule below or they get overridden by it. Christmas
// Day 2026 is a Friday, and telling you to abstain from meat on it would be
// plainly wrong.
match ctx.season {
Some(Season::BrightWeek) => return Fast::free("Bright Week \u{2014} no fasting"),
Some(Season::PentecostWeek) => {
return Fast::free("the week after Pentecost \u{2014} no fasting")
}
Some(Season::Christmastide) => return Fast::free("Christmastide \u{2014} no fasting"),
_ => {}
}
if ctx.within(mv.publican_pharisee, 6) {
return Fast::free("fast-free week (the Publican and the Pharisee)");
}
// The rest of the Great Fast and Great Week.
if let Some(season @ (Season::GreatFast | Season::GreatWeek)) = ctx.season {
if ctx.is_weekend() {
return Fast::customary(Level::Dairy, season.name());
}
return Fast::customary(Level::WineOil, season.name());
}
if ctx.season == Some(Season::CheesefareWeek) {
return Fast::customary(Level::Dairy, "Cheesefare Week");
}
// The three voluntary seasons.
if let Some(season @ (Season::Apostles | Season::Dormition | Season::NativityFast)) =
ctx.season
{
return Fast::customary(Level::Dairy, season.name());
}
// A great feast releases the ordinary weekday abstinence.
if matches!(ctx.weekday, Weekday::Wednesday | Weekday::Friday) {
if ctx.rank == Some(Rank::Great) {
return Fast::free(&format!("{} \u{2014} the fast is released", ctx.feast));
}
return Fast::customary(
Level::Dairy,
if ctx.weekday == Weekday::Friday { "Friday" } else { "Wednesday" },
);
}
Fast::new(Level::None, "", None)
}
pub static RULE: Rule = Rule {
name: "ruthenian",
title: "Ruthenian particular law",
vocabulary: Vocabulary { label, emoji },
fast_for,
has_obligation: true,
};
+112
View File
@@ -0,0 +1,112 @@
//! Which season of the year a day falls in.
//!
//! The movable seasons are counted from Pascha and so are compared as civil
//! dates. The fixed ones, the Nativity and Dormition Fasts and the fast-free
//! days after the Nativity, are compared in **ecclesiastical** space, against
//! the date the Church calls the day. That is the whole trick that lets one set
//! of rules serve the old and new calendars at once: the Nativity Fast begins
//! on 15 November in both, and only the mornings differ.
//!
//! The Great Fast here runs from Clean Monday through Palm Sunday. The Forty
//! Days proper end on the Friday before Lazarus Saturday, which is what the
//! fasting rules count from, but a season is a label for a reader, and a Palm
//! Sunday belonging to no season at all is worse than one belonging loosely to
//! the Fast.
use crate::calendars::{fixed_between, Date};
use crate::paschalion::Movables;
#[derive(Clone, Copy, PartialEq, Eq, Debug)]
pub enum Season {
GreatFast,
GreatWeek,
BrightWeek,
PreLenten,
CheesefareWeek,
Apostles,
Dormition,
NativityFast,
PentecostWeek,
Christmastide,
}
impl Season {
pub fn name(self) -> &'static str {
match self {
Season::GreatFast => "Great Fast",
Season::GreatWeek => "Great and Holy Week",
Season::BrightWeek => "Bright Week",
Season::PreLenten => "Triodion",
Season::CheesefareWeek => "Cheesefare Week",
Season::Apostles => "Apostles' Fast",
Season::Dormition => "Dormition Fast",
Season::NativityFast => "Nativity Fast",
Season::PentecostWeek => "Week after Pentecost",
Season::Christmastide => "Christmastide",
}
}
/// The seasons in which a fast is kept, as distinct from the ones that are
/// merely named.
pub fn is_fasting_season(self) -> bool {
matches!(
self,
Season::GreatFast
| Season::GreatWeek
| Season::CheesefareWeek
| Season::Apostles
| Season::Dormition
| Season::NativityFast
)
}
}
/// The season a day belongs to, or `None` for ordinary time.
///
/// `fixed_md` is the ecclesiastical (month, day). `apostles_end` is the civil
/// date of 28 June reckoned in this jurisdiction's menologion, the eve of Ss
/// Peter and Paul, where the Apostles' Fast stops.
pub fn season_for(
civil: Date,
fixed_md: (u32, u32),
mv: &Movables,
apostles_end: Date,
) -> Option<Season> {
// Movable first: Great Week outranks the Great Fast it follows, and Bright
// Week outranks everything.
if mv.pascha <= civil && civil <= mv.bright_week_end {
return Some(Season::BrightWeek);
}
if mv.great_week_start <= civil && civil <= mv.holy_saturday {
return Some(Season::GreatWeek);
}
if mv.clean_monday <= civil && civil <= mv.palm_sunday {
return Some(Season::GreatFast);
}
if mv.cheesefare_week_start <= civil && civil <= mv.cheesefare {
return Some(Season::CheesefareWeek);
}
if mv.publican_pharisee <= civil && civil < mv.cheesefare_week_start {
return Some(Season::PreLenten);
}
if mv.pentecost <= civil && civil <= mv.pentecost_week_end {
return Some(Season::PentecostWeek);
}
// The Apostles' Fast can be empty in a year with a late Pascha, which this
// comparison handles without a special case.
if mv.apostles_start <= civil && civil <= apostles_end {
return Some(Season::Apostles);
}
// Fixed seasons, in ecclesiastical space.
if fixed_between(fixed_md, (8, 1), (8, 14)) {
return Some(Season::Dormition);
}
if fixed_between(fixed_md, (11, 15), (12, 24)) {
return Some(Season::NativityFast);
}
if fixed_between(fixed_md, (12, 25), (1, 4)) {
return Some(Season::Christmastide);
}
None
}