tzdataparser.rb 36 KB

1234567891011121314151617181920212223242526272829303132333435363738394041424344454647484950515253545556575859606162636465666768697071727374757677787980818283848586878889909192939495969798991001011021031041051061071081091101111121131141151161171181191201211221231241251261271281291301311321331341351361371381391401411421431441451461471481491501511521531541551561571581591601611621631641651661671681691701711721731741751761771781791801811821831841851861871881891901911921931941951961971981992002012022032042052062072082092102112122132142152162172182192202212222232242252262272282292302312322332342352362372382392402412422432442452462472482492502512522532542552562572582592602612622632642652662672682692702712722732742752762772782792802812822832842852862872882892902912922932942952962972982993003013023033043053063073083093103113123133143153163173183193203213223233243253263273283293303313323333343353363373383393403413423433443453463473483493503513523533543553563573583593603613623633643653663673683693703713723733743753763773783793803813823833843853863873883893903913923933943953963973983994004014024034044054064074084094104114124134144154164174184194204214224234244254264274284294304314324334344354364374384394404414424434444454464474484494504514524534544554564574584594604614624634644654664674684694704714724734744754764774784794804814824834844854864874884894904914924934944954964974984995005015025035045055065075085095105115125135145155165175185195205215225235245255265275285295305315325335345355365375385395405415425435445455465475485495505515525535545555565575585595605615625635645655665675685695705715725735745755765775785795805815825835845855865875885895905915925935945955965975985996006016026036046056066076086096106116126136146156166176186196206216226236246256266276286296306316326336346356366376386396406416426436446456466476486496506516526536546556566576586596606616626636646656666676686696706716726736746756766776786796806816826836846856866876886896906916926936946956966976986997007017027037047057067077087097107117127137147157167177187197207217227237247257267277287297307317327337347357367377387397407417427437447457467477487497507517527537547557567577587597607617627637647657667677687697707717727737747757767777787797807817827837847857867877887897907917927937947957967977987998008018028038048058068078088098108118128138148158168178188198208218228238248258268278288298308318328338348358368378388398408418428438448458468478488498508518528538548558568578588598608618628638648658668678688698708718728738748758768778788798808818828838848858868878888898908918928938948958968978988999009019029039049059069079089099109119129139149159169179189199209219229239249259269279289299309319329339349359369379389399409419429439449459469479489499509519529539549559569579589599609619629639649659669679689699709719729739749759769779789799809819829839849859869879889899909919929939949959969979989991000100110021003100410051006100710081009101010111012101310141015101610171018101910201021102210231024102510261027102810291030103110321033103410351036103710381039104010411042104310441045104610471048104910501051105210531054105510561057105810591060106110621063106410651066106710681069107010711072107310741075107610771078107910801081108210831084108510861087108810891090109110921093109410951096109710981099110011011102110311041105110611071108110911101111111211131114111511161117111811191120112111221123112411251126112711281129113011311132113311341135113611371138113911401141114211431144114511461147114811491150115111521153115411551156115711581159116011611162116311641165116611671168
  1. #--
  2. # Copyright (c) 2005-2010 Philip Ross
  3. #
  4. # Permission is hereby granted, free of charge, to any person obtaining a copy
  5. # of this software and associated documentation files (the "Software"), to deal
  6. # in the Software without restriction, including without limitation the rights
  7. # to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
  8. # copies of the Software, and to permit persons to whom the Software is
  9. # furnished to do so, subject to the following conditions:
  10. #
  11. # The above copyright notice and this permission notice shall be included in all
  12. # copies or substantial portions of the Software.
  13. #
  14. # THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
  15. # IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
  16. # FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
  17. # AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
  18. # LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
  19. # OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
  20. # THE SOFTWARE.
  21. #++
  22. require 'date'
  23. require 'fileutils'
  24. module TZInfo
  25. # Parses tzdata from ftp://elsie.nci.nih.gov/pub/ and transforms it into
  26. # a set of Ruby modules that can be used through Timezone and Country.
  27. #
  28. # Normally, this class wouldn't be used. It is only run to update the
  29. # timezone data and index modules.
  30. class TZDataParser
  31. # Minimum year that will be considered.
  32. MIN_YEAR = 1800
  33. # Maximum year that will be considered.
  34. MAX_YEAR = 2050
  35. # Whether to generate zone definitions (set to false to stop zones being
  36. # generated).
  37. attr_accessor :generate_zones
  38. # Whether to generate country definitions (set to false to stop countries
  39. # being generated).
  40. attr_accessor :generate_countries
  41. # Limit the set of zones to generate (set to an array containing zone
  42. # identifiers).
  43. attr_accessor :only_zones
  44. # Zones to exclude from generation when not using only_zones (set to an
  45. # array containing zone identifiers).
  46. attr_accessor :exclude_zones
  47. # Initializes a new TZDataParser. input_dir must contain the extracted
  48. # tzdata tarball. output_dir is the location to output the modules
  49. # (in definitions and indexes directories).
  50. def initialize(input_dir, output_dir)
  51. super()
  52. @input_dir = input_dir
  53. @output_dir = output_dir
  54. @rule_sets = {}
  55. @zones = {}
  56. @countries = {}
  57. @no_rules = TZDataNoRules.new
  58. @generate_zones = true
  59. @generate_countries = true
  60. @only_zones = []
  61. @exclude_zones = []
  62. end
  63. # Reads the tzdata source and generates the classes. Takes a long time
  64. # to run. Currently outputs debugging information to standard out.
  65. def execute
  66. Dir.foreach(@input_dir) {|file|
  67. load_rules(file) if file =~ /^[^\.]+$/
  68. }
  69. Dir.foreach(@input_dir) {|file|
  70. load_zones(file) if file =~ /^[^\.]+$/
  71. }
  72. Dir.foreach(@input_dir) {|file|
  73. load_links(file) if file =~ /^[^\.]+$/
  74. }
  75. load_countries
  76. if @generate_zones
  77. modules = []
  78. if @only_zones.nil? || @only_zones.empty?
  79. @zones.each_value {|zone|
  80. zone.write_module(@output_dir) unless @exclude_zones.include?(zone.name)
  81. }
  82. else
  83. @only_zones.each {|id|
  84. zone = @zones[id]
  85. zone.write_module(@output_dir)
  86. }
  87. end
  88. write_timezones_index
  89. end
  90. if @generate_countries
  91. write_countries_index
  92. end
  93. end
  94. # Parses a month specified in the tz data and converts it to a number
  95. # between 1 and 12 representing January to December.
  96. def self.parse_month(month)
  97. lower = month.downcase
  98. if lower =~ /^jan/
  99. @month = 1
  100. elsif lower =~ /^feb/
  101. @month = 2
  102. elsif lower =~ /^mar/
  103. @month = 3
  104. elsif lower =~ /^apr/
  105. @month = 4
  106. elsif lower =~ /^may/
  107. @month = 5
  108. elsif lower =~ /^jun/
  109. @month = 6
  110. elsif lower =~ /^jul/
  111. @month = 7
  112. elsif lower =~ /^aug/
  113. @month = 8
  114. elsif lower =~ /^sep/
  115. @month = 9
  116. elsif lower =~ /^oct/
  117. @month = 10
  118. elsif lower =~ /^nov/
  119. @month = 11
  120. elsif lower =~ /^dec/
  121. @month = 12
  122. else
  123. raise "Invalid month: #{month}"
  124. end
  125. end
  126. # Parses an offset string [-]h:m:s (minutes and seconds are optional). Returns
  127. # the offset in seconds.
  128. def self.parse_offset(offset)
  129. raise "Invalid time: #{offset}" if offset !~ /^(-)?(?:([0-9]+)(?::([0-9]+)(?::([0-9]+))?)?)?$/
  130. negative = !$1.nil?
  131. hour = $2.nil? ? 0 : $2.to_i
  132. minute = $3.nil? ? 0 : $3.to_i
  133. second = $4.nil? ? 0 : $4.to_i
  134. seconds = hour
  135. seconds = seconds * 60
  136. seconds = seconds + minute
  137. seconds = seconds * 60
  138. seconds = seconds + second
  139. seconds = -seconds if negative
  140. seconds
  141. end
  142. # Encloses the string in single quotes and escapes any single quotes in
  143. # the content.
  144. def self.quote_str(str)
  145. "'#{str.gsub('\'', '\\\\\'')}'"
  146. end
  147. private
  148. # Loads all the Rule definitions from the tz data and stores them in
  149. # @rule_sets.
  150. def load_rules(file)
  151. puts 'load_rules: ' + file
  152. IO.foreach(@input_dir + File::SEPARATOR + file) {|line|
  153. line = line.gsub(/#.*$/, '')
  154. line = line.gsub(/\s+$/, '')
  155. if line =~ /^Rule\s+([^\s]+)\s+([^\s]+)\s+([^\s]+)\s+([^\s]+)\s+([^\s]+)\s+([^\s]+)\s+([^\s]+)\s+([^\s]+)\s+([^\s]+)/
  156. name = $1
  157. if @rule_sets[name].nil?
  158. @rule_sets[name] = TZDataRuleSet.new(name)
  159. end
  160. @rule_sets[name].add_rule(TZDataRule.new($2, $3, $4, $5, $6, $7, $8, $9))
  161. end
  162. }
  163. end
  164. # Gets a rules object for the given reference. Might be a named rule set,
  165. # a fixed offset or an empty ruleset.
  166. def get_rules(ref)
  167. if ref == '-'
  168. @no_rules
  169. elsif ref =~ /^[0-9]+:[0-9]+$/
  170. TZDataFixedOffsetRules.new(TZDataParser.parse_offset(ref))
  171. else
  172. rule_set = @rule_sets[ref]
  173. raise "Ruleset not found: #{ref}" if rule_set.nil?
  174. rule_set
  175. end
  176. end
  177. # Loads in the Zone definitions from the tz data and stores them in @zones.
  178. def load_zones(file)
  179. puts 'load_zones: ' + file
  180. in_zone = nil
  181. IO.foreach(@input_dir + File::SEPARATOR + file) {|line|
  182. line = line.gsub(/#.*$/, '')
  183. line = line.gsub(/\s+$/, '')
  184. if in_zone
  185. if line =~ /^\s+([^\s]+)\s+([^\s]+)\s+([^\s]+)(\s+([0-9]+(\s+.*)?))?$/
  186. in_zone.add_observance(TZDataObservance.new($1, get_rules($2), $3, $5))
  187. in_zone = nil if $4.nil?
  188. end
  189. else
  190. if line =~ /^Zone\s+([^\s]+)\s+([^\s]+)\s+([^\s]+)\s+([^\s]+)(\s+([0-9]+(\s+.*)?))?$/
  191. name = $1
  192. if @zones[name].nil?
  193. @zones[name] = TZDataZone.new(name)
  194. end
  195. @zones[name].add_observance(TZDataObservance.new($2, get_rules($3), $4, $6))
  196. in_zone = @zones[name] if !$5.nil?
  197. end
  198. end
  199. }
  200. end
  201. # Loads in the links and stores them in @zones.
  202. def load_links(file)
  203. puts 'load_links: ' + file
  204. IO.foreach(@input_dir + File::SEPARATOR + file) {|line|
  205. line = line.gsub(/#.*$/, '')
  206. line = line.gsub(/\s+$/, '')
  207. if line =~ /^Link\s+([^\s]+)\s+([^\s]+)/
  208. name = $2
  209. link_to = @zones[$1]
  210. raise "Link to zone not found (#{name}->#{link_to})" if link_to.nil?
  211. raise "Zone already defined: #{name}" if !@zones[name].nil?
  212. @zones[name] = TZDataLink.new(name, link_to)
  213. end
  214. }
  215. end
  216. # Loads countries from iso3166.tab and zone.tab and stores the result in
  217. # @countries.
  218. def load_countries
  219. puts 'load_countries'
  220. IO.foreach(@input_dir + File::SEPARATOR + 'iso3166.tab') {|line|
  221. if line =~ /^([A-Z]{2})\t(.*)$/
  222. code = $1
  223. name = $2
  224. @countries[code] = TZDataCountry.new(code, name)
  225. end
  226. }
  227. IO.foreach(@input_dir + File::SEPARATOR + 'zone.tab') {|line|
  228. line.chomp!
  229. if line =~ /^([A-Z]{2})\t([^\t]+)\t([^\t]+)(\t(.*))?$/
  230. code = $1
  231. location_str = $2
  232. zone_name = $3
  233. description = $5
  234. country = @countries[code]
  235. raise "Country not found: #{code}" if country.nil?
  236. location = TZDataLocation.new(location_str)
  237. zone = @zones[zone_name]
  238. raise "Zone not found: #{zone_name}" if zone.nil?
  239. description = nil if description == ''
  240. country.add_zone(TZDataCountryTimezone.new(zone, description, location))
  241. end
  242. }
  243. end
  244. # Writes a country index file.
  245. def write_countries_index
  246. dir = @output_dir + File::SEPARATOR + 'indexes'
  247. FileUtils.mkdir_p(dir)
  248. File.open(dir + File::SEPARATOR + 'countries.rb', 'w') {|file|
  249. file.binmode
  250. file.puts('module TZInfo')
  251. file.puts(' module Indexes')
  252. file.puts(' module Countries')
  253. file.puts(' include CountryIndexDefinition')
  254. file.puts('')
  255. countries = @countries.values.sort {|c1,c2| c1.code <=> c2.code}
  256. countries.each {|country| country.write_index_record(file)}
  257. file.puts(' end') # end module Countries
  258. file.puts(' end') # end module Indexes
  259. file.puts('end') # end module TZInfo
  260. }
  261. end
  262. # Writes a timezone index file.
  263. def write_timezones_index
  264. dir = File.join(@output_dir, 'indexes')
  265. FileUtils.mkdir_p(dir)
  266. File.open(File.join(dir, 'timezones.rb'), 'w') do |file|
  267. file.binmode
  268. file.puts('module TZInfo')
  269. file.puts(' module Indexes')
  270. file.puts(' module Timezones')
  271. file.puts(' include TimezoneIndexDefinition')
  272. file.puts('')
  273. zones = @zones.values.sort {|t1,t2| t1.name <=> t2.name}
  274. zones.each {|zone| zone.write_index_record(file)}
  275. file.puts(' end') # end module Timezones
  276. file.puts(' end') # end module Indexes
  277. file.puts('end') # end module TZInfo
  278. end
  279. end
  280. end
  281. # Base class for all rule sets.
  282. class TZDataRules #:nodoc:
  283. # Name of the rule set, e.g. EU.
  284. attr_reader :name
  285. def initialize(name)
  286. @name = name
  287. end
  288. def count
  289. 0
  290. end
  291. end
  292. # Empty rule set with a fixed daylight savings (std) offset.
  293. class TZDataFixedOffsetRules < TZDataRules #:nodoc:
  294. attr_reader :offset
  295. def initialize(offset)
  296. super(offset.to_s)
  297. @offset = offset
  298. end
  299. end
  300. # An empty set of rules.
  301. class TZDataNoRules < TZDataRules #:nodoc:
  302. def initialize
  303. super('-')
  304. end
  305. end
  306. # A rule set (as defined by Rule name in the tz data).
  307. class TZDataRuleSet < TZDataRules #:nodoc:
  308. attr_reader :rules
  309. def initialize(name)
  310. super
  311. @rules = []
  312. end
  313. # Adds a new rule to the set.
  314. def add_rule(rule)
  315. @rules << rule
  316. end
  317. def count
  318. @rules.length
  319. end
  320. def each
  321. @rules.each {|rule| yield rule}
  322. end
  323. end
  324. # A rule in a RuleSet (a single Rule line in the tz data).
  325. class TZDataRule #:nodoc:
  326. attr_reader :from
  327. attr_reader :to
  328. attr_reader :type
  329. attr_reader :in_month
  330. attr_reader :on_day
  331. attr_reader :at_time
  332. attr_reader :save
  333. attr_reader :letter
  334. def initialize(from, to, type, in_month, on_day, at_time, save, letter)
  335. @from = parse_from(from)
  336. @to = parse_to(to)
  337. # replace a to of :only with the from year
  338. raise 'to cannot be only if from is minimum' if @to == :only && @from == :min
  339. @to = @from if @to == :only
  340. @type = parse_type(type)
  341. @in_month = TZDataParser.parse_month(in_month)
  342. @on_day = TZDataDayOfMonth.new(on_day)
  343. @at_time = TZDataTime.new(at_time)
  344. @save = TZDataParser.parse_offset(save)
  345. @letter = parse_letter(letter)
  346. end
  347. def activate(year)
  348. # The following test ignores yearistype at present (currently unused in
  349. # the data. parse_type currently excepts on encountering a year type.
  350. if (@from == :min || @from <= year) && (@to == :max || @to >= year)
  351. TZDataActivatedRule.new(self, year)
  352. else
  353. nil
  354. end
  355. end
  356. def at_utc_time(year, utc_offset, std_offset)
  357. @at_time.to_utc(utc_offset, std_offset,
  358. year, @in_month, @on_day.to_absolute(year, @in_month))
  359. end
  360. private
  361. def parse_from(from)
  362. lower = from.downcase
  363. if lower =~ /^min/
  364. :min
  365. elsif lower =~ /^[0-9]+$/
  366. lower.to_i
  367. else
  368. raise "Invalid from: #{from}"
  369. end
  370. end
  371. def parse_to(to)
  372. lower = to.downcase
  373. if lower =~ /^max/
  374. :max
  375. elsif lower =~ /^o/
  376. :only
  377. elsif lower =~ /^[0-9]+$/
  378. lower.to_i
  379. else
  380. raise "Invalid to: #{to}"
  381. end
  382. end
  383. def parse_type(type)
  384. raise "Unsupported rule type: #{type}" if type != '-'
  385. nil
  386. end
  387. def parse_letter(letter)
  388. if letter == '-'
  389. nil
  390. else
  391. letter
  392. end
  393. end
  394. end
  395. # Base class for Zones and Links.
  396. class TZDataDefinition #:nodoc:
  397. attr_reader :name
  398. attr_reader :name_elements
  399. attr_reader :path_elements
  400. def initialize(name)
  401. @name = name
  402. # + and - aren't allowed in class names
  403. @name_elements = name.gsub(/-/, '__m__').gsub(/\+/, '__p__').split(/\//)
  404. @path_elements = @name_elements.clone
  405. @path_elements.pop
  406. end
  407. # Creates necessary directories, the file, writes the class header and footer
  408. # and yields to a block to write the content.
  409. def create_file(output_dir)
  410. dir = output_dir + File::SEPARATOR + 'definitions' + File::SEPARATOR + @path_elements.join(File::SEPARATOR)
  411. FileUtils.mkdir_p(dir)
  412. File.open(output_dir + File::SEPARATOR + 'definitions' + File::SEPARATOR + @name_elements.join(File::SEPARATOR) + '.rb', 'w') {|file|
  413. file.binmode
  414. def file.indent(by)
  415. if @tz_indent
  416. @tz_indent += by
  417. else
  418. @tz_indent = by
  419. end
  420. end
  421. def file.puts(s)
  422. super("#{' ' * (@tz_indent || 0)}#{s}")
  423. end
  424. file.puts('module TZInfo')
  425. file.indent(2)
  426. file.puts('module Definitions')
  427. file.indent(2)
  428. @name_elements.each do |part|
  429. file.puts("module #{part}")
  430. file.indent(2)
  431. end
  432. file.puts('include TimezoneDefinition')
  433. file.puts('')
  434. yield file
  435. @name_elements.each do
  436. file.indent(-2)
  437. file.puts('end')
  438. end
  439. file.indent(-2)
  440. file.puts('end') # end module Definitions
  441. file.indent(-2)
  442. file.puts('end') # end module TZInfo
  443. }
  444. end
  445. end
  446. # A tz data Link.
  447. class TZDataLink < TZDataDefinition #:nodoc:
  448. attr_reader :link_to
  449. def initialize(name, link_to)
  450. super(name)
  451. @link_to = link_to
  452. end
  453. # Writes a module for this link.
  454. def write_module(output_dir)
  455. puts "writing link #{name}"
  456. create_file(output_dir) {|file|
  457. file.puts("linked_timezone #{TZDataParser.quote_str(@name)}, #{TZDataParser.quote_str(@link_to.name)}")
  458. }
  459. end
  460. # Writes an index record for this link.
  461. def write_index_record(file)
  462. file.puts(" linked_timezone #{TZDataParser.quote_str(@name)}")
  463. end
  464. end
  465. # A tz data Zone. Each line from the tz data is loaded as a TZDataObservance.
  466. class TZDataZone < TZDataDefinition #:nodoc:
  467. attr_reader :observances
  468. def initialize(name)
  469. super
  470. @observances = []
  471. end
  472. def add_observance(observance)
  473. @observances << observance
  474. end
  475. # Writes the module for the zone. Iterates all the periods and asks them
  476. # to write all periods in the timezone.
  477. def write_module(output_dir)
  478. puts "writing zone #{name}"
  479. create_file(output_dir) {|file|
  480. file.puts("timezone #{TZDataParser.quote_str(@name)} do |tz|")
  481. file.indent(2)
  482. transitions = find_transitions
  483. transitions.output_module(file)
  484. file.indent(-2)
  485. file.puts('end')
  486. }
  487. end
  488. # Writes an index record for this zone.
  489. def write_index_record(file)
  490. file.puts(" timezone #{TZDataParser.quote_str(@name)}")
  491. end
  492. private
  493. def find_transitions
  494. transitions = TZDataTransitions.new
  495. # algorithm from zic.c outzone
  496. start_time = nil
  497. until_time = nil
  498. @observances.each_with_index {|observance, i|
  499. std_offset = 0
  500. use_start = i > 0
  501. use_until = i < @observances.length - 1
  502. utc_offset = observance.utc_offset
  503. start_zone_id = nil
  504. start_utc_offset = observance.utc_offset
  505. start_std_offset = 0
  506. if observance.rule_set.count == 0
  507. std_offset = observance.std_offset
  508. start_zone_id = observance.format.expand(std_offset, nil)
  509. if use_start
  510. transitions << TZDataTransition.new(start_time, utc_offset, std_offset, start_zone_id)
  511. use_start = false
  512. else
  513. # zic algorithm only outputs this if std_offset is non-zero
  514. # to get the initial LMT range, we output this regardless
  515. transitions << TZDataTransition.new(nil, utc_offset, std_offset, start_zone_id)
  516. end
  517. else
  518. (TZDataParser::MIN_YEAR..TZDataParser::MAX_YEAR).each {|year|
  519. if use_until && year > observance.valid_until.year
  520. break
  521. end
  522. activated_rules = []
  523. observance.rule_set.each {|rule|
  524. activated_rule = rule.activate(year)
  525. activated_rules << activated_rule unless activated_rule.nil?
  526. }
  527. while true
  528. # turn until_time into UTC using the current utc_offset and std_offset
  529. until_time = observance.valid_until.to_utc(utc_offset, std_offset) if use_until
  530. earliest = nil
  531. activated_rules.each {|activated_rule|
  532. # recalculate the time using the current std_offset
  533. activated_rule.calculate_time(utc_offset, std_offset)
  534. earliest = activated_rule if earliest.nil? || activated_rule.at < earliest.at
  535. }
  536. break if earliest.nil?
  537. activated_rules.delete(earliest)
  538. break if use_until && earliest.at >= until_time
  539. std_offset = earliest.rule.save
  540. use_start = false if use_start && earliest.at == start_time
  541. if use_start
  542. if earliest.at < start_time
  543. start_utc_offset = observance.utc_offset
  544. start_std_offset = std_offset
  545. start_zone_id = observance.format.expand(earliest.rule.save, earliest.rule.letter)
  546. next
  547. end
  548. if start_zone_id.nil? && start_utc_offset + start_std_offset == observance.utc_offset + std_offset
  549. start_zone_id = observance.format.expand(earliest.rule.save, earliest.rule.letter)
  550. end
  551. end
  552. zone_id = observance.format.expand(earliest.rule.save, earliest.rule.letter)
  553. transitions << TZDataTransition.new(earliest.at, observance.utc_offset, earliest.rule.save, zone_id)
  554. end
  555. }
  556. end
  557. if use_start
  558. start_zone_id = observance.format.expand(nil, nil) if start_zone_id.nil? && observance.format.fixed?
  559. raise 'Could not determine time zone abbreviation to use just after until time' if start_zone_id.nil?
  560. transitions << TZDataTransition.new(start_time, start_utc_offset, start_std_offset, start_zone_id)
  561. end
  562. start_time = observance.valid_until.to_utc(utc_offset, std_offset) if use_until
  563. }
  564. transitions
  565. end
  566. end
  567. # A observance within a zone (a line within the zone definition).
  568. class TZDataObservance #:nodoc:
  569. attr_reader :utc_offset
  570. attr_reader :rule_set
  571. attr_reader :format
  572. attr_reader :valid_until
  573. def initialize(utc_offset, rule_set, format, valid_until)
  574. @utc_offset = TZDataParser.parse_offset(utc_offset)
  575. @rule_set = rule_set
  576. @format = TZDataFormat.new(format)
  577. @valid_until = valid_until.nil? ? nil : TZDataUntil.new(valid_until)
  578. end
  579. def std_offset
  580. if @rule_set.kind_of?(TZDataFixedOffsetRules)
  581. @rule_set.offset
  582. else
  583. 0
  584. end
  585. end
  586. end
  587. # Collection of TZDataTransition instances used when building a zone class.
  588. class TZDataTransitions #:nodoc:
  589. def initialize
  590. @transitions = []
  591. end
  592. def << (transition)
  593. @transitions << transition
  594. end
  595. def output_module(file)
  596. optimize
  597. # Try and end on a transition to std if one happens in the last year.
  598. if @transitions.length > 1 &&
  599. @transitions.last.std_offset != 0 &&
  600. @transitions[@transitions.length - 2].std_offset == 0 &&
  601. @transitions[@transitions.length - 2].at_utc.year == TZDataParser::MAX_YEAR
  602. transitions = @transitions[0..@transitions.length - 2]
  603. else
  604. transitions = @transitions
  605. end
  606. process_offsets(file)
  607. file.puts('')
  608. transitions.each do |t|
  609. t.write(file)
  610. end
  611. end
  612. private
  613. def optimize
  614. @transitions.sort!
  615. # Optimization logic from zic.c writezone.
  616. from_i = 0
  617. to_i = 0
  618. while from_i < @transitions.length
  619. if to_i > 1 &&
  620. !@transitions[from_i].at_utc.nil? &&
  621. !@transitions[to_i - 1].at_utc.nil? &&
  622. @transitions[from_i].at_utc + Rational(@transitions[to_i - 1].total_offset, 86400) <=
  623. @transitions[to_i - 1].at_utc + Rational(@transitions[to_i - 2].total_offset, 86400)
  624. @transitions[to_i - 1] = @transitions[from_i].clone_with_at(@transitions[to_i - 1].at_utc)
  625. from_i += 1
  626. next
  627. end
  628. # Shuffle transitions up, eliminating any redundant transitions
  629. # along the way.
  630. if to_i == 0 ||
  631. @transitions[to_i - 1].utc_offset != @transitions[from_i].utc_offset ||
  632. @transitions[to_i - 1].std_offset != @transitions[from_i].std_offset ||
  633. @transitions[to_i - 1].zone_id != @transitions[from_i].zone_id
  634. @transitions[to_i] = @transitions[from_i]
  635. to_i += 1
  636. end
  637. from_i += 1
  638. end
  639. if to_i > 0
  640. @transitions = @transitions[0..to_i - 1]
  641. else
  642. @transitions = []
  643. end
  644. end
  645. def quote_zone_id(zone_id)
  646. if zone_id =~ %r{[\-+']}
  647. ":#{TZDataParser.quote_str(zone_id)}"
  648. else
  649. ":#{zone_id}"
  650. end
  651. end
  652. def process_offsets(file)
  653. # A bit of a hack at the moment. The offset used to be output with
  654. # each period (pair of transitions). They are now separated from the
  655. # transition data. The code should probably be changed at some point to
  656. # setup the offsets at an earlier stage.
  657. # Assume that when this is called, the first transition is the Local
  658. # Mean Time initial rule or a transition with no time that defines the
  659. # offset for the entire zone.
  660. offsets = []
  661. # Find the first std offset. Timezones always start in std.
  662. @transitions.each do |t|
  663. if t.std_offset == 0
  664. offset = {:utc_offset => t.utc_offset,
  665. :std_offset => t.std_offset,
  666. :zone_id => t.zone_id,
  667. :name => 'o0'}
  668. offsets << offset
  669. break
  670. end
  671. end
  672. @transitions.each do |t|
  673. offset = offsets.find do |o|
  674. o[:utc_offset] == t.utc_offset &&
  675. o[:std_offset] == t.std_offset &&
  676. o[:zone_id] == t.zone_id
  677. end
  678. unless offset
  679. offset = {:utc_offset => t.utc_offset,
  680. :std_offset => t.std_offset,
  681. :zone_id => t.zone_id,
  682. :name => "o#{offsets.length}"}
  683. offsets << offset
  684. end
  685. t.offset_name = offset[:name]
  686. end
  687. offsets.each do |offset|
  688. file.puts("tz.offset :#{offset[:name]}, #{offset[:utc_offset]}, #{offset[:std_offset]}, #{quote_zone_id(offset[:zone_id])}")
  689. end
  690. end
  691. end
  692. # A transition that will be used to write the periods in a zone class.
  693. class TZDataTransition #:nodoc:
  694. include Comparable
  695. attr_reader :at_utc
  696. attr_reader :utc_offset
  697. attr_reader :std_offset
  698. attr_reader :zone_id
  699. attr_accessor :offset_name
  700. def initialize(at_utc, utc_offset, std_offset, zone_id)
  701. @at_utc = at_utc
  702. @utc_offset = utc_offset
  703. @std_offset = std_offset
  704. @zone_id = zone_id
  705. @offset_name = nil
  706. end
  707. def to_s
  708. "At #{at_utc} UTC switch to UTC offset #{@utc_offset} with std offset #{@std_offset}, zone id #{@zone_id}"
  709. end
  710. def <=>(transition)
  711. if @at_utc == transition.at_utc
  712. 0
  713. elsif @at_utc.nil?
  714. -1
  715. elsif transition.nil?
  716. 1
  717. else
  718. @at_utc - transition.at_utc
  719. end
  720. end
  721. def total_offset
  722. @utc_offset + @std_offset
  723. end
  724. def clone_with_at(at_utc)
  725. TZDataTransition.new(at_utc, @utc_offset, @std_offset, @zone_id)
  726. end
  727. def write(file)
  728. if @at_utc
  729. file.puts "tz.transition #{@at_utc.year}, #{@at_utc.mon}, :#{@offset_name}, #{datetime_constructor(@at_utc)}"
  730. end
  731. end
  732. private
  733. def datetime_constructor(datetime)
  734. if (1970..2037).include?(datetime.year)
  735. "#{Time.utc(datetime.year, datetime.mon, datetime.mday, datetime.hour, datetime.min, datetime.sec).to_i}"
  736. else
  737. "#{datetime.ajd.numerator}, #{datetime.ajd.denominator}"
  738. end
  739. end
  740. end
  741. # An instance of a rule for a year.
  742. class TZDataActivatedRule #:nodoc:
  743. attr_reader :rule
  744. attr_reader :year
  745. attr_reader :at
  746. def initialize(rule, year)
  747. @rule = rule
  748. @year = year
  749. @at = nil
  750. end
  751. def calculate_time(utc_offset, std_offset)
  752. @at = @rule.at_utc_time(@year, utc_offset, std_offset)
  753. end
  754. end
  755. # A tz data time definition - an hour, minute, second and reference. Reference
  756. # is either :utc, :standard or :wall_clock.
  757. class TZDataTime #:nodoc:
  758. attr_reader :hour
  759. attr_reader :minute
  760. attr_reader :second
  761. attr_reader :ref
  762. def initialize(spec)
  763. raise "Invalid time: #{spec}" if spec !~ /^([0-9]+)(:([0-9]+)(:([0-9]+))?)?([wguzs])?$/
  764. @hour = $1.to_i
  765. @minute = $3.nil? ? 0 : $3.to_i
  766. @second = $5.nil? ? 0 : $5.to_i
  767. if $6 == 's'
  768. @ref = :standard
  769. elsif $6 == 'g' || $6 == 'u' || $6 == 'z'
  770. @ref = :utc
  771. else
  772. @ref = :wall_clock
  773. end
  774. end
  775. # Converts the time to UTC given a utc_offset and std_offset.
  776. def to_utc(utc_offset, std_offset, year, month, day)
  777. result = DateTime.new(year, month, day, @hour, @minute, @second)
  778. offset = 0
  779. offset = offset + utc_offset if @ref == :standard || @ref == :wall_clock
  780. offset = offset + std_offset if @ref == :wall_clock
  781. result - Rational(offset, 86400)
  782. end
  783. end
  784. # A tz data day of the month reference. Can either be an absolute day,
  785. # a last week day or a week day >= or <= than a specific day of month.
  786. class TZDataDayOfMonth #:nodoc:
  787. attr_reader :type
  788. attr_reader :day_of_month
  789. attr_reader :day_of_week
  790. attr_reader :operator
  791. def initialize(spec)
  792. raise "Invalid on: #{spec}" if spec !~ /^([0-9]+)|(last([A-z]+))|(([A-z]+)([<>]=)([0-9]+))$/
  793. if $1
  794. @type = :absolute
  795. @day_of_month = $1.to_i
  796. elsif $3
  797. @type = :last
  798. @day_of_week = parse_day_of_week($3)
  799. else
  800. @type = :comparison
  801. @day_of_week = parse_day_of_week($5)
  802. @operator = parse_operator($6)
  803. @day_of_month = $7.to_i
  804. end
  805. end
  806. # Returns the absolute day of month for the given year and month.
  807. def to_absolute(year, month)
  808. case @type
  809. when :last
  810. last_day_in_month = (Date.new(year, month, 1) >> 1) - 1
  811. offset = last_day_in_month.wday - @day_of_week
  812. offset = offset + 7 if offset < 0
  813. (last_day_in_month - offset).day
  814. when :comparison
  815. pivot = Date.new(year, month, @day_of_month)
  816. offset = @day_of_week - pivot.wday
  817. offset = -offset if @operator == :less_equal
  818. offset = offset + 7 if offset < 0
  819. offset = -offset if @operator == :less_equal
  820. result = pivot + offset
  821. if result.month != pivot.month
  822. puts self.inspect
  823. puts year
  824. puts month
  825. end
  826. raise 'No suitable date found' if result.month != pivot.month
  827. result.day
  828. else #absolute
  829. @day_of_month
  830. end
  831. end
  832. private
  833. def parse_day_of_week(day_of_week)
  834. lower = day_of_week.downcase
  835. if lower =~ /^mon/
  836. 1
  837. elsif lower =~ /^tue/
  838. 2
  839. elsif lower =~ /^wed/
  840. 3
  841. elsif lower =~ /^thu/
  842. 4
  843. elsif lower =~ /^fri/
  844. 5
  845. elsif lower =~ /^sat/
  846. 6
  847. elsif lower =~ /^sun/
  848. 0
  849. else
  850. raise "Invalid day of week: #{day_of_week}"
  851. end
  852. end
  853. def parse_operator(operator)
  854. if operator == '>='
  855. :greater_equal
  856. elsif operator == '<='
  857. :less_equal
  858. else
  859. raise "Invalid operator: #{operator}"
  860. end
  861. end
  862. end
  863. # A tz data Zone until reference.
  864. class TZDataUntil #:nodoc:
  865. attr_reader :year
  866. attr_reader :month
  867. attr_reader :day
  868. attr_reader :time
  869. def initialize(spec)
  870. parts = spec.split(/\s+/)
  871. raise "Invalid until: #{spec}" if parts.length < 1
  872. @year = parts[0].to_i
  873. @month = parts.length > 1 ? TZDataParser.parse_month(parts[1]) : 1
  874. @day = TZDataDayOfMonth.new(parts.length > 2 ? parts[2] : '1')
  875. @time = TZDataTime.new(parts.length > 3 ? parts[3] : '00:00')
  876. end
  877. # Converts the reference to a UTC DateTime.
  878. def to_utc(utc_offset, std_offset)
  879. @time.to_utc(utc_offset, std_offset, @year, @month, @day.to_absolute(@year, @month))
  880. end
  881. end
  882. # A tz data Zone format string. Either alternate standard/daylight-savings,
  883. # substitution (%s) format or a fixed string.
  884. class TZDataFormat #:nodoc:
  885. def initialize(spec)
  886. if spec =~ /([A-z]+)\/([A-z]+)/
  887. @type = :alternate
  888. @standard_abbrev = $1
  889. @daylight_abbrev = $2
  890. elsif spec =~ /%s/
  891. @type = :subst
  892. @abbrev = spec
  893. else
  894. @type = :fixed
  895. @abbrev = spec
  896. end
  897. end
  898. # Expands given the current daylight savings offset and Rule string.
  899. def expand(std_offset, rule_string)
  900. if @type == :alternate
  901. if std_offset == 0
  902. @standard_abbrev
  903. else
  904. @daylight_abbrev
  905. end
  906. elsif @type == :subst
  907. sprintf(@abbrev, rule_string)
  908. else
  909. @abbrev
  910. end
  911. end
  912. # True if a string from the rule is required to expand this format.
  913. def requires_rule_string?
  914. @type == :subst
  915. end
  916. # Is a fixed format string.
  917. def fixed?
  918. @type == :fixed
  919. end
  920. end
  921. # A location (latitude + longitude)
  922. class TZDataLocation #:nodoc:
  923. attr_reader :latitude
  924. attr_reader :longitude
  925. # Constructs a new TZDataLocation from a string in ISO 6709
  926. # sign-degrees-minutes-seconds format, either +-DDMM+-DDDMM
  927. # or +-DDMMSS+-DDDMMSS, first latitude (+ is north),
  928. # then longitude (+ is east).
  929. def initialize(coordinates)
  930. if coordinates !~ /^([+\-])([0-9]{2})([0-9]{2})([0-9]{2})?([+\-])([0-9]{3})([0-9]{2})([0-9]{2})?$/
  931. raise "Invalid coordinates: #{coordinates}"
  932. end
  933. @latitude = Rational($2.to_i) + Rational($3.to_i, 60)
  934. @latitude += Rational($4.to_i, 3600) unless $4.nil?
  935. @latitude = -@latitude if $1 == '-'
  936. @longitude = Rational($6.to_i) + Rational($7.to_i, 60)
  937. @longitude += Rational($8.to_i, 3600) unless $8.nil?
  938. @longitude = -@longitude if $5 == '-'
  939. end
  940. end
  941. TZDataCountryTimezone = Struct.new(:timezone, :description, :location)
  942. # An ISO 3166 country.
  943. class TZDataCountry #:nodoc:
  944. attr_reader :code
  945. attr_reader :name
  946. attr_reader :zones
  947. def initialize(code, name)
  948. @code = code
  949. @name = name
  950. @zones = []
  951. end
  952. # Adds a TZDataCountryTimezone
  953. def add_zone(zone)
  954. @zones << zone
  955. end
  956. def write_index_record(file)
  957. s = " country #{TZDataParser.quote_str(@code)}, #{TZDataParser.quote_str(@name)}"
  958. s << ' do |c|' if @zones.length > 0
  959. file.puts s
  960. @zones.each do |zone|
  961. file.puts " c.timezone #{TZDataParser.quote_str(zone.timezone.name)}, #{zone.location.latitude.numerator}, #{zone.location.latitude.denominator}, #{zone.location.longitude.numerator}, #{zone.location.longitude.denominator}#{zone.description.nil? ? '' : ', ' + TZDataParser.quote_str(zone.description)}"
  962. end
  963. file.puts ' end' if @zones.length > 0
  964. end
  965. end
  966. end