1 /* 2 * Copyright (c) 2014, 2020, Oracle and/or its affiliates. All rights reserved. 3 * DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER. 4 * 5 * This code is free software; you can redistribute it and/or modify it 6 * under the terms of the GNU General Public License version 2 only, as 7 * published by the Free Software Foundation. Oracle designates this 8 * particular file as subject to the "Classpath" exception as provided 9 * by Oracle in the LICENSE file that accompanied this code. 10 * 11 * This code is distributed in the hope that it will be useful, but WITHOUT 12 * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or 13 * FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License 14 * version 2 for more details (a copy is included in the LICENSE file that 15 * accompanied this code). 16 * 17 * You should have received a copy of the GNU General Public License version 18 * 2 along with this work; if not, write to the Free Software Foundation, 19 * Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA. 20 * 21 * Please contact Oracle, 500 Oracle Parkway, Redwood Shores, CA 94065 USA 22 * or visit www.oracle.com if you need additional information or have any 23 * questions. 24 */ 25 26 27 package build.tools.tzdb; 28 29 import java.io.IOException; 30 import java.nio.charset.StandardCharsets; 31 import java.nio.file.Files; 32 import java.nio.file.Path; 33 import java.nio.file.Paths; 34 import java.util.*; 35 import java.util.Map.Entry; 36 import java.util.concurrent.ConcurrentSkipListMap; 37 import java.time.*; 38 import java.time.Year; 39 import java.time.chrono.IsoChronology; 40 import java.time.temporal.TemporalAdjusters; 41 import build.tools.tzdb.ZoneOffsetTransitionRule.TimeDefinition; 42 import java.time.zone.ZoneRulesException; 43 44 /** 45 * Compile and build time-zone rules from IANA timezone data 46 * 47 * @author Xueming Shen 48 * @author Stephen Colebourne 49 * @author Michael Nascimento Santos 50 * 51 * @since 9 52 */ 53 54 class TzdbZoneRulesProvider { 55 56 /** 57 * Creates an instance. 58 * 59 * @throws ZoneRulesException if unable to load 60 */ 61 public TzdbZoneRulesProvider(List<Path> files) { 62 try { 63 load(files); 64 } catch (Exception ex) { 65 throw new ZoneRulesException("Unable to load TZDB time-zone rules", ex); 66 } 67 } 68 69 public Set<String> getZoneIds() { 70 return new TreeSet<String>(regionIds); 71 } 72 73 public Map<String, String> getAliasMap() { 74 return links; 75 } 76 77 public ZoneRules getZoneRules(String zoneId) { 78 Object obj = zones.get(zoneId); 79 if (obj == null) { 80 String zoneId0 = zoneId; 81 if (links.containsKey(zoneId)) { 82 zoneId = links.get(zoneId); 83 obj = zones.get(zoneId); 84 } 85 if (obj == null) { 86 // Timezone link can be located in 'backward' file and it 87 // can refer to another link, so we need to check for 88 // link one more time, before throwing an exception 89 String zoneIdBack = zoneId; 90 if (links.containsKey(zoneId)) { 91 zoneId = links.get(zoneId); 92 obj = zones.get(zoneId); 93 } 94 if (obj == null) { 95 throw new ZoneRulesException("Unknown time-zone ID: " + zoneIdBack); 96 } 97 } 98 } 99 if (obj instanceof ZoneRules) { 100 return (ZoneRules)obj; 101 } 102 try { 103 @SuppressWarnings("unchecked") 104 ZoneRules zrules = buildRules(zoneId, (List<ZoneLine>)obj); 105 zones.put(zoneId, zrules); 106 return zrules; 107 } catch (Exception ex) { 108 throw new ZoneRulesException( 109 "Invalid binary time-zone data: TZDB:" + zoneId, ex); 110 } 111 } 112 113 ////////////////////////////////////////////////////////////////////// 114 115 /** 116 * All the regions that are available. 117 */ 118 private List<String> regionIds = new ArrayList<>(600); 119 120 /** 121 * Zone region to rules mapping 122 */ 123 private final Map<String, Object> zones = new ConcurrentSkipListMap<>(); 124 125 /** 126 * compatibility list 127 */ 128 private static Set<String> excludedZones; 129 static { 130 // (1) exclude EST, HST and MST. They are supported 131 // via the short-id mapping 132 // (2) remove UTC and GMT 133 // (3) remove ROC, which is not supported in j.u.tz 134 excludedZones = new TreeSet<>(); 135 excludedZones.add("EST"); 136 excludedZones.add("HST"); 137 excludedZones.add("MST"); 138 excludedZones.add("GMT+0"); 139 excludedZones.add("GMT-0"); 140 excludedZones.add("ROC"); 141 } 142 143 private Map<String, String> links = new TreeMap<>(); 144 private Map<String, List<RuleLine>> rules = new TreeMap<>(); 145 146 private void load(List<Path> files) throws IOException { 147 148 for (Path file : files) { 149 List<ZoneLine> openZone = null; 150 try { 151 for (String line : Files.readAllLines(file, StandardCharsets.ISO_8859_1)) { 152 if (line.length() == 0 || line.charAt(0) == '#') { 153 continue; 154 } 155 //StringIterator itr = new StringIterator(line); 156 String[] tokens = split(line); 157 if (openZone != null && // continuing zone line 158 Character.isWhitespace(line.charAt(0)) && 159 tokens.length > 0) { 160 ZoneLine zLine = new ZoneLine(); 161 openZone.add(zLine); 162 if (zLine.parse(tokens, 0)) { 163 openZone = null; 164 } 165 continue; 166 } 167 if (line.startsWith("Zone")) { // parse Zone line 168 String name = tokens[1]; 169 if (excludedZones.contains(name)){ 170 continue; 171 } 172 if (zones.containsKey(name)) { 173 throw new IllegalArgumentException( 174 "Duplicated zone name in file: " + name + 175 ", line: [" + line + "]"); 176 } 177 openZone = new ArrayList<>(10); 178 zones.put(name, openZone); 179 regionIds.add(name); 180 ZoneLine zLine = new ZoneLine(); 181 openZone.add(zLine); 182 if (zLine.parse(tokens, 2)) { 183 openZone = null; 184 } 185 } else if (line.startsWith("Rule")) { // parse Rule line 186 String name = tokens[1]; 187 if (!rules.containsKey(name)) { 188 rules.put(name, new ArrayList<RuleLine>(10)); 189 } 190 rules.get(name).add(new RuleLine().parse(tokens)); 191 } else if (line.startsWith("Link")) { // parse link line 192 if (tokens.length >= 3) { 193 String realId = tokens[1]; 194 String aliasId = tokens[2]; 195 if (excludedZones.contains(aliasId)){ 196 continue; 197 } 198 links.put(aliasId, realId); 199 regionIds.add(aliasId); 200 } else { 201 throw new IllegalArgumentException( 202 "Invalid Link line in file" + 203 file + ", line: [" + line + "]"); 204 } 205 } else { 206 // skip unknown line 207 } 208 } 209 210 } catch (Exception ex) { 211 throw new RuntimeException("Failed while processing file [" + file + 212 "]", ex); 213 } 214 } 215 } 216 217 private String[] split(String str) { 218 int off = 0; 219 int end = str.length(); 220 ArrayList<String> list = new ArrayList<>(10); 221 while (off < end) { 222 char c = str.charAt(off); 223 if (c == '\t' || c == ' ') { 224 off++; 225 continue; 226 } 227 if (c == '#') { // comment 228 break; 229 } 230 int start = off; 231 while (off < end) { 232 c = str.charAt(off); 233 if (c == ' ' || c == '\t') { 234 break; 235 } 236 off++; 237 } 238 if (start != off) { 239 list.add(str.substring(start, off)); 240 } 241 } 242 return list.toArray(new String[list.size()]); 243 } 244 245 /** 246 * Class representing a month-day-time in the TZDB file. 247 */ 248 private static abstract class MonthDayTime { 249 /** The month of the cutover. */ 250 Month month = Month.JANUARY; 251 252 /** The day-of-month of the cutover. */ 253 int dayOfMonth = 1; 254 255 /** Whether to adjust forwards. */ 256 boolean adjustForwards = true; 257 258 /** The day-of-week of the cutover. */ 259 DayOfWeek dayOfWeek; 260 261 /** The time of the cutover, in second of day */ 262 int secsOfDay = 0; 263 264 /** Whether this is midnight end of day. */ 265 boolean endOfDay; 266 267 /** The time definition of the cutover. */ 268 TimeDefinition timeDefinition = TimeDefinition.WALL; 269 270 void adjustToForwards(int year) { 271 if (adjustForwards == false && dayOfMonth > 0) { 272 // weekDay<=monthDay case, don't have it in tzdb data for now 273 LocalDate adjustedDate = LocalDate.of(year, month, dayOfMonth).minusDays(6); 274 dayOfMonth = adjustedDate.getDayOfMonth(); 275 month = adjustedDate.getMonth(); 276 adjustForwards = true; 277 } 278 } 279 280 LocalDateTime toDateTime(int year) { 281 LocalDate date; 282 if (dayOfMonth < 0) { 283 int monthLen = month.length(IsoChronology.INSTANCE.isLeapYear(year)); 284 date = LocalDate.of(year, month, monthLen + 1 + dayOfMonth); 285 if (dayOfWeek != null) { 286 date = date.with(TemporalAdjusters.previousOrSame(dayOfWeek)); 287 } 288 } else { 289 date = LocalDate.of(year, month, dayOfMonth); 290 if (dayOfWeek != null) { 291 date = date.with(TemporalAdjusters.nextOrSame(dayOfWeek)); 292 } 293 } 294 if (endOfDay) { 295 date = date.plusDays(1); 296 } 297 return LocalDateTime.of(date, LocalTime.ofSecondOfDay(secsOfDay)); 298 } 299 300 /** 301 * Parses the MonthDaytime segment of a tzdb line. 302 */ 303 private void parse(String[] tokens, int off) { 304 month = parseMonth(tokens[off++]); 305 if (off < tokens.length) { 306 String dayRule = tokens[off++]; 307 if (dayRule.startsWith("last")) { 308 dayOfMonth = -1; 309 dayOfWeek = parseDayOfWeek(dayRule.substring(4)); 310 adjustForwards = false; 311 } else { 312 int index = dayRule.indexOf(">="); 313 if (index > 0) { 314 dayOfWeek = parseDayOfWeek(dayRule.substring(0, index)); 315 dayRule = dayRule.substring(index + 2); 316 } else { 317 index = dayRule.indexOf("<="); 318 if (index > 0) { 319 dayOfWeek = parseDayOfWeek(dayRule.substring(0, index)); 320 adjustForwards = false; 321 dayRule = dayRule.substring(index + 2); 322 } 323 } 324 dayOfMonth = Integer.parseInt(dayRule); 325 if (dayOfMonth < -28 || dayOfMonth > 31 || dayOfMonth == 0) { 326 throw new IllegalArgumentException( 327 "Day of month indicator must be between -28 and 31 inclusive excluding zero"); 328 } 329 } 330 if (off < tokens.length) { 331 String timeStr = tokens[off++]; 332 secsOfDay = parseSecs(timeStr); 333 if (secsOfDay == 86400) { 334 // time must be midnight when end of day flag is true 335 endOfDay = true; 336 secsOfDay = 0; 337 } else if (secsOfDay < 0 || secsOfDay > 86400) { 338 // beyond 0:00-24:00 range. Adjust the cutover date. 339 int beyondDays = secsOfDay / 86400; 340 secsOfDay %= 86400; 341 if (secsOfDay < 0) { 342 secsOfDay = 86400 + secsOfDay; 343 beyondDays -= 1; 344 } 345 LocalDate date = LocalDate.of(2004, month, dayOfMonth).plusDays(beyondDays); // leap-year 346 month = date.getMonth(); 347 dayOfMonth = date.getDayOfMonth(); 348 if (dayOfWeek != null) { 349 dayOfWeek = dayOfWeek.plus(beyondDays); 350 } 351 } 352 timeDefinition = parseTimeDefinition(timeStr.charAt(timeStr.length() - 1)); 353 } 354 } 355 } 356 357 int parseYear(String year, int defaultYear) { 358 switch (year.toLowerCase()) { 359 case "min": return 1900; 360 case "max": return Year.MAX_VALUE; 361 case "only": return defaultYear; 362 } 363 return Integer.parseInt(year); 364 } 365 366 Month parseMonth(String mon) { 367 switch (mon) { 368 case "Jan": return Month.JANUARY; 369 case "Feb": return Month.FEBRUARY; 370 case "Mar": return Month.MARCH; 371 case "Apr": return Month.APRIL; 372 case "May": return Month.MAY; 373 case "Jun": return Month.JUNE; 374 case "Jul": return Month.JULY; 375 case "Aug": return Month.AUGUST; 376 case "Sep": return Month.SEPTEMBER; 377 case "Oct": return Month.OCTOBER; 378 case "Nov": return Month.NOVEMBER; 379 case "Dec": return Month.DECEMBER; 380 } 381 throw new IllegalArgumentException("Unknown month: " + mon); 382 } 383 384 DayOfWeek parseDayOfWeek(String dow) { 385 switch (dow) { 386 case "Mon": return DayOfWeek.MONDAY; 387 case "Tue": return DayOfWeek.TUESDAY; 388 case "Wed": return DayOfWeek.WEDNESDAY; 389 case "Thu": return DayOfWeek.THURSDAY; 390 case "Fri": return DayOfWeek.FRIDAY; 391 case "Sat": return DayOfWeek.SATURDAY; 392 case "Sun": return DayOfWeek.SUNDAY; 393 } 394 throw new IllegalArgumentException("Unknown day-of-week: " + dow); 395 } 396 397 String parseOptional(String str) { 398 return str.equals("-") ? null : str; 399 } 400 401 static final boolean isDigit(char c) { 402 return c >= '0' && c <= '9'; 403 } 404 405 private int parseSecs(String time) { 406 if (time.equals("-")) { 407 return 0; 408 } 409 // faster hack 410 int secs = 0; 411 int sign = 1; 412 int off = 0; 413 int len = time.length(); 414 if (off < len && time.charAt(off) == '-') { 415 sign = -1; 416 off++; 417 } 418 char c0, c1; 419 if (off < len && isDigit(c0 = time.charAt(off++))) { 420 int hour = c0 - '0'; 421 if (off < len && isDigit(c1 = time.charAt(off))) { 422 hour = hour * 10 + c1 - '0'; 423 off++; 424 } 425 secs = hour * 60 * 60; 426 if (off < len && time.charAt(off++) == ':') { 427 if (off + 1 < len && 428 isDigit(c0 = time.charAt(off++)) && 429 isDigit(c1 = time.charAt(off++))) { 430 // minutes 431 secs += ((c0 - '0') * 10 + c1 - '0') * 60; 432 if (off < len && time.charAt(off++) == ':') { 433 if (off + 1 < len && 434 isDigit(c0 = time.charAt(off++)) && 435 isDigit(c1 = time.charAt(off++))) { 436 // seconds 437 secs += ((c0 - '0') * 10 + c1 - '0'); 438 } 439 } 440 } 441 442 } 443 return secs * sign; 444 } 445 throw new IllegalArgumentException("[" + time + "]"); 446 } 447 448 int parseOffset(String str) { 449 int secs = parseSecs(str); 450 if (Math.abs(secs) > 18 * 60 * 60) { 451 throw new IllegalArgumentException( 452 "Zone offset not in valid range: -18:00 to +18:00"); 453 } 454 return secs; 455 } 456 457 int parsePeriod(String str) { 458 return parseSecs(str); 459 } 460 461 TimeDefinition parseTimeDefinition(char c) { 462 switch (c) { 463 case 's': 464 case 'S': 465 // standard time 466 return TimeDefinition.STANDARD; 467 case 'u': 468 case 'U': 469 case 'g': 470 case 'G': 471 case 'z': 472 case 'Z': 473 // UTC 474 return TimeDefinition.UTC; 475 case 'w': 476 case 'W': 477 default: 478 // wall time 479 return TimeDefinition.WALL; 480 } 481 } 482 } 483 484 /** 485 * Class representing a rule line in the TZDB file. 486 */ 487 private static class RuleLine extends MonthDayTime { 488 /** The start year. */ 489 int startYear; 490 491 /** The end year. */ 492 int endYear; 493 494 /** The amount of savings, in seconds. */ 495 int savingsAmount; 496 497 /** The text name of the zone. */ 498 String text; 499 500 /** 501 * Converts this to a transition rule. 502 * 503 * @param standardOffset the active standard offset, not null 504 * @param savingsBeforeSecs the active savings before the transition in seconds 505 * @param negativeSavings minimum savings in the rule, usually zero, but negative if negative DST is 506 * in effect. 507 * @return the transition, not null 508 */ 509 ZoneOffsetTransitionRule toTransitionRule(ZoneOffset stdOffset, int savingsBefore, int negativeSavings) { 510 // rule shared by different zones, so don't change it 511 Month month = this.month; 512 int dayOfMonth = this.dayOfMonth; 513 DayOfWeek dayOfWeek = this.dayOfWeek; 514 boolean endOfDay = this.endOfDay; 515 516 // optimize stored format 517 if (dayOfMonth < 0) { 518 if (month != Month.FEBRUARY) { // not Month.FEBRUARY 519 dayOfMonth = month.maxLength() - 6; 520 } 521 } 522 if (endOfDay && dayOfMonth > 0 && 523 (dayOfMonth == 28 && month == Month.FEBRUARY) == false) { 524 LocalDate date = LocalDate.of(2004, month, dayOfMonth).plusDays(1); // leap-year 525 month = date.getMonth(); 526 dayOfMonth = date.getDayOfMonth(); 527 if (dayOfWeek != null) { 528 dayOfWeek = dayOfWeek.plus(1); 529 } 530 endOfDay = false; 531 } 532 533 // build rule 534 return ZoneOffsetTransitionRule.of( 535 //month, dayOfMonth, dayOfWeek, time, endOfDay, timeDefinition, 536 month, dayOfMonth, dayOfWeek, 537 LocalTime.ofSecondOfDay(secsOfDay), endOfDay, timeDefinition, 538 stdOffset, 539 ZoneOffset.ofTotalSeconds(stdOffset.getTotalSeconds() + savingsBefore), 540 ZoneOffset.ofTotalSeconds(stdOffset.getTotalSeconds() + savingsAmount - negativeSavings)); 541 } 542 543 RuleLine parse(String[] tokens) { 544 startYear = parseYear(tokens[2], 0); 545 endYear = parseYear(tokens[3], startYear); 546 if (startYear > endYear) { 547 throw new IllegalArgumentException( 548 "Invalid <Rule> line/Year order invalid:" + startYear + " > " + endYear); 549 } 550 //parseOptional(s.next()); // type is unused 551 super.parse(tokens, 5); // monthdaytime parsing 552 savingsAmount = parsePeriod(tokens[8]); 553 //rule.text = parseOptional(s.next()); 554 return this; 555 } 556 } 557 558 /** 559 * Class representing a linked set of zone lines in the TZDB file. 560 */ 561 private static class ZoneLine extends MonthDayTime { 562 /** The standard offset. */ 563 int stdOffsetSecs; 564 565 /** The fixed savings amount. */ 566 int fixedSavingsSecs = 0; 567 568 /** The savings rule. */ 569 String savingsRule; 570 571 /** The text name of the zone. */ 572 String text; 573 574 /** The cutover year */ 575 int year = Year.MAX_VALUE; 576 577 /** The cutover date time */ 578 LocalDateTime ldt; 579 580 /** The cutover date/time in epoch seconds/UTC */ 581 long ldtSecs = Long.MIN_VALUE; 582 583 LocalDateTime toDateTime() { 584 if (ldt == null) { 585 ldt = toDateTime(year); 586 } 587 return ldt; 588 } 589 590 /** 591 * Creates the date-time epoch second in the wall offset for the local 592 * date-time at the end of the window. 593 * 594 * @param savingsSecs the amount of savings in use in seconds 595 * @return the created date-time epoch second in the wall offset, not null 596 */ 597 long toDateTimeEpochSecond(int savingsSecs) { 598 if (ldtSecs == Long.MIN_VALUE) { 599 ldtSecs = toDateTime().toEpochSecond(ZoneOffset.UTC); 600 } 601 switch(timeDefinition) { 602 case UTC: return ldtSecs; 603 case STANDARD: return ldtSecs - stdOffsetSecs; 604 default: return ldtSecs - (stdOffsetSecs + savingsSecs); // WALL 605 } 606 } 607 608 boolean parse(String[] tokens, int off) { 609 stdOffsetSecs = parseOffset(tokens[off++]); 610 savingsRule = parseOptional(tokens[off++]); 611 if (savingsRule != null && savingsRule.length() > 0 && 612 (savingsRule.charAt(0) == '-' || isDigit(savingsRule.charAt(0)))) { 613 try { 614 fixedSavingsSecs = parsePeriod(savingsRule); 615 savingsRule = null; 616 } catch (Exception ex) { 617 fixedSavingsSecs = 0; 618 } 619 } 620 text = tokens[off++]; 621 if (off < tokens.length) { 622 year = Integer.parseInt(tokens[off++]); 623 if (off < tokens.length) { 624 super.parse(tokens, off); // MonthDayTime 625 } 626 return false; 627 } else { 628 return true; 629 } 630 } 631 } 632 633 /** 634 * Class representing a rule line in the TZDB file for a particular year. 635 */ 636 private static class TransRule implements Comparable<TransRule> 637 { 638 private int year; 639 private RuleLine rule; 640 641 /** The trans date/time */ 642 private LocalDateTime ldt; 643 644 /** The trans date/time in epoch seconds (assume UTC) */ 645 long ldtSecs; 646 647 TransRule(int year, RuleLine rule) { 648 this.year = year; 649 this.rule = rule; 650 this.ldt = rule.toDateTime(year); 651 this.ldtSecs = ldt.toEpochSecond(ZoneOffset.UTC); 652 } 653 654 ZoneOffsetTransition toTransition(ZoneOffset standardOffset, int savingsBeforeSecs, int negativeSavings) { 655 // copy of code in ZoneOffsetTransitionRule to avoid infinite loop 656 ZoneOffset wallOffset = ZoneOffset.ofTotalSeconds( 657 standardOffset.getTotalSeconds() + savingsBeforeSecs); 658 ZoneOffset offsetAfter = ZoneOffset.ofTotalSeconds( 659 standardOffset.getTotalSeconds() + rule.savingsAmount - negativeSavings); 660 LocalDateTime dt = rule.timeDefinition 661 .createDateTime(ldt, standardOffset, wallOffset); 662 return ZoneOffsetTransition.of(dt, wallOffset, offsetAfter); 663 } 664 665 long toEpochSecond(ZoneOffset stdOffset, int savingsBeforeSecs) { 666 switch(rule.timeDefinition) { 667 case UTC: return ldtSecs; 668 case STANDARD: return ldtSecs - stdOffset.getTotalSeconds(); 669 default: return ldtSecs - (stdOffset.getTotalSeconds() + savingsBeforeSecs); // WALL 670 } 671 } 672 673 /** 674 * Tests if this a real transition with the active savings in seconds 675 * 676 * @param savingsBefore the active savings in seconds 677 * @param negativeSavings minimum savings in the rule, usually zero, but negative if negative DST is 678 * in effect. 679 * @return true, if savings changes 680 */ 681 boolean isTransition(int savingsBefore, int negativeSavings) { 682 return rule.savingsAmount - negativeSavings != savingsBefore; 683 } 684 685 public int compareTo(TransRule other) { 686 return (ldtSecs < other.ldtSecs)? -1 : ((ldtSecs == other.ldtSecs) ? 0 : 1); 687 } 688 } 689 690 private ZoneRules buildRules(String zoneId, List<ZoneLine> zones) { 691 if (zones.isEmpty()) { 692 throw new IllegalStateException("No available zone window"); 693 } 694 final List<ZoneOffsetTransition> standardTransitionList = new ArrayList<>(4); 695 final List<ZoneOffsetTransition> transitionList = new ArrayList<>(256); 696 final List<ZoneOffsetTransitionRule> lastTransitionRuleList = new ArrayList<>(2); 697 698 final ZoneLine zone0 = zones.get(0); 699 // initialize the standard offset, wallOffset and savings for loop 700 701 //ZoneOffset stdOffset = zone0.standardOffset; 702 ZoneOffset stdOffset = ZoneOffset.ofTotalSeconds(zone0.stdOffsetSecs); 703 704 int savings = zone0.fixedSavingsSecs; 705 ZoneOffset wallOffset = ZoneOffset.ofTotalSeconds(stdOffset.getTotalSeconds() + savings); 706 707 // start ldt of each zone window 708 LocalDateTime zoneStart = LocalDateTime.MIN; 709 710 // first standard offset 711 ZoneOffset firstStdOffset = stdOffset; 712 // first wall offset 713 ZoneOffset firstWallOffset = wallOffset; 714 715 for (ZoneLine zone : zones) { 716 // Adjust stdOffset, if negative DST is observed. It should be either 717 // fixed amount, or expressed in the named Rules. 718 int negativeSavings = Math.min(zone.fixedSavingsSecs, findNegativeSavings(zoneStart, zone)); 719 if (negativeSavings < 0) { 720 zone.stdOffsetSecs += negativeSavings; 721 if (zone.fixedSavingsSecs < 0) { 722 zone.fixedSavingsSecs = 0; 723 } 724 } 725 726 // check if standard offset changed, update it if yes 727 ZoneOffset stdOffsetPrev = stdOffset; // for effectiveSavings check 728 if (zone.stdOffsetSecs != stdOffset.getTotalSeconds()) { 729 ZoneOffset stdOffsetNew = ZoneOffset.ofTotalSeconds(zone.stdOffsetSecs); 730 standardTransitionList.add( 731 ZoneOffsetTransition.of( 732 LocalDateTime.ofEpochSecond(zoneStart.toEpochSecond(wallOffset), 733 0, 734 stdOffset), 735 stdOffset, 736 stdOffsetNew)); 737 stdOffset = stdOffsetNew; 738 } 739 740 LocalDateTime zoneEnd; 741 if (zone.year == Year.MAX_VALUE) { 742 zoneEnd = LocalDateTime.MAX; 743 } else { 744 zoneEnd = zone.toDateTime(); 745 } 746 if (zoneEnd.compareTo(zoneStart) < 0) { 747 throw new IllegalStateException("Windows must be in date-time order: " + 748 zoneEnd + " < " + zoneStart); 749 } 750 // calculate effective savings at the start of the window 751 List<TransRule> trules = null; 752 List<TransRule> lastRules = null; 753 754 int effectiveSavings = zone.fixedSavingsSecs; 755 if (zone.savingsRule != null) { 756 List<RuleLine> tzdbRules = rules.get(zone.savingsRule); 757 if (tzdbRules == null) { 758 throw new IllegalArgumentException("<Rule> not found: " + 759 zone.savingsRule); 760 } 761 trules = new ArrayList<>(256); 762 lastRules = new ArrayList<>(2); 763 int lastRulesStartYear = Year.MIN_VALUE; 764 765 // merge the rules to transitions 766 for (RuleLine rule : tzdbRules) { 767 if (rule.startYear > zoneEnd.getYear()) { 768 // rules will not be used for this zone entry 769 continue; 770 } 771 rule.adjustToForwards(2004); // irrelevant, treat as leap year 772 773 int startYear = rule.startYear; 774 int endYear = rule.endYear; 775 if (zoneEnd.equals(LocalDateTime.MAX)) { 776 if (endYear == Year.MAX_VALUE) { 777 endYear = startYear; 778 lastRules.add(new TransRule(endYear, rule)); 779 } 780 lastRulesStartYear = Math.max(startYear, lastRulesStartYear); 781 } else { 782 if (endYear == Year.MAX_VALUE) { 783 //endYear = zoneEnd.getYear(); 784 endYear = zone.year; 785 } 786 } 787 int year = startYear; 788 while (year <= endYear) { 789 trules.add(new TransRule(year, rule)); 790 year++; 791 } 792 } 793 794 // last rules, fill the gap years between different last rules 795 if (zoneEnd.equals(LocalDateTime.MAX)) { 796 lastRulesStartYear = Math.max(lastRulesStartYear, zoneStart.getYear()) + 1; 797 for (TransRule rule : lastRules) { 798 if (rule.year <= lastRulesStartYear) { 799 int year = rule.year; 800 while (year <= lastRulesStartYear) { 801 trules.add(new TransRule(year, rule.rule)); 802 year++; 803 } 804 rule.year = lastRulesStartYear; 805 rule.ldt = rule.rule.toDateTime(year); 806 rule.ldtSecs = rule.ldt.toEpochSecond(ZoneOffset.UTC); 807 } 808 } 809 Collections.sort(lastRules); 810 } 811 // sort the merged rules 812 Collections.sort(trules); 813 814 effectiveSavings = -negativeSavings; 815 for (TransRule rule : trules) { 816 if (rule.toEpochSecond(stdOffsetPrev, savings) > 817 zoneStart.toEpochSecond(wallOffset)) { 818 // previous savings amount found, which could be the 819 // savings amount at the instant that the window starts 820 // (hence isAfter) 821 break; 822 } 823 effectiveSavings = rule.rule.savingsAmount - negativeSavings; 824 } 825 } 826 // check if the start of the window represents a transition 827 ZoneOffset effectiveWallOffset = 828 ZoneOffset.ofTotalSeconds(stdOffset.getTotalSeconds() + effectiveSavings); 829 830 if (!wallOffset.equals(effectiveWallOffset)) { 831 transitionList.add(ZoneOffsetTransition.of(zoneStart, 832 wallOffset, 833 effectiveWallOffset)); 834 } 835 savings = effectiveSavings; 836 // apply rules within the window 837 if (trules != null) { 838 long zoneStartEpochSecs = zoneStart.toEpochSecond(wallOffset); 839 for (TransRule trule : trules) { 840 if (trule.isTransition(savings, negativeSavings)) { 841 long epochSecs = trule.toEpochSecond(stdOffset, savings); 842 if (epochSecs < zoneStartEpochSecs || 843 epochSecs >= zone.toDateTimeEpochSecond(savings)) { 844 continue; 845 } 846 transitionList.add(trule.toTransition(stdOffset, savings, negativeSavings)); 847 savings = trule.rule.savingsAmount - negativeSavings; 848 } 849 } 850 } 851 if (lastRules != null) { 852 for (TransRule trule : lastRules) { 853 lastTransitionRuleList.add(trule.rule.toTransitionRule(stdOffset, savings, negativeSavings)); 854 savings = trule.rule.savingsAmount - negativeSavings; 855 } 856 } 857 858 // finally we can calculate the true end of the window, passing it to the next window 859 wallOffset = ZoneOffset.ofTotalSeconds(stdOffset.getTotalSeconds() + savings); 860 zoneStart = LocalDateTime.ofEpochSecond(zone.toDateTimeEpochSecond(savings), 861 0, 862 wallOffset); 863 } 864 return new ZoneRules(firstStdOffset, 865 firstWallOffset, 866 standardTransitionList, 867 transitionList, 868 lastTransitionRuleList); 869 } 870 871 /** 872 * Find the minimum negative savings in named Rules for a Zone. Savings are only 873 * looked at for the period of the subject Zone. 874 * 875 * @param zoneStart start LDT of the zone 876 * @param zl ZoneLine to look at 877 */ 878 private int findNegativeSavings(LocalDateTime zoneStart, ZoneLine zl) { 879 int negativeSavings = 0; 880 LocalDateTime zoneEnd = zl.toDateTime(); 881 882 if (zl.savingsRule != null) { 883 List<RuleLine> rlines = rules.get(zl.savingsRule); 884 if (rlines == null) { 885 throw new IllegalArgumentException("<Rule> not found: " + 886 zl.savingsRule); 887 } 888 889 negativeSavings = Math.min(0, rlines.stream() 890 .filter(l -> windowOverlap(l, zoneStart.getYear(), zoneEnd.getYear())) 891 .map(l -> l.savingsAmount) 892 .min(Comparator.naturalOrder()) 893 .orElse(0)); 894 } 895 896 return negativeSavings; 897 } 898 899 private boolean windowOverlap(RuleLine ruleLine, int zoneStartYear, int zoneEndYear) { 900 boolean overlap = zoneStartYear <= ruleLine.startYear && zoneEndYear >= ruleLine.startYear || 901 zoneStartYear <= ruleLine.endYear && zoneEndYear >= ruleLine.endYear; 902 903 return overlap; 904 } 905 }