1 /* 2 * Copyright (c) 2007-present, Stephen Colebourne & Michael Nascimento Santos 3 * 4 * All rights reserved. 5 * 6 * Redistribution and use in source and binary forms, with or without 7 * modification, are permitted provided that the following conditions are met: 8 * 9 * * Redistributions of source code must retain the above copyright notice, 10 * this list of conditions and the following disclaimer. 11 * 12 * * Redistributions in binary form must reproduce the above copyright notice, 13 * this list of conditions and the following disclaimer in the documentation 14 * and/or other materials provided with the distribution. 15 * 16 * * Neither the name of JSR-310 nor the names of its contributors 17 * may be used to endorse or promote products derived from this software 18 * without specific prior written permission. 19 * 20 * THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS 21 * "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT 22 * LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR 23 * A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT OWNER OR 24 * CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, 25 * EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, 26 * PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR 27 * PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF 28 * LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING 29 * NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS 30 * SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. 31 */ 32 package org.threeten.bp.zone; 33 34 import static org.threeten.bp.temporal.ChronoField.HOUR_OF_DAY; 35 import static org.threeten.bp.temporal.ChronoField.MINUTE_OF_HOUR; 36 import static org.threeten.bp.temporal.ChronoField.SECOND_OF_MINUTE; 37 38 import java.io.BufferedReader; 39 import java.io.ByteArrayOutputStream; 40 import java.io.DataOutputStream; 41 import java.io.File; 42 import java.io.FileOutputStream; 43 import java.io.FileReader; 44 import java.io.IOException; 45 import java.io.OutputStream; 46 import java.text.ParsePosition; 47 import java.util.ArrayList; 48 import java.util.Arrays; 49 import java.util.HashMap; 50 import java.util.HashSet; 51 import java.util.List; 52 import java.util.Map; 53 import java.util.Set; 54 import java.util.SortedMap; 55 import java.util.StringTokenizer; 56 import java.util.TreeMap; 57 import java.util.TreeSet; 58 import java.util.jar.JarOutputStream; 59 import java.util.zip.ZipEntry; 60 61 import org.threeten.bp.DayOfWeek; 62 import org.threeten.bp.LocalDate; 63 import org.threeten.bp.LocalDateTime; 64 import org.threeten.bp.LocalTime; 65 import org.threeten.bp.Month; 66 import org.threeten.bp.Year; 67 import org.threeten.bp.ZoneOffset; 68 import org.threeten.bp.format.DateTimeFormatter; 69 import org.threeten.bp.format.DateTimeFormatterBuilder; 70 import org.threeten.bp.jdk8.Jdk8Methods; 71 import org.threeten.bp.temporal.TemporalAccessor; 72 import org.threeten.bp.temporal.TemporalAdjusters; 73 import org.threeten.bp.zone.ZoneOffsetTransitionRule.TimeDefinition; 74 75 /** 76 * A builder that can read the TZDB time-zone files and build {@code ZoneRules} instances. 77 * 78 * <h3>Specification for implementors</h3> 79 * This class is a mutable builder. A new instance must be created for each compile. 80 */ 81 final class TzdbZoneRulesCompiler { 82 83 /** 84 * Time parser. 85 */ 86 private static final DateTimeFormatter TIME_PARSER; 87 static { 88 TIME_PARSER = new DateTimeFormatterBuilder() 89 .appendValue(HOUR_OF_DAY) 90 .optionalStart().appendLiteral(':').appendValue(MINUTE_OF_HOUR, 2) 91 .optionalStart().appendLiteral(':').appendValue(SECOND_OF_MINUTE, 2) 92 .toFormatter(); 93 } 94 95 /** 96 * Reads a set of TZDB files and builds a single combined data file. 97 * 98 * @param args the arguments 99 */ main(String[] args)100 public static void main(String[] args) { 101 if (args.length < 2) { 102 outputHelp(); 103 return; 104 } 105 106 // parse args 107 String version = null; 108 File baseSrcDir = null; 109 File dstDir = null; 110 boolean unpacked = false; 111 boolean verbose = false; 112 113 // parse options 114 int i; 115 for (i = 0; i < args.length; i++) { 116 String arg = args[i]; 117 if (arg.startsWith("-") == false) { 118 break; 119 } 120 if ("-srcdir".equals(arg)) { 121 if (baseSrcDir == null && ++i < args.length) { 122 baseSrcDir = new File(args[i]); 123 continue; 124 } 125 } else if ("-dstdir".equals(arg)) { 126 if (dstDir == null && ++i < args.length) { 127 dstDir = new File(args[i]); 128 continue; 129 } 130 } else if ("-version".equals(arg)) { 131 if (version == null && ++i < args.length) { 132 version = args[i]; 133 continue; 134 } 135 } else if ("-unpacked".equals(arg)) { 136 if (unpacked == false) { 137 unpacked = true; 138 continue; 139 } 140 } else if ("-verbose".equals(arg)) { 141 if (verbose == false) { 142 verbose = true; 143 continue; 144 } 145 } else if ("-help".equals(arg) == false) { 146 System.out.println("Unrecognised option: " + arg); 147 } 148 outputHelp(); 149 return; 150 } 151 152 // check source directory 153 if (baseSrcDir == null) { 154 System.out.println("Source directory must be specified using -srcdir: " + baseSrcDir); 155 return; 156 } 157 if (baseSrcDir.isDirectory() == false) { 158 System.out.println("Source does not exist or is not a directory: " + baseSrcDir); 159 return; 160 } 161 dstDir = (dstDir != null ? dstDir : baseSrcDir); 162 163 // parse source file names 164 List<String> srcFileNames = Arrays.asList(Arrays.copyOfRange(args, i, args.length)); 165 if (srcFileNames.isEmpty()) { 166 System.out.println("Source filenames not specified, using default set"); 167 System.out.println("(africa antarctica asia australasia backward etcetera europe northamerica southamerica)"); 168 srcFileNames = Arrays.asList("africa", "antarctica", "asia", "australasia", "backward", 169 "etcetera", "europe", "northamerica", "southamerica"); 170 } 171 172 // find source directories to process 173 List<File> srcDirs = new ArrayList<File>(); 174 if (version != null) { 175 File srcDir = new File(baseSrcDir, version); 176 if (srcDir.isDirectory() == false) { 177 System.out.println("Version does not represent a valid source directory : " + srcDir); 178 return; 179 } 180 srcDirs.add(srcDir); 181 } else { 182 File[] dirs = baseSrcDir.listFiles(); 183 for (File dir : dirs) { 184 if (dir.isDirectory() && dir.getName().matches("[12][0-9][0-9][0-9][A-Za-z0-9._-]+")) { 185 srcDirs.add(dir); 186 } 187 } 188 } 189 if (srcDirs.isEmpty()) { 190 System.out.println("Source directory contains no valid source folders: " + baseSrcDir); 191 return; 192 } 193 194 // check destination directory 195 if (dstDir.exists() == false && dstDir.mkdirs() == false) { 196 System.out.println("Destination directory could not be created: " + dstDir); 197 return; 198 } 199 if (dstDir.isDirectory() == false) { 200 System.out.println("Destination is not a directory: " + dstDir); 201 return; 202 } 203 process(srcDirs, srcFileNames, dstDir, unpacked, verbose); 204 } 205 206 /** 207 * Output usage text for the command line. 208 */ outputHelp()209 private static void outputHelp() { 210 System.out.println("Usage: TzdbZoneRulesCompiler <options> <tzdb source filenames>"); 211 System.out.println("where options include:"); 212 System.out.println(" -srcdir <directory> Where to find source directories (required)"); 213 System.out.println(" -dstdir <directory> Where to output generated files (default srcdir)"); 214 System.out.println(" -version <version> Specify the version, such as 2009a (optional)"); 215 System.out.println(" -unpacked Generate dat files without jar files"); 216 System.out.println(" -help Print this usage message"); 217 System.out.println(" -verbose Output verbose information during compilation"); 218 System.out.println(" There must be one directory for each version in srcdir"); 219 System.out.println(" Each directory must have the name of the version, such as 2009a"); 220 System.out.println(" Each directory must contain the unpacked tzdb files, such as asia or europe"); 221 System.out.println(" Directories must match the regex [12][0-9][0-9][0-9][A-Za-z0-9._-]+"); 222 System.out.println(" There will be one jar file for each version and one combined jar in dstdir"); 223 System.out.println(" If the version is specified, only that version is processed"); 224 } 225 226 /** 227 * Process to create the jar files. 228 */ process(List<File> srcDirs, List<String> srcFileNames, File dstDir, boolean unpacked, boolean verbose)229 private static void process(List<File> srcDirs, List<String> srcFileNames, File dstDir, boolean unpacked, boolean verbose) { 230 // build actual jar files 231 Map<Object, Object> deduplicateMap = new HashMap<Object, Object>(); 232 Map<String, SortedMap<String, ZoneRules>> allBuiltZones = new TreeMap<String, SortedMap<String, ZoneRules>>(); 233 Set<String> allRegionIds = new TreeSet<String>(); 234 Set<ZoneRules> allRules = new HashSet<ZoneRules>(); 235 SortedMap<LocalDate, Byte> bestLeapSeconds = null; 236 237 for (File srcDir : srcDirs) { 238 // source files in this directory 239 List<File> srcFiles = new ArrayList<File>(); 240 for (String srcFileName : srcFileNames) { 241 File file = new File(srcDir, srcFileName); 242 if (file.exists()) { 243 srcFiles.add(file); 244 } 245 } 246 if (srcFiles.isEmpty()) { 247 continue; // nothing to process 248 } 249 File leapSecondsFile = new File(srcDir, "leapseconds"); 250 if (!leapSecondsFile.exists()) { 251 System.out.println("Version " + srcDir.getName() + " does not include leap seconds information."); 252 leapSecondsFile = null; 253 } 254 255 // compile 256 String loopVersion = srcDir.getName(); 257 TzdbZoneRulesCompiler compiler = new TzdbZoneRulesCompiler(loopVersion, srcFiles, leapSecondsFile, verbose); 258 compiler.setDeduplicateMap(deduplicateMap); 259 try { 260 // compile 261 compiler.compile(); 262 SortedMap<String, ZoneRules> builtZones = compiler.getZones(); 263 SortedMap<LocalDate, Byte> parsedLeapSeconds = compiler.getLeapSeconds(); 264 265 // output version-specific file 266 if (unpacked == false) { 267 File dstFile = new File(dstDir, "threeten-TZDB-" + loopVersion + ".jar"); 268 if (verbose) { 269 System.out.println("Outputting file: " + dstFile); 270 } 271 outputFile(dstFile, loopVersion, builtZones, parsedLeapSeconds); 272 } 273 274 // create totals 275 allBuiltZones.put(loopVersion, builtZones); 276 allRegionIds.addAll(builtZones.keySet()); 277 allRules.addAll(builtZones.values()); 278 279 // track best possible leap seconds collection 280 if (compiler.getMostRecentLeapSecond() != null) { 281 // we've got a live one! 282 if (bestLeapSeconds == null || compiler.getMostRecentLeapSecond().compareTo(bestLeapSeconds.lastKey()) > 0) { 283 // found the first one, or found a better one 284 bestLeapSeconds = parsedLeapSeconds; 285 } 286 } 287 } catch (Exception ex) { 288 System.out.println("Failed: " + ex.toString()); 289 ex.printStackTrace(); 290 System.exit(1); 291 } 292 } 293 294 // output merged file 295 if (unpacked) { 296 if (verbose) { 297 System.out.println("Outputting combined files: " + dstDir); 298 } 299 outputFilesDat(dstDir, allBuiltZones, allRegionIds, allRules, bestLeapSeconds); 300 } else { 301 File dstFile = new File(dstDir, "threeten-TZDB-all.jar"); 302 if (verbose) { 303 System.out.println("Outputting combined file: " + dstFile); 304 } 305 outputFile(dstFile, allBuiltZones, allRegionIds, allRules, bestLeapSeconds); 306 } 307 } 308 309 /** 310 * Outputs the DAT files. 311 */ outputFilesDat(File dstDir, Map<String, SortedMap<String, ZoneRules>> allBuiltZones, Set<String> allRegionIds, Set<ZoneRules> allRules, SortedMap<LocalDate, Byte> leapSeconds)312 private static void outputFilesDat(File dstDir, Map<String, SortedMap<String, ZoneRules>> allBuiltZones, 313 Set<String> allRegionIds, Set<ZoneRules> allRules, SortedMap<LocalDate, Byte> leapSeconds) { 314 File tzdbFile = new File(dstDir, "TZDB.dat"); 315 tzdbFile.delete(); 316 try { 317 FileOutputStream fos = null; 318 try { 319 fos = new FileOutputStream(tzdbFile); 320 outputTzdbDat(fos, allBuiltZones, allRegionIds, allRules); 321 } finally { 322 if (fos != null) { 323 fos.close(); 324 } 325 } 326 } catch (Exception ex) { 327 System.out.println("Failed: " + ex.toString()); 328 ex.printStackTrace(); 329 System.exit(1); 330 } 331 } 332 333 /** 334 * Outputs the file. 335 */ outputFile(File dstFile, String version, SortedMap<String, ZoneRules> builtZones, SortedMap<LocalDate, Byte> leapSeconds)336 private static void outputFile(File dstFile, String version, SortedMap<String, ZoneRules> builtZones, SortedMap<LocalDate, Byte> leapSeconds) { 337 Map<String, SortedMap<String, ZoneRules>> loopAllBuiltZones = new TreeMap<String, SortedMap<String, ZoneRules>>(); 338 loopAllBuiltZones.put(version, builtZones); 339 Set<String> loopAllRegionIds = new TreeSet<String>(builtZones.keySet()); 340 Set<ZoneRules> loopAllRules = new HashSet<ZoneRules>(builtZones.values()); 341 outputFile(dstFile, loopAllBuiltZones, loopAllRegionIds, loopAllRules, leapSeconds); 342 } 343 344 /** 345 * Outputs the file. 346 */ outputFile(File dstFile, Map<String, SortedMap<String, ZoneRules>> allBuiltZones, Set<String> allRegionIds, Set<ZoneRules> allRules, SortedMap<LocalDate, Byte> leapSeconds)347 private static void outputFile(File dstFile, Map<String, SortedMap<String, ZoneRules>> allBuiltZones, 348 Set<String> allRegionIds, Set<ZoneRules> allRules, SortedMap<LocalDate, Byte> leapSeconds) { 349 JarOutputStream jos = null; 350 try { 351 jos = new JarOutputStream(new FileOutputStream(dstFile)); 352 outputTzdbEntry(jos, allBuiltZones, allRegionIds, allRules); 353 } catch (Exception ex) { 354 System.out.println("Failed: " + ex.toString()); 355 ex.printStackTrace(); 356 System.exit(1); 357 } finally { 358 if (jos != null) { 359 try { 360 jos.close(); 361 } catch (IOException ex) { 362 // ignore 363 } 364 } 365 } 366 } 367 368 /** 369 * Outputs the timezone entry in the JAR file. 370 */ outputTzdbEntry( JarOutputStream jos, Map<String, SortedMap<String, ZoneRules>> allBuiltZones, Set<String> allRegionIds, Set<ZoneRules> allRules)371 private static void outputTzdbEntry( 372 JarOutputStream jos, Map<String, SortedMap<String, ZoneRules>> allBuiltZones, 373 Set<String> allRegionIds, Set<ZoneRules> allRules) { 374 // this format is not publicly specified 375 try { 376 jos.putNextEntry(new ZipEntry("org/threeten/bp/TZDB.dat")); 377 outputTzdbDat(jos, allBuiltZones, allRegionIds, allRules); 378 jos.closeEntry(); 379 } catch (Exception ex) { 380 System.out.println("Failed: " + ex.toString()); 381 ex.printStackTrace(); 382 System.exit(1); 383 } 384 } 385 386 /** 387 * Outputs the timezone DAT file. 388 */ outputTzdbDat(OutputStream jos, Map<String, SortedMap<String, ZoneRules>> allBuiltZones, Set<String> allRegionIds, Set<ZoneRules> allRules)389 private static void outputTzdbDat(OutputStream jos, 390 Map<String, SortedMap<String, ZoneRules>> allBuiltZones, 391 Set<String> allRegionIds, Set<ZoneRules> allRules) throws IOException { 392 DataOutputStream out = new DataOutputStream(jos); 393 394 // file version 395 out.writeByte(1); 396 // group 397 out.writeUTF("TZDB"); 398 // versions 399 String[] versionArray = allBuiltZones.keySet().toArray(new String[allBuiltZones.size()]); 400 out.writeShort(versionArray.length); 401 for (String version : versionArray) { 402 out.writeUTF(version); 403 } 404 // regions 405 String[] regionArray = allRegionIds.toArray(new String[allRegionIds.size()]); 406 out.writeShort(regionArray.length); 407 for (String regionId : regionArray) { 408 out.writeUTF(regionId); 409 } 410 // rules 411 List<ZoneRules> rulesList = new ArrayList<ZoneRules>(allRules); 412 out.writeShort(rulesList.size()); 413 ByteArrayOutputStream baos = new ByteArrayOutputStream(1024); 414 for (ZoneRules rules : rulesList) { 415 baos.reset(); 416 DataOutputStream dataos = new DataOutputStream(baos); 417 Ser.write(rules, dataos); 418 dataos.close(); 419 byte[] bytes = baos.toByteArray(); 420 out.writeShort(bytes.length); 421 out.write(bytes); 422 } 423 // link version-region-rules 424 for (String version : allBuiltZones.keySet()) { 425 out.writeShort(allBuiltZones.get(version).size()); 426 for (Map.Entry<String, ZoneRules> entry : allBuiltZones.get(version).entrySet()) { 427 int regionIndex = Arrays.binarySearch(regionArray, entry.getKey()); 428 int rulesIndex = rulesList.indexOf(entry.getValue()); 429 out.writeShort(regionIndex); 430 out.writeShort(rulesIndex); 431 } 432 } 433 out.flush(); 434 } 435 436 //----------------------------------------------------------------------- 437 /** The TZDB rules. */ 438 private final Map<String, List<TZDBRule>> rules = new HashMap<String, List<TZDBRule>>(); 439 /** The TZDB zones. */ 440 private final Map<String, List<TZDBZone>> zones = new HashMap<String, List<TZDBZone>>(); 441 /** The TZDB links. */ 442 private final Map<String, String> links = new HashMap<String, String>(); 443 /** The built zones. */ 444 private final SortedMap<String, ZoneRules> builtZones = new TreeMap<String, ZoneRules>(); 445 /** A map to deduplicate object instances. */ 446 private Map<Object, Object> deduplicateMap = new HashMap<Object, Object>(); 447 /** Sorted collection of LeapSecondRules. */ 448 private final SortedMap<LocalDate, Byte> leapSeconds = new TreeMap<LocalDate, Byte>(); 449 450 /** The version to produce. */ 451 private final String version; 452 /** The source files. */ 453 private final List<File> sourceFiles; 454 /** The leap seconds file. */ 455 private final File leapSecondsFile; 456 /** The version to produce. */ 457 private final boolean verbose; 458 459 /** 460 * Creates an instance if you want to invoke the compiler manually. 461 * 462 * @param version the version, such as 2009a, not null 463 * @param sourceFiles the list of source files, not empty, not null 464 * @param verbose whether to output verbose messages 465 */ TzdbZoneRulesCompiler(String version, List<File> sourceFiles, File leapSecondsFile, boolean verbose)466 public TzdbZoneRulesCompiler(String version, List<File> sourceFiles, File leapSecondsFile, boolean verbose) { 467 this.version = version; 468 this.sourceFiles = sourceFiles; 469 this.leapSecondsFile = leapSecondsFile; 470 this.verbose = verbose; 471 } 472 473 /** 474 * Compile the rules file. 475 * <p> 476 * Use {@link #getZones()} and {@link #getLeapSeconds()} to retrieve the parsed data. 477 * 478 * @throws Exception if an error occurs 479 */ compile()480 public void compile() throws Exception { 481 printVerbose("Compiling TZDB version " + version); 482 parseFiles(); 483 parseLeapSecondsFile(); 484 buildZoneRules(); 485 printVerbose("Compiled TZDB version " + version); 486 } 487 488 /** 489 * Gets the parsed zone rules. 490 * 491 * @return the parsed zone rules, not null 492 */ getZones()493 public SortedMap<String, ZoneRules> getZones() { 494 return builtZones; 495 } 496 497 /** 498 * Gets the parsed leap seconds. 499 * 500 * @return the parsed and sorted leap seconds, not null 501 */ getLeapSeconds()502 public SortedMap<LocalDate, Byte> getLeapSeconds() { 503 return leapSeconds; 504 } 505 506 /** 507 * Gets the most recent leap second. 508 * 509 * @return the most recent leap second, null if none 510 */ getMostRecentLeapSecond()511 private LocalDate getMostRecentLeapSecond() { 512 return leapSeconds.isEmpty() ? null : leapSeconds.lastKey(); 513 } 514 515 /** 516 * Sets the deduplication map. 517 * 518 * @param deduplicateMap the map to deduplicate items 519 */ setDeduplicateMap(Map<Object, Object> deduplicateMap)520 void setDeduplicateMap(Map<Object, Object> deduplicateMap) { 521 this.deduplicateMap = deduplicateMap; 522 } 523 524 //----------------------------------------------------------------------- 525 /** 526 * Parses the source files. 527 * 528 * @throws Exception if an error occurs 529 */ parseFiles()530 private void parseFiles() throws Exception { 531 for (File file : sourceFiles) { 532 printVerbose("Parsing file: " + file); 533 parseFile(file); 534 } 535 } 536 537 /** 538 * Parses the leap seconds file. 539 * 540 * @throws Exception if an error occurs 541 */ parseLeapSecondsFile()542 private void parseLeapSecondsFile() throws Exception { 543 printVerbose("Parsing leap second file: " + leapSecondsFile); 544 int lineNumber = 1; 545 String line = null; 546 BufferedReader in = null; 547 548 try { 549 in = new BufferedReader(new FileReader(leapSecondsFile)); 550 for ( ; (line = in.readLine()) != null; lineNumber++) { 551 int index = line.indexOf('#'); // remove comments (doesn't handle # in quotes) 552 if (index >= 0) { 553 line = line.substring(0, index); 554 } 555 if (line.trim().length() == 0) { // ignore blank lines 556 continue; 557 } 558 LeapSecondRule secondRule = parseLeapSecondRule(line); 559 leapSeconds.put(secondRule.leapDate, secondRule.secondAdjustment); 560 } 561 } catch (Exception ex) { 562 throw new Exception("Failed while processing file '" + leapSecondsFile + "' on line " + lineNumber + " '" + line + "'", ex); 563 } finally { 564 try { 565 if (in != null) { 566 in.close(); 567 } 568 } catch (Exception ex) { 569 // ignore NPE and IOE 570 } 571 } 572 } 573 parseLeapSecondRule(String line)574 private LeapSecondRule parseLeapSecondRule(String line) { 575 // # Leap YEAR MONTH DAY HH:MM:SS CORR R/S 576 // Leap 1972 Jun 30 23:59:60 + S 577 // Leap 1972 Dec 31 23:59:60 + S 578 // Leap 1973 Dec 31 23:59:60 + S 579 // Leap 1974 Dec 31 23:59:60 + S 580 // Leap 1975 Dec 31 23:59:60 + S 581 // Leap 1976 Dec 31 23:59:60 + S 582 // Leap 1977 Dec 31 23:59:60 + S 583 // Leap 1978 Dec 31 23:59:60 + S 584 // Leap 1979 Dec 31 23:59:60 + S 585 // Leap 1981 Jun 30 23:59:60 + S 586 // Leap 1982 Jun 30 23:59:60 + S 587 // Leap 1983 Jun 30 23:59:60 + S 588 589 StringTokenizer st = new StringTokenizer(line, " \t"); 590 String first = st.nextToken(); 591 if (first.equals("Leap")) { 592 if (st.countTokens() < 6) { 593 printVerbose("Invalid leap second line in file: " + leapSecondsFile + ", line: " + line); 594 throw new IllegalArgumentException("Invalid leap second line"); 595 } 596 } else { 597 throw new IllegalArgumentException("Unknown line"); 598 } 599 600 int year = Integer.parseInt(st.nextToken()); 601 Month month = parseMonth(st.nextToken()); 602 int dayOfMonth = Integer.parseInt(st.nextToken()); 603 LocalDate leapDate = LocalDate.of(year, month, dayOfMonth); 604 String timeOfLeapSecond = st.nextToken(); 605 606 byte adjustmentByte = 0; 607 String adjustment = st.nextToken(); 608 if (adjustment.equals("+")) { 609 if (!("23:59:60".equals(timeOfLeapSecond))) { 610 throw new IllegalArgumentException("Leap seconds can only be inserted at 23:59:60 - Date:" + leapDate); 611 } 612 adjustmentByte = +1; 613 } else if (adjustment.equals("-")) { 614 if (!("23:59:59".equals(timeOfLeapSecond))) { 615 throw new IllegalArgumentException("Leap seconds can only be removed at 23:59:59 - Date:" + leapDate); 616 } 617 adjustmentByte = -1; 618 } else { 619 throw new IllegalArgumentException("Invalid adjustment '" + adjustment + "' in leap second rule for " + leapDate); 620 } 621 622 String rollingOrStationary = st.nextToken(); 623 if (!"S".equalsIgnoreCase(rollingOrStationary)) { 624 throw new IllegalArgumentException("Only stationary ('S') leap seconds are supported, not '" + rollingOrStationary + "'"); 625 } 626 return new LeapSecondRule(leapDate, adjustmentByte); 627 } 628 629 /** 630 * Parses a source file. 631 * 632 * @param file the file being read, not null 633 * @throws Exception if an error occurs 634 */ parseFile(File file)635 private void parseFile(File file) throws Exception { 636 int lineNumber = 1; 637 String line = null; 638 BufferedReader in = null; 639 try { 640 in = new BufferedReader(new FileReader(file)); 641 List<TZDBZone> openZone = null; 642 for ( ; (line = in.readLine()) != null; lineNumber++) { 643 int index = line.indexOf('#'); // remove comments (doesn't handle # in quotes) 644 if (index >= 0) { 645 line = line.substring(0, index); 646 } 647 if (line.trim().length() == 0) { // ignore blank lines 648 continue; 649 } 650 StringTokenizer st = new StringTokenizer(line, " \t"); 651 if (openZone != null && Character.isWhitespace(line.charAt(0)) && st.hasMoreTokens()) { 652 if (parseZoneLine(st, openZone)) { 653 openZone = null; 654 } 655 } else { 656 if (st.hasMoreTokens()) { 657 String first = st.nextToken(); 658 if (first.equals("Zone")) { 659 if (st.countTokens() < 3) { 660 printVerbose("Invalid Zone line in file: " + file + ", line: " + line); 661 throw new IllegalArgumentException("Invalid Zone line"); 662 } 663 openZone = new ArrayList<TZDBZone>(); 664 zones.put(st.nextToken(), openZone); 665 if (parseZoneLine(st, openZone)) { 666 openZone = null; 667 } 668 } else { 669 openZone = null; 670 if (first.equals("Rule")) { 671 if (st.countTokens() < 9) { 672 printVerbose("Invalid Rule line in file: " + file + ", line: " + line); 673 throw new IllegalArgumentException("Invalid Rule line"); 674 } 675 parseRuleLine(st); 676 677 } else if (first.equals("Link")) { 678 if (st.countTokens() < 2) { 679 printVerbose("Invalid Link line in file: " + file + ", line: " + line); 680 throw new IllegalArgumentException("Invalid Link line"); 681 } 682 String realId = st.nextToken(); 683 String aliasId = st.nextToken(); 684 links.put(aliasId, realId); 685 686 } else { 687 throw new IllegalArgumentException("Unknown line"); 688 } 689 } 690 } 691 } 692 } 693 } catch (Exception ex) { 694 throw new Exception("Failed while processing file '" + file + "' on line " + lineNumber + " '" + line + "'", ex); 695 } finally { 696 if (in != null) { 697 in.close(); 698 } 699 } 700 } 701 702 /** 703 * Parses a Rule line. 704 * 705 * @param st the tokenizer, not null 706 */ parseRuleLine(StringTokenizer st)707 private void parseRuleLine(StringTokenizer st) { 708 TZDBRule rule = new TZDBRule(); 709 String name = st.nextToken(); 710 if (rules.containsKey(name) == false) { 711 rules.put(name, new ArrayList<TZDBRule>()); 712 } 713 rules.get(name).add(rule); 714 rule.startYear = parseYear(st.nextToken(), 0); 715 rule.endYear = parseYear(st.nextToken(), rule.startYear); 716 if (rule.startYear > rule.endYear) { 717 throw new IllegalArgumentException("Year order invalid: " + rule.startYear + " > " + rule.endYear); 718 } 719 parseOptional(st.nextToken()); // type is unused 720 parseMonthDayTime(st, rule); 721 rule.savingsAmount = parsePeriod(st.nextToken()); 722 rule.text = parseOptional(st.nextToken()); 723 } 724 725 /** 726 * Parses a Zone line. 727 * 728 * @param st the tokenizer, not null 729 * @return true if the zone is complete 730 */ parseZoneLine(StringTokenizer st, List<TZDBZone> zoneList)731 private boolean parseZoneLine(StringTokenizer st, List<TZDBZone> zoneList) { 732 TZDBZone zone = new TZDBZone(); 733 zoneList.add(zone); 734 zone.standardOffset = parseOffset(st.nextToken()); 735 String savingsRule = parseOptional(st.nextToken()); 736 if (savingsRule == null) { 737 zone.fixedSavingsSecs = 0; 738 zone.savingsRule = null; 739 } else { 740 try { 741 zone.fixedSavingsSecs = parsePeriod(savingsRule); 742 zone.savingsRule = null; 743 } catch (Exception ex) { 744 zone.fixedSavingsSecs = null; 745 zone.savingsRule = savingsRule; 746 } 747 } 748 zone.text = st.nextToken(); 749 if (st.hasMoreTokens()) { 750 zone.year = Year.of(Integer.parseInt(st.nextToken())); 751 if (st.hasMoreTokens()) { 752 parseMonthDayTime(st, zone); 753 } 754 return false; 755 } else { 756 return true; 757 } 758 } 759 760 /** 761 * Parses a Rule line. 762 * 763 * @param st the tokenizer, not null 764 * @param mdt the object to parse into, not null 765 */ parseMonthDayTime(StringTokenizer st, TZDBMonthDayTime mdt)766 private void parseMonthDayTime(StringTokenizer st, TZDBMonthDayTime mdt) { 767 mdt.month = parseMonth(st.nextToken()); 768 if (st.hasMoreTokens()) { 769 String dayRule = st.nextToken(); 770 if (dayRule.startsWith("last")) { 771 mdt.dayOfMonth = -1; 772 mdt.dayOfWeek = parseDayOfWeek(dayRule.substring(4)); 773 mdt.adjustForwards = false; 774 } else { 775 int index = dayRule.indexOf(">="); 776 if (index > 0) { 777 mdt.dayOfWeek = parseDayOfWeek(dayRule.substring(0, index)); 778 dayRule = dayRule.substring(index + 2); 779 } else { 780 index = dayRule.indexOf("<="); 781 if (index > 0) { 782 mdt.dayOfWeek = parseDayOfWeek(dayRule.substring(0, index)); 783 mdt.adjustForwards = false; 784 dayRule = dayRule.substring(index + 2); 785 } 786 } 787 mdt.dayOfMonth = Integer.parseInt(dayRule); 788 } 789 if (st.hasMoreTokens()) { 790 String timeStr = st.nextToken(); 791 int timeOfDaySecs = parseSecs(timeStr); 792 LocalTime time = deduplicate(LocalTime.ofSecondOfDay(Jdk8Methods.floorMod(timeOfDaySecs, 86400))); 793 mdt.time = time; 794 mdt.adjustDays = Jdk8Methods.floorDiv(timeOfDaySecs, 86400); 795 mdt.timeDefinition = parseTimeDefinition(timeStr.charAt(timeStr.length() - 1)); 796 } 797 } 798 } 799 parseYear(String str, int defaultYear)800 private int parseYear(String str, int defaultYear) { 801 str = str.toLowerCase(); 802 if (matches(str, "minimum")) { 803 return Year.MIN_VALUE; 804 } else if (matches(str, "maximum")) { 805 return Year.MAX_VALUE; 806 } else if (str.equals("only")) { 807 return defaultYear; 808 } 809 return Integer.parseInt(str); 810 } 811 parseMonth(String str)812 private Month parseMonth(String str) { 813 str = str.toLowerCase(); 814 for (Month moy : Month.values()) { 815 if (matches(str, moy.name().toLowerCase())) { 816 return moy; 817 } 818 } 819 throw new IllegalArgumentException("Unknown month: " + str); 820 } 821 parseDayOfWeek(String str)822 private DayOfWeek parseDayOfWeek(String str) { 823 str = str.toLowerCase(); 824 for (DayOfWeek dow : DayOfWeek.values()) { 825 if (matches(str, dow.name().toLowerCase())) { 826 return dow; 827 } 828 } 829 throw new IllegalArgumentException("Unknown day-of-week: " + str); 830 } 831 matches(String str, String search)832 private boolean matches(String str, String search) { 833 return str.startsWith(search.substring(0, 3)) && search.startsWith(str) && str.length() <= search.length(); 834 } 835 parseOptional(String str)836 private String parseOptional(String str) { 837 return str.equals("-") ? null : str; 838 } 839 parseSecs(String str)840 private int parseSecs(String str) { 841 if (str.equals("-")) { 842 return 0; 843 } 844 int pos = 0; 845 if (str.startsWith("-")) { 846 pos = 1; 847 } 848 ParsePosition pp = new ParsePosition(pos); 849 TemporalAccessor parsed = TIME_PARSER.parseUnresolved(str, pp); 850 if (parsed == null || pp.getErrorIndex() >= 0) { 851 throw new IllegalArgumentException(str); 852 } 853 long hour = parsed.getLong(HOUR_OF_DAY); 854 Long min = (parsed.isSupported(MINUTE_OF_HOUR) ? parsed.getLong(MINUTE_OF_HOUR) : null); 855 Long sec = (parsed.isSupported(SECOND_OF_MINUTE) ? parsed.getLong(SECOND_OF_MINUTE) : null); 856 int secs = (int) (hour * 60 * 60 + (min != null ? min : 0) * 60 + (sec != null ? sec : 0)); 857 if (pos == 1) { 858 secs = -secs; 859 } 860 return secs; 861 } 862 parseOffset(String str)863 private ZoneOffset parseOffset(String str) { 864 int secs = parseSecs(str); 865 return ZoneOffset.ofTotalSeconds(secs); 866 } 867 parsePeriod(String str)868 private int parsePeriod(String str) { 869 return parseSecs(str); 870 } 871 parseTimeDefinition(char c)872 private TimeDefinition parseTimeDefinition(char c) { 873 switch (c) { 874 case 's': 875 case 'S': 876 // standard time 877 return TimeDefinition.STANDARD; 878 case 'u': 879 case 'U': 880 case 'g': 881 case 'G': 882 case 'z': 883 case 'Z': 884 // UTC 885 return TimeDefinition.UTC; 886 case 'w': 887 case 'W': 888 default: 889 // wall time 890 return TimeDefinition.WALL; 891 } 892 } 893 894 //----------------------------------------------------------------------- 895 /** 896 * Build the rules, zones and links into real zones. 897 * 898 * @throws Exception if an error occurs 899 */ buildZoneRules()900 private void buildZoneRules() throws Exception { 901 // build zones 902 for (String zoneId : zones.keySet()) { 903 printVerbose("Building zone " + zoneId); 904 zoneId = deduplicate(zoneId); 905 List<TZDBZone> tzdbZones = zones.get(zoneId); 906 ZoneRulesBuilder bld = new ZoneRulesBuilder(); 907 for (TZDBZone tzdbZone : tzdbZones) { 908 bld = tzdbZone.addToBuilder(bld, rules); 909 } 910 ZoneRules buildRules = bld.toRules(zoneId, deduplicateMap); 911 builtZones.put(zoneId, deduplicate(buildRules)); 912 } 913 914 // build aliases 915 for (String aliasId : links.keySet()) { 916 aliasId = deduplicate(aliasId); 917 String realId = links.get(aliasId); 918 printVerbose("Linking alias " + aliasId + " to " + realId); 919 ZoneRules realRules = builtZones.get(realId); 920 if (realRules == null) { 921 realId = links.get(realId); // try again (handle alias liked to alias) 922 printVerbose("Relinking alias " + aliasId + " to " + realId); 923 realRules = builtZones.get(realId); 924 if (realRules == null) { 925 throw new IllegalArgumentException("Alias '" + aliasId + "' links to invalid zone '" + realId + "' for '" + version + "'"); 926 } 927 } 928 builtZones.put(aliasId, realRules); 929 } 930 931 // remove UTC and GMT 932 builtZones.remove("UTC"); 933 builtZones.remove("GMT"); 934 builtZones.remove("GMT0"); 935 builtZones.remove("GMT+0"); 936 builtZones.remove("GMT-0"); 937 } 938 939 //----------------------------------------------------------------------- 940 /** 941 * Deduplicates an object instance. 942 * 943 * @param <T> the generic type 944 * @param object the object to deduplicate 945 * @return the deduplicated object 946 */ 947 @SuppressWarnings("unchecked") deduplicate(T object)948 <T> T deduplicate(T object) { 949 if (deduplicateMap.containsKey(object) == false) { 950 deduplicateMap.put(object, object); 951 } 952 return (T) deduplicateMap.get(object); 953 } 954 955 //----------------------------------------------------------------------- 956 /** 957 * Prints a verbose message. 958 * 959 * @param message the message, not null 960 */ printVerbose(String message)961 private void printVerbose(String message) { 962 if (verbose) { 963 System.out.println(message); 964 } 965 } 966 967 //----------------------------------------------------------------------- 968 /** 969 * Class representing a month-day-time in the TZDB file. 970 */ 971 abstract class TZDBMonthDayTime { 972 /** The month of the cutover. */ 973 Month month = Month.JANUARY; 974 /** The day-of-month of the cutover. */ 975 int dayOfMonth = 1; 976 /** Whether to adjust forwards. */ 977 boolean adjustForwards = true; 978 /** The day-of-week of the cutover. */ 979 DayOfWeek dayOfWeek; 980 /** The time of the cutover. */ 981 LocalTime time = LocalTime.MIDNIGHT; 982 /** The time days adjustment. */ 983 int adjustDays; 984 /** The time of the cutover. */ 985 TimeDefinition timeDefinition = TimeDefinition.WALL; 986 adjustToFowards(int year)987 void adjustToFowards(int year) { 988 if (adjustForwards == false && dayOfMonth > 0) { 989 LocalDate adjustedDate = LocalDate.of(year, month, dayOfMonth).minusDays(6); 990 dayOfMonth = adjustedDate.getDayOfMonth(); 991 month = adjustedDate.getMonth(); 992 adjustForwards = true; 993 } 994 } 995 } 996 997 //----------------------------------------------------------------------- 998 /** 999 * Class representing a rule line in the TZDB file. 1000 */ 1001 final class TZDBRule extends TZDBMonthDayTime { 1002 /** The start year. */ 1003 int startYear; 1004 /** The end year. */ 1005 int endYear; 1006 /** The amount of savings. */ 1007 int savingsAmount; 1008 /** The text name of the zone. */ 1009 String text; 1010 addToBuilder(ZoneRulesBuilder bld)1011 void addToBuilder(ZoneRulesBuilder bld) { 1012 adjustToFowards(2004); // irrelevant, treat as leap year 1013 bld.addRuleToWindow(startYear, endYear, month, dayOfMonth, dayOfWeek, time, adjustDays, timeDefinition, savingsAmount); 1014 } 1015 } 1016 1017 //----------------------------------------------------------------------- 1018 /** 1019 * Class representing a linked set of zone lines in the TZDB file. 1020 */ 1021 final class TZDBZone extends TZDBMonthDayTime { 1022 /** The standard offset. */ 1023 ZoneOffset standardOffset; 1024 /** The fixed savings amount. */ 1025 Integer fixedSavingsSecs; 1026 /** The savings rule. */ 1027 String savingsRule; 1028 /** The text name of the zone. */ 1029 String text; 1030 /** The year of the cutover. */ 1031 Year year; 1032 addToBuilder(ZoneRulesBuilder bld, Map<String, List<TZDBRule>> rules)1033 ZoneRulesBuilder addToBuilder(ZoneRulesBuilder bld, Map<String, List<TZDBRule>> rules) { 1034 if (year != null) { 1035 bld.addWindow(standardOffset, toDateTime(year.getValue()), timeDefinition); 1036 } else { 1037 bld.addWindowForever(standardOffset); 1038 } 1039 1040 if (fixedSavingsSecs != null) { 1041 bld.setFixedSavingsToWindow(fixedSavingsSecs); 1042 } else { 1043 List<TZDBRule> tzdbRules = rules.get(savingsRule); 1044 if (tzdbRules == null) { 1045 throw new IllegalArgumentException("Rule not found: " + savingsRule); 1046 } 1047 for (TZDBRule tzdbRule : tzdbRules) { 1048 tzdbRule.addToBuilder(bld); 1049 } 1050 } 1051 1052 return bld; 1053 } 1054 toDateTime(int year)1055 private LocalDateTime toDateTime(int year) { 1056 adjustToFowards(year); 1057 LocalDate date; 1058 if (dayOfMonth == -1) { 1059 dayOfMonth = month.length(Year.isLeap(year)); 1060 date = LocalDate.of(year, month, dayOfMonth); 1061 if (dayOfWeek != null) { 1062 date = date.with(TemporalAdjusters.previousOrSame(dayOfWeek)); 1063 } 1064 } else { 1065 date = LocalDate.of(year, month, dayOfMonth); 1066 if (dayOfWeek != null) { 1067 date = date.with(TemporalAdjusters.nextOrSame(dayOfWeek)); 1068 } 1069 } 1070 date = deduplicate(date.plusDays(adjustDays)); 1071 return LocalDateTime.of(date, time); 1072 } 1073 } 1074 1075 //----------------------------------------------------------------------- 1076 /** 1077 * Class representing a rule line in the TZDB file. 1078 */ 1079 static final class LeapSecondRule { 1080 /** 1081 * Constructs a rule using fields. 1082 * @param leapDate Date which has gets leap second adjustment (at the end) 1083 * @param secondAdjustment +1 or -1 for inserting or dropping a second 1084 */ LeapSecondRule(LocalDate leapDate, byte secondAdjustment)1085 public LeapSecondRule(LocalDate leapDate, byte secondAdjustment) { 1086 this.leapDate = leapDate; 1087 this.secondAdjustment = secondAdjustment; 1088 } 1089 /** The date of the leap second. */ 1090 final LocalDate leapDate; 1091 /** The adjustment (in seconds), +1 means a second is inserted, 1092 * -1 means a second is dropped. */ 1093 byte secondAdjustment; 1094 } 1095 1096 } 1097