1use std::path::Path;
25
26use crate::diagnostics::{Diagnostic, DiagnosticCode};
27use crate::error::{Error, Result};
28use crate::model::calendar::OnDay;
29use crate::model::time::{parse_offset, parse_save, parse_time_of_day};
30use crate::model::{
31 Database, LinkRecord, Origin, RuleRecord, Until, YearBound, ZoneEra, ZoneRecord, ZoneRules,
32};
33
34use super::lexer::tokenize;
35use super::names;
36use super::records::Line;
37
38pub fn parse_into(bytes: &[u8], file: &Path, db: &mut Database) -> Result<()> {
40 let lines = tokenize(bytes, file)?;
41
42 let mut seen_zones: std::collections::HashMap<String, usize> =
47 db.zones.iter().map(|z| (z.name.clone(), 0usize)).collect();
48
49 let mut expect_continuation = false;
50 for line in &lines {
51 if expect_continuation {
52 let era = parse_era(&line.fields, 0, file, line)?;
54 expect_continuation = era.until.is_some();
55 let zone = db
56 .zones
57 .last_mut()
58 .expect("continuation without an open zone is impossible by construction");
59 zone.eras.push(era);
60 continue;
61 }
62
63 let keyword = line.keyword().unwrap_or("");
64 match record_keyword(keyword) {
65 Some(RecordKind::Rule) => {
66 let r = parse_rule(line, file)?;
67 db.rules.entry(r.name.clone()).or_default().push(r);
68 }
69 Some(RecordKind::Zone) => {
70 let (zone, more) = parse_zone(line, file)?;
71 if let Some(&orig) = seen_zones.get(&zone.name) {
75 let where_orig = if orig > 0 {
76 format!(" (originally defined at line {orig})")
77 } else {
78 " (originally defined in an earlier input)".to_string()
79 };
80 return Err(diag(
81 DiagnosticCode::DuplicateZone,
82 format!("duplicate zone name {:?}{where_orig}", zone.name),
83 file,
84 line,
85 ));
86 }
87 seen_zones.insert(zone.name.clone(), line.number);
88 expect_continuation = more;
89 db.zones.push(zone);
90 }
91 Some(RecordKind::Link) => {
92 db.links.push(parse_link(line, file)?);
93 }
94 None => {
95 let indented = line.fields.first().is_some_and(|f| f.col > 0);
102 let (code, msg) = if indented {
103 (
104 DiagnosticCode::ContinuationWithoutZone,
105 format!("continuation line {keyword:?} has no open zone to continue"),
106 )
107 } else {
108 (
109 DiagnosticCode::UnknownLineType,
110 format!("input line of unknown type beginning with {keyword:?}"),
111 )
112 };
113 return Err(diag(code, msg, file, line));
114 }
115 }
116 }
117
118 if expect_continuation {
119 let last = db.zones.last();
121 let name = last.map(|z| z.name.as_str()).unwrap_or("<zone>");
122 return Err(Error::message(format!(
123 "zone {name:?} ends with an UNTIL but has no continuation line"
124 )));
125 }
126 Ok(())
127}
128
129#[derive(Debug, Clone, Copy, PartialEq, Eq)]
131enum RecordKind {
132 Rule,
133 Zone,
134 Link,
135}
136
137fn record_keyword(word: &str) -> Option<RecordKind> {
153 if word.is_empty() {
154 return None;
155 }
156 let needle = word.to_ascii_lowercase();
157 const TABLE: [(&str, RecordKind); 3] = [
158 ("rule", RecordKind::Rule),
159 ("zone", RecordKind::Zone),
160 ("link", RecordKind::Link),
161 ];
162 let mut found = None;
163 for (full, kind) in TABLE {
164 if full.starts_with(&needle) {
165 if found.is_some() {
166 return None; }
168 found = Some(kind);
169 }
170 }
171 found
172}
173
174fn diag(code: DiagnosticCode, msg: impl Into<String>, file: &Path, line: &Line) -> Error {
176 Error::from(Diagnostic::error(code, msg, file, line.number))
177}
178
179fn field_err(e: (DiagnosticCode, String), file: &Path, line: &Line, col: usize) -> Error {
181 Error::from(Diagnostic::error(e.0, e.1, file, line.number).with_span(col, col))
182}
183
184fn parse_rule(line: &Line, file: &Path) -> Result<RuleRecord> {
188 let f = &line.fields;
189 if f.len() != 10 {
190 return Err(diag(
191 DiagnosticCode::InvalidFieldCount,
192 format!("Rule line needs 10 fields, found {}", f.len()),
193 file,
194 line,
195 ));
196 }
197 let name = f[1].text.clone();
198 let from = parse_year(&f[2].text).map_err(|e| field_err(e, file, line, f[2].col))?;
199 let to = parse_to_year(&f[3].text, from).map_err(|e| field_err(e, file, line, f[3].col))?;
200 let in_month = names::month(&f[5].text).map_err(|e| field_err(e, file, line, f[5].col))?;
203 let on = parse_on_day(&f[6].text).map_err(|e| field_err(e, file, line, f[6].col))?;
204 let at = parse_time_of_day(&f[7].text).map_err(|e| field_err(e, file, line, f[7].col))?;
205 let save = parse_save(&f[8].text).map_err(|e| field_err(e, file, line, f[8].col))?;
206 let letter = if f[9].text == "-" {
208 String::new()
209 } else {
210 f[9].text.clone()
211 };
212
213 Ok(RuleRecord {
214 name,
215 from,
216 to,
217 in_month,
218 on,
219 at,
220 save,
221 letter,
222 origin: Origin::new(file, line.number),
223 })
224}
225
226fn parse_year(s: &str) -> std::result::Result<i32, (DiagnosticCode, String)> {
238 if s.is_empty() {
239 return Err((
240 DiagnosticCode::UnsupportedYearType,
241 "empty year".to_string(),
242 ));
243 }
244 let low = s.to_ascii_lowercase();
245 let is_min = "minimum".starts_with(&low);
246 let is_max = "maximum".starts_with(&low);
247 match (is_min, is_max) {
248 (true, true) => Err((
249 DiagnosticCode::UnsupportedYearType,
250 format!("ambiguous year {s:?} — write `min`/`minimum` or `max`/`maximum`"),
251 )),
252 (true, false) => Ok(1900), (false, true) => Ok(i32::MAX),
254 (false, false) => s.parse::<i32>().map_err(|_| {
255 (
256 DiagnosticCode::UnsupportedYearType,
257 format!("invalid year {s:?}"),
258 )
259 }),
260 }
261}
262
263fn parse_to_year(s: &str, from: i32) -> std::result::Result<YearBound, (DiagnosticCode, String)> {
268 if s.is_empty() {
269 return Err((
270 DiagnosticCode::UnsupportedYearType,
271 "empty year".to_string(),
272 ));
273 }
274 let low = s.to_ascii_lowercase();
275 if "only".starts_with(&low) {
276 return Ok(YearBound::Year(from));
278 }
279 let is_min = "minimum".starts_with(&low);
280 let is_max = "maximum".starts_with(&low);
281 match (is_min, is_max) {
282 (true, true) => Err((
283 DiagnosticCode::UnsupportedYearType,
284 format!("ambiguous year {s:?} — write `min`/`minimum` or `max`/`maximum`"),
285 )),
286 (true, false) => Ok(YearBound::Year(1900)), (false, true) => Ok(YearBound::Max),
288 (false, false) => s.parse::<i32>().map(YearBound::Year).map_err(|_| {
289 (
290 DiagnosticCode::UnsupportedYearType,
291 format!("invalid year {s:?}"),
292 )
293 }),
294 }
295}
296
297fn parse_on_day(s: &str) -> std::result::Result<OnDay, (DiagnosticCode, String)> {
299 if let Ok(d) = s.parse::<u8>() {
301 if (1..=31).contains(&d) {
302 return Ok(OnDay::Day(d));
303 }
304 return Err((
305 DiagnosticCode::InvalidDayRule,
306 format!("day {d} out of range"),
307 ));
308 }
309 if let Some(rest) = strip_prefix_ci(s, "last") {
311 let wd = names::weekday(rest)?;
312 return Ok(OnDay::Last(wd));
313 }
314 if let Some((wd_str, n)) = s.split_once(">=") {
316 let wd = names::weekday(wd_str)?;
317 let n = parse_dom(n)?;
318 return Ok(OnDay::OnAfter(wd, n));
319 }
320 if let Some((wd_str, n)) = s.split_once("<=") {
321 let wd = names::weekday(wd_str)?;
322 let n = parse_dom(n)?;
323 return Ok(OnDay::OnBefore(wd, n));
324 }
325 Err((
326 DiagnosticCode::InvalidDayRule,
327 format!("unrecognised ON day spec {s:?}"),
328 ))
329}
330
331fn parse_dom(s: &str) -> std::result::Result<u8, (DiagnosticCode, String)> {
332 s.parse::<u8>()
333 .ok()
334 .filter(|d| (1..=31).contains(d))
335 .ok_or_else(|| {
336 (
337 DiagnosticCode::InvalidDayRule,
338 format!("invalid day-of-month {s:?}"),
339 )
340 })
341}
342
343fn strip_prefix_ci<'a>(s: &'a str, prefix: &str) -> Option<&'a str> {
345 if s.len() >= prefix.len() && s[..prefix.len()].eq_ignore_ascii_case(prefix) {
346 Some(&s[prefix.len()..])
347 } else {
348 None
349 }
350}
351
352fn parse_zone(line: &Line, file: &Path) -> Result<(ZoneRecord, bool)> {
357 let f = &line.fields;
358 if f.len() < 5 {
360 return Err(diag(
361 DiagnosticCode::InvalidFieldCount,
362 format!("Zone line needs at least 5 fields, found {}", f.len()),
363 file,
364 line,
365 ));
366 }
367 let name = f[1].text.clone();
368 let era = parse_era(f, 2, file, line)?;
369 let more = era.until.is_some();
370 let zone = ZoneRecord {
371 name,
372 eras: vec![era],
373 origin: Origin::new(file, line.number),
374 };
375 Ok((zone, more))
376}
377
378fn parse_era(
380 fields: &[super::records::Field],
381 start: usize,
382 file: &Path,
383 line: &Line,
384) -> Result<ZoneEra> {
385 let era = &fields[start..];
386 if era.len() < 3 {
387 return Err(diag(
388 DiagnosticCode::InvalidFieldCount,
389 "zone era needs STDOFF, RULES and FORMAT",
390 file,
391 line,
392 ));
393 }
394 let stdoff = parse_offset(&era[0].text).map_err(|e| field_err(e, file, line, era[0].col))?;
395 let rules =
396 parse_rules_field(&era[1].text).map_err(|e| field_err(e, file, line, era[1].col))?;
397 let format = era[2].text.clone();
398 let until = if era.len() > 3 {
399 Some(parse_until(&era[3..], file, line)?)
400 } else {
401 None
402 };
403 Ok(ZoneEra {
404 stdoff,
405 rules,
406 format,
407 until,
408 origin: Origin::new(file, line.number),
409 })
410}
411
412fn parse_rules_field(s: &str) -> std::result::Result<ZoneRules, (DiagnosticCode, String)> {
414 if s == "-" {
415 return Ok(ZoneRules::None);
416 }
417 let looks_like_time = s
419 .chars()
420 .next()
421 .map(|c| c.is_ascii_digit() || c == '-' || c == '+')
422 .unwrap_or(false)
423 && s.chars().any(|c| c.is_ascii_digit());
424 if looks_like_time {
425 return parse_save(s).map(ZoneRules::Save);
426 }
427 Ok(ZoneRules::Named(s.to_string()))
428}
429
430fn parse_until(fields: &[super::records::Field], file: &Path, line: &Line) -> Result<Until> {
433 let year = fields[0].text.parse::<i32>().map_err(|_| {
434 diag(
435 DiagnosticCode::InvalidValue,
436 "invalid UNTIL year",
437 file,
438 line,
439 )
440 })?;
441 let month = match fields.get(1) {
442 Some(m) => names::month(&m.text).map_err(|e| field_err(e, file, line, m.col))?,
443 None => 1,
444 };
445 let day = match fields.get(2) {
446 Some(d) => parse_on_day(&d.text).map_err(|e| field_err(e, file, line, d.col))?,
447 None => OnDay::Day(1),
448 };
449 let time = match fields.get(3) {
450 Some(t) => parse_time_of_day(&t.text).map_err(|e| field_err(e, file, line, t.col))?,
451 None => crate::model::TimeOfDay::zero(),
452 };
453 Ok(Until {
454 year,
455 month,
456 day,
457 time,
458 })
459}
460
461fn parse_link(line: &Line, file: &Path) -> Result<LinkRecord> {
465 let f = &line.fields;
466 if f.len() != 3 {
467 return Err(diag(
468 DiagnosticCode::InvalidFieldCount,
469 format!("Link line needs 3 fields, found {}", f.len()),
470 file,
471 line,
472 ));
473 }
474 Ok(LinkRecord {
475 target: f[1].text.clone(),
476 link_name: f[2].text.clone(),
477 origin: Origin::new(file, line.number),
478 })
479}
480
481#[cfg(test)]
482mod tests {
483 use super::*;
484 use crate::model::calendar::Weekday;
485 use std::path::PathBuf;
486
487 fn parse(s: &str) -> Result<Database> {
488 let mut db = Database::default();
489 parse_into(s.as_bytes(), &PathBuf::from("t.zi"), &mut db)?;
490 Ok(db)
491 }
492
493 #[test]
494 fn fixed_zone_and_link() {
495 let db = parse("Zone Etc/UTC 0 - UTC\nLink Etc/UTC UTC\n").unwrap();
496 assert_eq!(db.zones.len(), 1);
497 assert_eq!(db.zones[0].name, "Etc/UTC");
498 assert_eq!(db.zones[0].eras.len(), 1);
499 assert_eq!(db.zones[0].eras[0].rules, ZoneRules::None);
500 assert_eq!(db.links.len(), 1);
501 assert_eq!(db.links[0].target, "Etc/UTC");
502 assert_eq!(db.links[0].link_name, "UTC");
503 }
504
505 #[test]
506 fn rule_round_trip() {
507 let db = parse("Rule X 2020 only - Mar Sun>=8 2:00 1:00 D\n").unwrap();
508 let r = &db.rules["X"][0];
509 assert_eq!(r.from, 2020);
510 assert_eq!(r.to, YearBound::Year(2020));
511 assert_eq!(r.in_month, 3);
512 assert_eq!(r.on, OnDay::OnAfter(Weekday::Sun, 8));
513 assert_eq!(r.save.seconds, 3600);
514 assert!(r.save.is_dst);
515 assert_eq!(r.letter, "D");
516 }
517
518 #[test]
519 fn multi_era_zone_with_until() {
520 let src = "Zone T/Z -5:00 - EST 1970\n\t\t-6:00 - CST\n";
522 let db = parse(src).unwrap();
523 assert_eq!(db.zones.len(), 1);
524 assert_eq!(db.zones[0].eras.len(), 2);
525 assert!(db.zones[0].eras[0].until.is_some());
526 assert!(db.zones[0].eras[1].until.is_none());
527 }
528
529 #[test]
530 fn wrong_field_count_is_diagnostic() {
531 let e = parse("Link only-one-field\n").unwrap_err();
532 assert_eq!(
533 e.diagnostic().map(|d| d.code),
534 Some(DiagnosticCode::InvalidFieldCount)
535 );
536 }
537
538 #[test]
539 fn dangling_until_errors() {
540 assert!(parse("Zone T/Z -5:00 - EST 1970\n").is_err());
541 }
542
543 #[test]
544 fn record_keyword_matches_zic_style_unambiguous_prefixes() {
545 for w in ["Rule", "rule", "RULE", "Rul", "Ru", "R", "r"] {
547 assert_eq!(record_keyword(w), Some(RecordKind::Rule), "{w:?}");
548 }
549 for w in ["Zone", "zone", "Zon", "Zo", "Z", "z"] {
550 assert_eq!(record_keyword(w), Some(RecordKind::Zone), "{w:?}");
551 }
552 for w in ["Link", "link", "Lin", "Li", "L", "l"] {
553 assert_eq!(record_keyword(w), Some(RecordKind::Link), "{w:?}");
554 }
555 for w in [
558 "", "Leap", "Le", "Expires", "Ex", "X", "Rules", "Zonely", "q",
559 ] {
560 assert_eq!(record_keyword(w), None, "{w:?}");
561 }
562 }
563
564 #[test]
565 fn parses_zishrink_abbreviated_record_keys() {
566 let db = parse("R X 2020 o - Mar Sun>=8 2:00 1:00 D\nZ Etc/UTC 0 - UTC\nL Etc/UTC UTC\n")
568 .unwrap();
569 assert_eq!(db.rules["X"][0].from, 2020);
570 assert_eq!(db.zones.len(), 1);
571 assert_eq!(db.zones[0].name, "Etc/UTC");
572 assert_eq!(db.links[0].link_name, "UTC");
573 }
574
575 #[test]
576 fn year_keyword_prefixes_match_zic_style() {
577 for w in ["minimum", "minimu", "min", "mi"] {
579 assert_eq!(parse_year(w), Ok(1900), "{w:?}"); }
581 for w in ["maximum", "max", "ma"] {
582 assert_eq!(parse_year(w), Ok(i32::MAX), "{w:?}");
583 }
584 assert_eq!(parse_year("1916"), Ok(1916));
585 for w in ["only", "onl", "on", "o"] {
587 assert_eq!(parse_to_year(w, 1916), Ok(YearBound::Year(1916)), "{w:?}");
588 }
589 assert_eq!(parse_to_year("ma", 1916), Ok(YearBound::Max));
590 assert_eq!(parse_to_year("mi", 1916), Ok(YearBound::Year(1900)));
591 }
592
593 #[test]
594 fn bare_m_year_keyword_is_ambiguous() {
595 assert!(parse_year("m").is_err());
597 assert!(parse_to_year("m", 2000).is_err());
598 }
599
600 #[test]
601 fn from_minimum_is_lowered_to_1900() {
602 let db = parse("Rule X minimum max - Apr lastSun 2:00 1:00 D\n").unwrap();
604 assert_eq!(db.rules["X"][0].from, 1900);
605 assert_eq!(db.rules["X"][0].to, YearBound::Max);
606 assert_ne!(db.rules["X"][0].from, i32::MIN);
607 }
608
609 #[test]
610 fn leap_line_rejected_in_main_source_context() {
611 let e = parse("Leap 2016 Dec 31 23:59:60 + S\n").unwrap_err();
615 assert_eq!(
616 e.diagnostic().map(|d| d.code),
617 Some(DiagnosticCode::UnknownLineType)
618 );
619 }
620}