/* * Copyright (c) 2014, Oracle and/or its affiliates. All rights reserved. * DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER. * * This code is free software; you can redistribute it and/or modify it * under the terms of the GNU General Public License version 2 only, as * published by the Free Software Foundation. Oracle designates this * particular file as subject to the "Classpath" exception as provided * by Oracle in the LICENSE file that accompanied this code. * * This code is distributed in the hope that it will be useful, but WITHOUT * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or * FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License * version 2 for more details (a copy is included in the LICENSE file that * accompanied this code). * * You should have received a copy of the GNU General Public License version * 2 along with this work; if not, write to the Free Software Foundation, * Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA. * * Please contact Oracle, 500 Oracle Parkway, Redwood Shores, CA 94065 USA * or visit www.oracle.com if you need additional information or have any * questions. */ package build.tools.tzdb; import java.io.IOException; import java.nio.charset.StandardCharsets; import java.nio.file.Files; import java.nio.file.Path; import java.nio.file.Paths; import java.util.ArrayList; import java.util.Collections; import java.util.HashMap; import java.util.HashSet; import java.util.List; import java.util.Map; import java.util.Map.Entry; import java.util.NavigableMap; import java.util.Objects; import java.util.Set; import java.util.TreeMap; import java.util.TreeSet; import java.util.concurrent.ConcurrentHashMap; import java.time.*; import java.time.Year; import java.time.chrono.IsoChronology; import java.time.temporal.TemporalAdjusters; import java.time.zone.ZoneOffsetTransition; import java.time.zone.ZoneOffsetTransitionRule; import java.time.zone.ZoneOffsetTransitionRule.TimeDefinition; import java.time.zone.ZoneRulesException; /** * Compile and build time-zone rules from IANA timezone data * * @author Xueming Shen * @author Stephen Colebourne * @author Michael Nascimento Santos * * @since 1.9 */ class TzdbZoneRulesProvider { /** * Creates an instance. * * @throws ZoneRulesException if unable to load */ public TzdbZoneRulesProvider(List files) { try { load(files); } catch (Exception ex) { throw new ZoneRulesException("Unable to load TZDB time-zone rules", ex); } } public Set getZoneIds() { return new TreeSet(regionIds); } public Map getAliasMap() { return links; } public ZoneRules getZoneRules(String zoneId) { Object obj = zones.get(zoneId); if (obj == null) { String zoneId0 = zoneId; if (links.containsKey(zoneId)) { zoneId = links.get(zoneId); obj = zones.get(zoneId); } if (obj == null) { // Timezone link can be located in 'backward' file and it // can refer to another link, so we need to check for // link one more time, before throwing an exception String zoneIdBack = zoneId; if (links.containsKey(zoneId)) { zoneId = links.get(zoneId); obj = zones.get(zoneId); } if (obj == null) { throw new ZoneRulesException("Unknown time-zone ID: " + zoneIdBack); } } } if (obj instanceof ZoneRules) { return (ZoneRules)obj; } try { ZoneRules zrules = buildRules(zoneId, (List)obj); zones.put(zoneId, zrules); return zrules; } catch (Exception ex) { throw new ZoneRulesException( "Invalid binary time-zone data: TZDB:" + zoneId, ex); } } ////////////////////////////////////////////////////////////////////// /** * All the regions that are available. */ private List regionIds = new ArrayList<>(600); /** * Zone region to rules mapping */ private final Map zones = new ConcurrentHashMap<>(); /** * compatibility list */ private static HashSet excludedZones; static { // (1) exclude EST, HST and MST. They are supported // via the short-id mapping // (2) remove UTC and GMT // (3) remove ROC, which is not supported in j.u.tz excludedZones = new HashSet<>(10); excludedZones.add("EST"); excludedZones.add("HST"); excludedZones.add("MST"); excludedZones.add("GMT+0"); excludedZones.add("GMT-0"); excludedZones.add("ROC"); } private Map links = new HashMap<>(150); private Map> rules = new HashMap<>(500); private void load(List files) throws IOException { for (Path file : files) { List openZone = null; try { for (String line : Files.readAllLines(file, StandardCharsets.ISO_8859_1)) { if (line.length() == 0 || line.charAt(0) == '#') { continue; } //StringIterator itr = new StringIterator(line); String[] tokens = split(line); if (openZone != null && // continuing zone line Character.isWhitespace(line.charAt(0)) && tokens.length > 0) { ZoneLine zLine = new ZoneLine(); openZone.add(zLine); if (zLine.parse(tokens, 0)) { openZone = null; } continue; } if (line.startsWith("Zone")) { // parse Zone line String name = tokens[1]; if (excludedZones.contains(name)){ continue; } if (zones.containsKey(name)) { throw new IllegalArgumentException( "Duplicated zone name in file: " + name + ", line: [" + line + "]"); } openZone = new ArrayList<>(10); zones.put(name, openZone); regionIds.add(name); ZoneLine zLine = new ZoneLine(); openZone.add(zLine); if (zLine.parse(tokens, 2)) { openZone = null; } } else if (line.startsWith("Rule")) { // parse Rule line String name = tokens[1]; if (!rules.containsKey(name)) { rules.put(name, new ArrayList(10)); } rules.get(name).add(new RuleLine().parse(tokens)); } else if (line.startsWith("Link")) { // parse link line if (tokens.length >= 3) { String realId = tokens[1]; String aliasId = tokens[2]; if (excludedZones.contains(aliasId)){ continue; } links.put(aliasId, realId); regionIds.add(aliasId); } else { throw new IllegalArgumentException( "Invalid Link line in file" + file + ", line: [" + line + "]"); } } else { // skip unknown line } } } catch (Exception ex) { throw new RuntimeException("Failed while processing file [" + file + "]", ex); } } } private String[] split(String str) { int off = 0; int end = str.length(); ArrayList list = new ArrayList<>(10); while (off < end) { char c = str.charAt(off); if (c == '\t' || c == ' ') { off++; continue; } if (c == '#') { // comment break; } int start = off; while (off < end) { c = str.charAt(off); if (c == ' ' || c == '\t') { break; } off++; } if (start != off) { list.add(str.substring(start, off)); } } return list.toArray(new String[list.size()]); } /** * Class representing a month-day-time in the TZDB file. */ private static abstract class MonthDayTime { /** The month of the cutover. */ Month month = Month.JANUARY; /** The day-of-month of the cutover. */ int dayOfMonth = 1; /** Whether to adjust forwards. */ boolean adjustForwards = true; /** The day-of-week of the cutover. */ DayOfWeek dayOfWeek; /** The time of the cutover, in second of day */ int secsOfDay = 0; /** Whether this is midnight end of day. */ boolean endOfDay; /** The time of the cutover. */ TimeDefinition timeDefinition = TimeDefinition.WALL; void adjustToForwards(int year) { if (adjustForwards == false && dayOfMonth > 0) { // weekDay<=monthDay case, don't have it in tzdb data for now LocalDate adjustedDate = LocalDate.of(year, month, dayOfMonth).minusDays(6); dayOfMonth = adjustedDate.getDayOfMonth(); month = adjustedDate.getMonth(); adjustForwards = true; } } LocalDateTime toDateTime(int year) { LocalDate date; if (dayOfMonth < 0) { int monthLen = month.length(IsoChronology.INSTANCE.isLeapYear(year)); date = LocalDate.of(year, month, monthLen + 1 + dayOfMonth); if (dayOfWeek != null) { date = date.with(TemporalAdjusters.previousOrSame(dayOfWeek)); } } else { date = LocalDate.of(year, month, dayOfMonth); if (dayOfWeek != null) { date = date.with(TemporalAdjusters.nextOrSame(dayOfWeek)); } } if (endOfDay) { date = date.plusDays(1); } return LocalDateTime.of(date, LocalTime.ofSecondOfDay(secsOfDay)); } /** * Parses the MonthDaytime segment of a tzdb line. */ private void parse(String[] tokens, int off) { month = parseMonth(tokens[off++]); if (off < tokens.length) { String dayRule = tokens[off++]; if (dayRule.startsWith("last")) { dayOfMonth = -1; dayOfWeek = parseDayOfWeek(dayRule.substring(4)); adjustForwards = false; } else { int index = dayRule.indexOf(">="); if (index > 0) { dayOfWeek = parseDayOfWeek(dayRule.substring(0, index)); dayRule = dayRule.substring(index + 2); } else { index = dayRule.indexOf("<="); if (index > 0) { dayOfWeek = parseDayOfWeek(dayRule.substring(0, index)); adjustForwards = false; dayRule = dayRule.substring(index + 2); } } dayOfMonth = Integer.parseInt(dayRule); if (dayOfMonth < -28 || dayOfMonth > 31 || dayOfMonth == 0) { throw new IllegalArgumentException( "Day of month indicator must be between -28 and 31 inclusive excluding zero"); } } if (off < tokens.length) { String timeStr = tokens[off++]; secsOfDay = parseSecs(timeStr); if (secsOfDay == 86400) { // time must be midnight when end of day flag is true endOfDay = true; secsOfDay = 0; } timeDefinition = parseTimeDefinition(timeStr.charAt(timeStr.length() - 1)); } } } int parseYear(String year, int defaultYear) { switch (year.toLowerCase()) { case "min": return 1900; case "max": return Year.MAX_VALUE; case "only": return defaultYear; } return Integer.parseInt(year); } Month parseMonth(String mon) { switch (mon) { case "Jan": return Month.JANUARY; case "Feb": return Month.FEBRUARY; case "Mar": return Month.MARCH; case "Apr": return Month.APRIL; case "May": return Month.MAY; case "Jun": return Month.JUNE; case "Jul": return Month.JULY; case "Aug": return Month.AUGUST; case "Sep": return Month.SEPTEMBER; case "Oct": return Month.OCTOBER; case "Nov": return Month.NOVEMBER; case "Dec": return Month.DECEMBER; } throw new IllegalArgumentException("Unknown month: " + mon); } DayOfWeek parseDayOfWeek(String dow) { switch (dow) { case "Mon": return DayOfWeek.MONDAY; case "Tue": return DayOfWeek.TUESDAY; case "Wed": return DayOfWeek.WEDNESDAY; case "Thu": return DayOfWeek.THURSDAY; case "Fri": return DayOfWeek.FRIDAY; case "Sat": return DayOfWeek.SATURDAY; case "Sun": return DayOfWeek.SUNDAY; } throw new IllegalArgumentException("Unknown day-of-week: " + dow); } String parseOptional(String str) { return str.equals("-") ? null : str; } static final boolean isDigit(char c) { return c >= '0' && c <= '9'; } private int parseSecs(String time) { if (time.equals("-")) { return 0; } // faster hack int secs = 0; int sign = 1; int off = 0; int len = time.length(); if (off < len && time.charAt(off) == '-') { sign = -1; off++; } char c0, c1; if (off < len && isDigit(c0 = time.charAt(off++))) { int hour = c0 - '0'; if (off < len && isDigit(c1 = time.charAt(off))) { hour = hour * 10 + c1 - '0'; off++; } secs = hour * 60 * 60; if (off < len && time.charAt(off++) == ':') { if (off + 1 < len && isDigit(c0 = time.charAt(off++)) && isDigit(c1 = time.charAt(off++))) { // minutes secs += ((c0 - '0') * 10 + c1 - '0') * 60; if (off < len && time.charAt(off++) == ':') { if (off + 1 < len && isDigit(c0 = time.charAt(off++)) && isDigit(c1 = time.charAt(off++))) { // seconds secs += ((c0 - '0') * 10 + c1 - '0'); } } } } return secs * sign; } throw new IllegalArgumentException("[" + time + "]"); } int parseOffset(String str) { int secs = parseSecs(str); if (Math.abs(secs) > 18 * 60 * 60) { throw new IllegalArgumentException( "Zone offset not in valid range: -18:00 to +18:00"); } return secs; } int parsePeriod(String str) { return parseSecs(str); } TimeDefinition parseTimeDefinition(char c) { switch (c) { case 's': case 'S': // standard time return TimeDefinition.STANDARD; case 'u': case 'U': case 'g': case 'G': case 'z': case 'Z': // UTC return TimeDefinition.UTC; case 'w': case 'W': default: // wall time return TimeDefinition.WALL; } } } /** * Class representing a rule line in the TZDB file. */ private static class RuleLine extends MonthDayTime { /** The start year. */ int startYear; /** The end year. */ int endYear; /** The amount of savings, in seconds. */ int savingsAmount; /** The text name of the zone. */ String text; /** * Converts this to a transition rule. * * @param standardOffset the active standard offset, not null * @param savingsBeforeSecs the active savings before the transition in seconds * @return the transition, not null */ ZoneOffsetTransitionRule toTransitionRule(ZoneOffset stdOffset, int savingsBefore) { // rule shared by different zones, so don't change it Month month = this.month; int dayOfMonth = this.dayOfMonth; DayOfWeek dayOfWeek = this.dayOfWeek; boolean endOfDay = this.endOfDay; // optimize stored format if (dayOfMonth < 0) { if (month != Month.FEBRUARY) { // not Month.FEBRUARY dayOfMonth = month.maxLength() - 6; } } if (endOfDay && dayOfMonth > 0 && (dayOfMonth == 28 && month == Month.FEBRUARY) == false) { LocalDate date = LocalDate.of(2004, month, dayOfMonth).plusDays(1); // leap-year month = date.getMonth(); dayOfMonth = date.getDayOfMonth(); if (dayOfWeek != null) { dayOfWeek = dayOfWeek.plus(1); } endOfDay = false; } // build rule return ZoneOffsetTransitionRule.of( //month, dayOfMonth, dayOfWeek, time, endOfDay, timeDefinition, month, dayOfMonth, dayOfWeek, LocalTime.ofSecondOfDay(secsOfDay), endOfDay, timeDefinition, stdOffset, ZoneOffset.ofTotalSeconds(stdOffset.getTotalSeconds() + savingsBefore), ZoneOffset.ofTotalSeconds(stdOffset.getTotalSeconds() + savingsAmount)); } RuleLine parse(String[] tokens) { startYear = parseYear(tokens[2], 0); endYear = parseYear(tokens[3], startYear); if (startYear > endYear) { throw new IllegalArgumentException( "Invalid line/Year order invalid:" + startYear + " > " + endYear); } //parseOptional(s.next()); // type is unused super.parse(tokens, 5); // monthdaytime parsing savingsAmount = parsePeriod(tokens[8]); //rule.text = parseOptional(s.next()); return this; } } /** * Class representing a linked set of zone lines in the TZDB file. */ private static class ZoneLine extends MonthDayTime { /** The standard offset. */ int stdOffsetSecs; /** The fixed savings amount. */ int fixedSavingsSecs = 0; /** The savings rule. */ String savingsRule; /** The text name of the zone. */ String text; /** The cutover year */ int year = Year.MAX_VALUE; /** The cutover date time */ LocalDateTime ldt; /** The cutover date/time in epoch seconds/UTC */ long ldtSecs = Long.MIN_VALUE; LocalDateTime toDateTime() { if (ldt == null) { ldt = toDateTime(year); } return ldt; } /** * Creates the date-time epoch second in the wall offset for the local * date-time at the end of the window. * * @param savingsSecs the amount of savings in use in seconds * @return the created date-time epoch second in the wall offset, not null */ long toDateTimeEpochSecond(int savingsSecs) { if (ldtSecs == Long.MIN_VALUE) { ldtSecs = toDateTime().toEpochSecond(ZoneOffset.UTC); } switch(timeDefinition) { case UTC: return ldtSecs; case STANDARD: return ldtSecs - stdOffsetSecs; default: return ldtSecs - (stdOffsetSecs + savingsSecs); // WALL } } boolean parse(String[] tokens, int off) { stdOffsetSecs = parseOffset(tokens[off++]); savingsRule = parseOptional(tokens[off++]); if (savingsRule != null && savingsRule.length() > 0 && (savingsRule.charAt(0) == '-' || isDigit(savingsRule.charAt(0)))) { try { fixedSavingsSecs = parsePeriod(savingsRule); savingsRule = null; } catch (Exception ex) { fixedSavingsSecs = 0; } } text = tokens[off++]; if (off < tokens.length) { year = Integer.parseInt(tokens[off++]); if (off < tokens.length) { super.parse(tokens, off); // MonthDayTime } return false; } else { return true; } } } /** * Class representing a rule line in the TZDB file for a particular year. */ private static class TransRule implements Comparable { private int year; private RuleLine rule; /** The trans date/time */ private LocalDateTime ldt; /** The trans date/time in epoch seconds (assume UTC) */ long ldtSecs; TransRule(int year, RuleLine rule) { this.year = year; this.rule = rule; this.ldt = rule.toDateTime(year); this.ldtSecs = ldt.toEpochSecond(ZoneOffset.UTC); } ZoneOffsetTransition toTransition(ZoneOffset standardOffset, int savingsBeforeSecs) { // copy of code in ZoneOffsetTransitionRule to avoid infinite loop ZoneOffset wallOffset = ZoneOffset.ofTotalSeconds( standardOffset.getTotalSeconds() + savingsBeforeSecs); ZoneOffset offsetAfter = ZoneOffset.ofTotalSeconds( standardOffset.getTotalSeconds() + rule.savingsAmount); LocalDateTime dt = rule.timeDefinition .createDateTime(ldt, standardOffset, wallOffset); return ZoneOffsetTransition.of(dt, wallOffset, offsetAfter); } long toEpochSecond(ZoneOffset stdOffset, int savingsBeforeSecs) { switch(rule.timeDefinition) { case UTC: return ldtSecs; case STANDARD: return ldtSecs - stdOffset.getTotalSeconds(); default: return ldtSecs - (stdOffset.getTotalSeconds() + savingsBeforeSecs); // WALL } } /** * Tests if this a real transition with the active savings in seconds * * @param savingsBefore the active savings in seconds * @return true, if savings changes */ boolean isTransition(int savingsBefore) { return rule.savingsAmount != savingsBefore; } public int compareTo(TransRule other) { return (ldtSecs < other.ldtSecs)? -1 : ((ldtSecs == other.ldtSecs) ? 0 : 1); } } private ZoneRules buildRules(String zoneId, List zones) { if (zones.isEmpty()) { throw new IllegalStateException("No available zone window"); } final List standardTransitionList = new ArrayList<>(4); final List transitionList = new ArrayList<>(256); final List lastTransitionRuleList = new ArrayList<>(2); final ZoneLine zone0 = zones.get(0); // initialize the standard offset, wallOffset and savings for loop //ZoneOffset stdOffset = zone0.standardOffset; ZoneOffset stdOffset = ZoneOffset.ofTotalSeconds(zone0.stdOffsetSecs); int savings = zone0.fixedSavingsSecs; ZoneOffset wallOffset = ZoneOffset.ofTotalSeconds(stdOffset.getTotalSeconds() + savings); // start ldt of each zone window LocalDateTime zoneStart = LocalDateTime.MIN; // first stanard offset ZoneOffset firstStdOffset = stdOffset; // first wall offset ZoneOffset firstWallOffset = wallOffset; for (ZoneLine zone : zones) { // check if standard offset changed, update it if yes ZoneOffset stdOffsetPrev = stdOffset; // for effectiveSavings check if (zone.stdOffsetSecs != stdOffset.getTotalSeconds()) { ZoneOffset stdOffsetNew = ZoneOffset.ofTotalSeconds(zone.stdOffsetSecs); standardTransitionList.add( ZoneOffsetTransition.of( LocalDateTime.ofEpochSecond(zoneStart.toEpochSecond(wallOffset), 0, stdOffset), stdOffset, stdOffsetNew)); stdOffset = stdOffsetNew; } LocalDateTime zoneEnd; if (zone.year == Year.MAX_VALUE) { zoneEnd = LocalDateTime.MAX; } else { zoneEnd = zone.toDateTime(); } if (zoneEnd.compareTo(zoneStart) < 0) { throw new IllegalStateException("Windows must be in date-time order: " + zoneEnd + " < " + zoneStart); } // calculate effective savings at the start of the window List trules = null; List lastRules = null; int effectiveSavings = zone.fixedSavingsSecs; if (zone.savingsRule != null) { List tzdbRules = rules.get(zone.savingsRule); if (tzdbRules == null) { throw new IllegalArgumentException(" not found: " + zone.savingsRule); } trules = new ArrayList<>(256); lastRules = new ArrayList<>(2); int lastRulesStartYear = Year.MIN_VALUE; // merge the rules to transitions for (RuleLine rule : tzdbRules) { if (rule.startYear > zoneEnd.getYear()) { // rules will not be used for this zone entry continue; } rule.adjustToForwards(2004); // irrelevant, treat as leap year int startYear = rule.startYear; int endYear = rule.endYear; if (zoneEnd.equals(LocalDateTime.MAX)) { if (endYear == Year.MAX_VALUE) { endYear = startYear; lastRules.add(new TransRule(endYear, rule)); } lastRulesStartYear = Math.max(startYear, lastRulesStartYear); } else { if (endYear == Year.MAX_VALUE) { //endYear = zoneEnd.getYear(); endYear = zone.year; } } int year = startYear; while (year <= endYear) { trules.add(new TransRule(year, rule)); year++; } } // last rules, fill the gap years between different last rules if (zoneEnd.equals(LocalDateTime.MAX)) { lastRulesStartYear = Math.max(lastRulesStartYear, zoneStart.getYear()) + 1; for (TransRule rule : lastRules) { if (rule.year <= lastRulesStartYear) { int year = rule.year; while (year <= lastRulesStartYear) { trules.add(new TransRule(year, rule.rule)); year++; } rule.year = lastRulesStartYear; rule.ldt = rule.rule.toDateTime(year); rule.ldtSecs = rule.ldt.toEpochSecond(ZoneOffset.UTC); } } Collections.sort(lastRules); } // sort the merged rules Collections.sort(trules); effectiveSavings = 0; for (TransRule rule : trules) { if (rule.toEpochSecond(stdOffsetPrev, savings) > zoneStart.toEpochSecond(wallOffset)) { // previous savings amount found, which could be the // savings amount at the instant that the window starts // (hence isAfter) break; } effectiveSavings = rule.rule.savingsAmount; } } // check if the start of the window represents a transition ZoneOffset effectiveWallOffset = ZoneOffset.ofTotalSeconds(stdOffset.getTotalSeconds() + effectiveSavings); if (!wallOffset.equals(effectiveWallOffset)) { transitionList.add(ZoneOffsetTransition.of(zoneStart, wallOffset, effectiveWallOffset)); } savings = effectiveSavings; // apply rules within the window if (trules != null) { long zoneStartEpochSecs = zoneStart.toEpochSecond(wallOffset); for (TransRule trule : trules) { if (trule.isTransition(savings)) { long epochSecs = trule.toEpochSecond(stdOffset, savings); if (epochSecs < zoneStartEpochSecs || epochSecs >= zone.toDateTimeEpochSecond(savings)) { continue; } transitionList.add(trule.toTransition(stdOffset, savings)); savings = trule.rule.savingsAmount; } } } if (lastRules != null) { for (TransRule trule : lastRules) { lastTransitionRuleList.add(trule.rule.toTransitionRule(stdOffset, savings)); savings = trule.rule.savingsAmount; } } // finally we can calculate the true end of the window, passing it to the next window wallOffset = ZoneOffset.ofTotalSeconds(stdOffset.getTotalSeconds() + savings); zoneStart = LocalDateTime.ofEpochSecond(zone.toDateTimeEpochSecond(savings), 0, wallOffset); } return new ZoneRules(firstStdOffset, firstWallOffset, standardTransitionList, transitionList, lastTransitionRuleList); } }