Skip to main content

tzcompile/source/
leap.rs

1//! Leap-source parsing (T11.2 — the **grammar wall**).
2//!
3//! A *leap-source* file (reference `zic`'s `-L` argument) uses a **different keyword table** from a
4//! zone-source file: only `Leap` and `Expires` are recognised (`zic`'s `leap_line_codes`; a
5//! `Rule`/`Zone`/`Link` here is "input line of unknown type"). Symmetrically, the ordinary
6//! zone-source path ([`super::parse_into`]) never recognises `Leap`/`Expires`. This module is that
7//! wall: it parses a leap-source into a [`LeapTable`] and nothing else.
8//!
9//! **Scope (T11.2): grammar only.** It does *not* compile leap data into TZif, do `right/` emission,
10//! change the TZif version, interact with `-r`, or perform the `Rolling` local-wall conversion —
11//! those are T11.3–T11.6. Leap entries are stored verbatim (UT-as-written) with the `rolling` flag.
12
13use std::path::Path;
14
15use crate::diagnostics::{Diagnostic, DiagnosticCode};
16use crate::error::{Error, Result};
17use crate::model::calendar::{days_from_civil, days_in_month};
18use crate::model::{LeapSecond, LeapTable};
19use crate::source::lexer::tokenize;
20use crate::source::names;
21use crate::source::records::Line;
22
23/// Parse an explicit **leap-source** file into a [`LeapTable`]. Recognises only `Leap`/`Expires`;
24/// any other line (incl. `Rule`/`Zone`/`Link`) is rejected as an unknown line type — matching
25/// reference `zic`'s leap-file keyword table.
26pub fn parse_leap_source(bytes: &[u8], file: &Path) -> Result<LeapTable> {
27    let lines = tokenize(bytes, file)?;
28    let mut table = LeapTable::default();
29    for line in &lines {
30        match leap_keyword(line.keyword().unwrap_or("")) {
31            Some(LeapKind::Leap) => {
32                let entry = parse_leap_line(line, file)?;
33                // Insert sorted by `trans`, as `zic`'s `leapadd` does.
34                let pos = table
35                    .entries
36                    .iter()
37                    .position(|e| entry.trans <= e.trans)
38                    .unwrap_or(table.entries.len());
39                table.entries.insert(pos, entry);
40            }
41            Some(LeapKind::Expires) => {
42                if table.expires.is_some() {
43                    return Err(err(
44                        DiagnosticCode::InvalidValue,
45                        "multiple Expires lines",
46                        file,
47                        line,
48                    ));
49                }
50                table.expires = Some(parse_expires_line(line, file)?);
51            }
52            None => {
53                return Err(err(
54                    DiagnosticCode::UnsupportedDirective,
55                    format!(
56                        "input line of unknown type {:?} (a leap-source file accepts only \
57                         Leap/Expires)",
58                        line.keyword().unwrap_or("")
59                    ),
60                    file,
61                    line,
62                ));
63            }
64        }
65    }
66    // T17.1b: bound the leap table (a bucket-3 safer divergence; real tables hold ~27 entries, the
67    // default cap is far above that). Also caps the O(n²) sorted-insert above on adversarial input.
68    crate::limits::ResourceLimits::default().check_leap_count(table.entries.len())?;
69    Ok(table)
70}
71
72#[derive(Clone, Copy)]
73enum LeapKind {
74    Leap,
75    Expires,
76}
77
78/// The leap-file keyword table: `Leap` / `Expires`, matched by exact spelling or unambiguous prefix
79/// (mirroring `zic`'s `byword`). The two words share no common prefix, so any non-empty prefix is
80/// unambiguous.
81fn leap_keyword(word: &str) -> Option<LeapKind> {
82    if word.is_empty() {
83        return None;
84    }
85    let lower = word.to_ascii_lowercase();
86    let leap = "leap".starts_with(&lower);
87    let expires = "expires".starts_with(&lower);
88    match (leap, expires) {
89        (true, false) => Some(LeapKind::Leap),
90        (false, true) => Some(LeapKind::Expires),
91        _ => None,
92    }
93}
94
95/// `Leap  YEAR  MON  DAY  HH:MM:SS  CORR  ROLL` — 7 fields.
96fn parse_leap_line(line: &Line, file: &Path) -> Result<LeapSecond> {
97    let f = &line.fields;
98    if f.len() != 7 {
99        return Err(err(
100            DiagnosticCode::InvalidFieldCount,
101            format!("Leap line needs 7 fields, found {}", f.len()),
102            file,
103            line,
104        ));
105    }
106    let trans = leap_datetime(&f[1].text, &f[2].text, &f[3].text, &f[4].text, file, line)?;
107    let correction = match f[5].text.as_str() {
108        "+" => 1,
109        // Reference `zic` treats a bare `-` correction as `-1` (its `infile` blanks a lone `-`, so an
110        // empty field also means `-1`).
111        "-" | "" => -1,
112        other => {
113            return Err(err(
114                DiagnosticCode::InvalidValue,
115                format!("invalid CORRECTION field {other:?} on Leap line (want + or -)"),
116                file,
117                line,
118            ));
119        }
120    };
121    let rolling = match roll_kind(&f[6].text) {
122        Some(r) => r,
123        None => {
124            return Err(err(
125                DiagnosticCode::InvalidValue,
126                format!(
127                    "invalid Rolling/Stationary field {:?} on Leap line",
128                    f[6].text
129                ),
130                file,
131                line,
132            ));
133        }
134    };
135    Ok(LeapSecond {
136        trans,
137        correction,
138        rolling,
139    })
140}
141
142/// `Expires  YEAR  MON  DAY  HH:MM:SS` — 5 fields (no CORR/ROLL).
143fn parse_expires_line(line: &Line, file: &Path) -> Result<i64> {
144    let f = &line.fields;
145    if f.len() != 5 {
146        return Err(err(
147            DiagnosticCode::InvalidFieldCount,
148            format!("Expires line needs 5 fields, found {}", f.len()),
149            file,
150            line,
151        ));
152    }
153    leap_datetime(&f[1].text, &f[2].text, &f[3].text, &f[4].text, file, line)
154}
155
156/// `Rolling`/`Stationary`, matched by unambiguous prefix (`zic`'s `leap_types` byword). No common
157/// prefix, so any non-empty prefix is unambiguous.
158fn roll_kind(word: &str) -> Option<bool> {
159    let lower = word.to_ascii_lowercase();
160    if lower.is_empty() {
161        return None;
162    }
163    let rolling = "rolling".starts_with(&lower);
164    let stationary = "stationary".starts_with(&lower);
165    match (rolling, stationary) {
166        (true, false) => Some(true),
167        (false, true) => Some(false),
168        _ => None,
169    }
170}
171
172/// Resolve a leap `YEAR MON DAY HH:MM:SS` to seconds since the 1970 epoch. Rejects a malformed field
173/// and — like reference `zic` — an instant that **precedes the Epoch** (`t < 0`).
174fn leap_datetime(
175    year_s: &str,
176    mon_s: &str,
177    day_s: &str,
178    time_s: &str,
179    file: &Path,
180    line: &Line,
181) -> Result<i64> {
182    let bad = |msg: String| err(DiagnosticCode::InvalidValue, msg, file, line);
183    let year: i32 = year_s
184        .parse()
185        .map_err(|_| bad(format!("invalid leap year {year_s:?}")))?;
186    let month = names::month(mon_s).map_err(|(_, m)| bad(m))?;
187    let day: u8 = day_s
188        .parse()
189        .ok()
190        .filter(|&d| d >= 1 && d <= days_in_month(year, month))
191        .ok_or_else(|| bad(format!("invalid day of month {day_s:?}")))?;
192    let tod = leap_seconds_of_day(time_s).map_err(bad)?;
193    let t = days_from_civil(year, month, day) * 86_400 + tod;
194    if t < 0 {
195        return Err(bad("leap second precedes Epoch".to_string()));
196    }
197    Ok(t)
198}
199
200/// Parse a leap-line time-of-day to seconds. **Unlike ordinary `AT` times, a `60` seconds field is
201/// allowed** — that is the leap second itself (`23:59:60` ≡ 86 400 s ≡ next-day midnight). No w/s/u
202/// suffix. (Deliberately a *separate* parser from `model::time::parse_time_of_day`, which rejects
203/// `:60` — leap times are a different grammar.)
204fn leap_seconds_of_day(s: &str) -> std::result::Result<i64, String> {
205    let mut parts = s.split(':');
206    let parse = |p: Option<&str>| -> std::result::Result<i64, String> {
207        match p {
208            None => Ok(0),
209            Some(x) if !x.is_empty() && x.bytes().all(|b| b.is_ascii_digit()) => x
210                .parse::<i64>()
211                .map_err(|_| format!("number {x:?} out of range")),
212            Some(x) => Err(format!("invalid time component {x:?} in {s:?}")),
213        }
214    };
215    let h = parse(parts.next())?;
216    let m = parse(parts.next())?;
217    let sec = parse(parts.next())?;
218    if parts.next().is_some() {
219        return Err(format!("too many ':' groups in time {s:?}"));
220    }
221    if m >= 60 || sec > 60 {
222        return Err(format!("minutes/seconds out of range in {s:?}"));
223    }
224    Ok(h * 3600 + m * 60 + sec)
225}
226
227fn err(code: DiagnosticCode, msg: impl Into<String>, file: &Path, line: &Line) -> Error {
228    Error::from(Diagnostic::error(code, msg, file, line.number))
229}
230
231#[cfg(test)]
232mod tests {
233    use super::*;
234    use crate::source::parse_into;
235    use std::path::Path;
236
237    fn leap(src: &str) -> Result<LeapTable> {
238        parse_leap_source(src.as_bytes(), Path::new("<leap>"))
239    }
240
241    #[test]
242    fn parses_stationary_and_rolling_leaps() {
243        let t = leap("Leap 2016 Dec 31 23:59:60 + S\nLeap 2015 Jun 30 23:59:60 + R\n").unwrap();
244        assert_eq!(t.entries.len(), 2);
245        // Sorted by trans: 2015 before 2016.
246        assert!(t.entries[0].trans < t.entries[1].trans);
247        assert!(!t.entries[1].rolling, "S = Stationary");
248        assert!(t.entries[0].rolling, "R = Rolling");
249        assert_eq!(t.entries[0].correction, 1);
250        // 23:59:60 normalises to next-day midnight.
251        assert_eq!(t.entries[1].trans, days_from_civil(2017, 1, 1) * 86_400);
252    }
253
254    #[test]
255    fn parses_expires() {
256        let t = leap("Leap 2016 Dec 31 23:59:60 + S\nExpires 2025 Jan 1 0:00:00\n").unwrap();
257        assert_eq!(t.expires, Some(days_from_civil(2025, 1, 1) * 86_400));
258    }
259
260    #[test]
261    fn rejects_rule_zone_link_in_leap_source() {
262        for kw in [
263            "Rule US 2007 max - Mar Sun>=8 2:00 1:00 D",
264            "Zone X 0 - X",
265            "Link A B",
266        ] {
267            assert!(
268                leap(&format!("{kw}\n")).is_err(),
269                "{kw} must be rejected in a leap source"
270            );
271        }
272    }
273
274    #[test]
275    fn rejects_multiple_expires() {
276        let e = leap("Expires 2025 Jan 1 0:00:00\nExpires 2026 Jan 1 0:00:00\n");
277        assert!(e.is_err());
278    }
279
280    #[test]
281    fn rejects_malformed_correction_and_roll() {
282        assert!(leap("Leap 2016 Dec 31 23:59:60 ? S\n").is_err(), "bad CORR");
283        assert!(
284            leap("Leap 2016 Dec 31 23:59:60 + Wobbly\n").is_err(),
285            "bad ROLL"
286        );
287    }
288
289    #[test]
290    fn rejects_pre_epoch_leap() {
291        // `1969 Jun 30` is genuinely before the 1970 epoch (t < 0). (Note `1969 Dec 31 23:59:60`
292        // normalises to t = 0 = 1970-01-01 00:00:00, which reference `zic` *accepts* — the check is
293        // `t < 0`, not `year < 1970`.)
294        assert!(leap("Leap 1969 Jun 30 23:59:60 + S\n").is_err());
295    }
296
297    /// The wall holds the other way too: the ordinary zone-source parser still rejects `Leap`.
298    #[test]
299    fn zone_source_still_rejects_leap_and_expires() {
300        let mut db = crate::model::Database::default();
301        assert!(parse_into(
302            b"Leap 2016 Dec 31 23:59:60 + S\n",
303            Path::new("<z>"),
304            &mut db
305        )
306        .is_err());
307        assert!(parse_into(b"Expires 2025 Jan 1 0:00:00\n", Path::new("<z>"), &mut db).is_err());
308    }
309}