File Coverage

blib/lib/Time/TZif/POSIX.pm
Criterion Covered Total %
statement 222 224 99.1
branch 151 172 87.7
condition 82 104 78.8
subroutine 33 33 100.0
pod 13 13 100.0
total 501 546 91.7


line stmt bran cond sub pod time code
1             package Time::TZif::POSIX;
2 2     2   1077 use strict;
  2         3  
  2         58  
3 2     2   5 use warnings;
  2         3  
  2         101  
4 2     2   19 use v5.10;
  2         11  
5              
6             our $VERSION = '0.91';
7              
8 2     2   11 use Carp qw[croak];
  2         3  
  2         110  
9 2         144 use Time::Str::Calendar qw[leap_year
10             month_days
11             nth_dow_in_month
12             ymd_to_dow
13             yd_to_md
14 2     2   976 rdn_to_ymd];
  2         5  
15 2         110 use Time::Str::Time qw[ gmtime_year
16 2     2   321 timegm_modern ];
  2         3  
17 2         119 use Time::Str::Util qw[upper_bound
18 2     2   348 valid_tzdb_timezone];
  2         3  
19              
20             my %ValidPolicy = (
21             earlier => 1, later => 1, std => 1, dst => 1, reject => 1
22             );
23              
24 2     2   9 use constant RDN_UNIX_EPOCH => 719163; # 1970-01-01
  2         2  
  2         5671  
25              
26             # POSIX TZ string (IEEE Std 1003.1)
27             #
28             # std offset [dst [offset] , start [/time] , end [/time]]
29             #
30             # Examples:
31             # EST5EDT,M3.2.0,M11.1.0 US Eastern
32             # CET-1CEST,M3.5.0/2,M10.5.0/3 Central European
33             # <+05>-5 Fixed UTC+5
34             # NZST-12NZDT,M9.5.0,M4.1.0/3 New Zealand
35             #
36             my $POSIX_TZ_Rx = qr{
37             (?(DEFINE)
38             (? [A-Za-z]{3,} | [<][A-Za-z0-9+-]{3,}[>] )
39             (? [+-]? [0-9]{1,2} (?: [:][0-9]{2} (?: [:][0-9]{2} )? )? )
40             (?
41             (? M [0-9]{1,2} [.] [1-5] [.] [0-6]
42             | J [0-9]{1,3}
43             | [0-9]{1,3} )
44             )
45              
46             \A
47             (? (?&Name)) (? (?&Offset))
48             (?:
49             (? (?&Name)) (? (?&Offset) )?
50             [,] (? (?&Rule)) (?: [/] (? (?&Time)) )?
51             [,] (? (?&Rule)) (?: [/] (? (?&Time)) )?
52             )?
53             \z
54             }x;
55              
56             my $Rule_Rx = qr{
57             \A
58             (?:
59             M (? [0-9]{1,2}) [.] (? [1-5]) [.] (? [0-6])
60             | J (? [0-9]{1,3})
61             | (? [0-9]{1,3})
62             )
63             \z
64             }x;
65              
66             sub new {
67 86 100 66 86 1 192244 (@_ & 1 && @_ >= 3) or croak q/Usage: Time::TZif::POSIX->new(tz_string => $string)/;
68 85         237 my ($class, %p) = @_;
69              
70 85         133 my (%state, $tz_string);
71              
72 85         236 while (my ($key, $v) = each %p) {
73 131 100       232 if ($key eq 'tz_string') {
    100          
    100          
    100          
74 81         189 $tz_string = $v;
75             }
76             elsif ($key eq 'name') {
77 5 100       10 valid_tzdb_timezone($v)
78             or croak qq/Invalid value for the parameter 'name'/;
79 3         12 $state{name} = $v;
80             }
81             elsif ($key eq 'gap_policy') {
82 22 100 66     132 (defined $v && exists $ValidPolicy{$v})
83             or croak qq/Invalid policy value for the parameter 'gap_policy'/;
84 21         51 $state{gap_policy} = $v;
85             }
86             elsif ($key eq 'overlap_policy') {
87 21 100 66     140 (defined $v && exists $ValidPolicy{$v})
88             or croak qq/Invalid policy value for the parameter 'overlap_policy'/;
89 20         51 $state{overlap_policy} = $v;
90             }
91             else {
92 2         142 croak qq/Unrecognised named parameter: '$key'/;
93             }
94             }
95              
96 79 100       187 (defined $tz_string)
97             or croak q/Parameter 'tz_string' is required/;
98              
99 78         128 $state{tz_string} = $tz_string;
100 78   100     227 $state{gap_policy} //= 'reject';
101 78   100     179 $state{overlap_policy} //= 'reject';
102              
103 78         111 my $self = bless \%state, $class;
104 78         168 $self->_parse($tz_string);
105 61         207 return $self;
106             }
107              
108             sub _with {
109 4     4   8 my ($object, %with) = @_;
110 4         5 return bless { %{$object}, %with }, ref $object;
  4         25  
111             }
112              
113             sub name {
114 4 50   4 1 1137 @_ == 1 or croak q/Usage: $tz->name()/;
115 4         14 return $_[0]->{name};
116             }
117              
118             sub tz_string {
119 3 50   3 1 7 @_ == 1 or croak q/Usage: $tz->tz_string()/;
120 3         10 return $_[0]->{tz_string};
121             }
122              
123             sub gap_policy {
124 7 50   7 1 632 @_ == 1 or croak q/Usage: $tz->gap_policy()/;
125 7         18 return $_[0]->{gap_policy};
126             }
127              
128             sub overlap_policy {
129 5 50   5 1 567 @_ == 1 or croak q/Usage: $tz->overlap_policy()/;
130 5         15 return $_[0]->{overlap_policy};
131             }
132              
133             sub has_name {
134 2 50   2 1 6 @_ == 1 or croak q/Usage: $tz->has_name()/;
135 2         7 return exists $_[0]->{name};
136             }
137              
138             sub with_name {
139 5 100   5 1 122 @_ == 2 or croak q/Usage: $tz->with_name($name)/;
140 4         6 my ($self, $name) = @_;
141              
142 4 100       8 valid_tzdb_timezone($name)
143             or croak qq/Invalid name value/;
144              
145 3 100 100     12 if (!exists $self->{name} || $name ne $self->{name}) {
146 2         5 return _with($self, name => $name);
147             }
148 1         2 return $self;
149             }
150              
151             sub with_gap_policy {
152 4 100   4 1 92 @_ == 2 or croak q/Usage: $tz->with_gap_policy($policy)/;
153 3         4 my ($self, $policy) = @_;
154              
155 3 100 66     90 (defined $policy && exists $ValidPolicy{$policy})
156             or croak qq/Invalid policy value/;
157              
158 2 100       4 if ($policy ne $self->{gap_policy}) {
159 1         3 return _with($self, gap_policy => $policy);
160             }
161 1         2 return $self;
162             }
163              
164             sub with_overlap_policy {
165 4 100   4 1 90 @_ == 2 or croak q/Usage: $tz->with_overlap_policy($policy)/;
166 3         5 my ($self, $policy) = @_;
167              
168 3 100 66     83 (defined $policy && exists $ValidPolicy{$policy})
169             or croak qq/Invalid policy value/;
170              
171 2 100       5 if ($policy ne $self->{overlap_policy}) {
172 1         2 return _with($self, overlap_policy => $policy);
173             }
174 1         2 return $self;
175             }
176              
177             sub _parse_offset {
178 75     75   115 my ($str) = @_;
179 75 50       263 $str =~ /\A ([+-]?) ([0-9]{1,2}) (?: [:]([0-9]{2}) (?: [:]([0-9]{2}) )? )? \z/x
180             or croak qq/Unable to parse POSIX TZ string: invalid offset '$str'/;
181 75   100     406 my ($h, $m, $s) = ($2, $3 // 0, $4 // 0);
      100        
182 75 100 100     585 ($h <= 24 && $m <= 59 && $s <= 59)
      100        
183             or croak qq/Unable to parse POSIX TZ string: offset time is out of range: $str/;
184 71         91 my $secs = $h * 3600 + $m * 60 + $s;
185 71 100       190 return ($1 eq '-') ? -$secs : $secs;
186             }
187              
188             sub _parse_rule_time {
189 63     63   79 my ($str) = @_;
190 63 50       172 $str =~ /\A ([+-]?) ([0-9]{1,3}) (?: [:]([0-9]{2}) (?: [:]([0-9]{2}) )? )? \z/x
191             or croak qq/Unable to parse POSIX TZ string: invalid rule time '$str'/;
192 63   50     223 my ($h, $m, $s) = ($2, $3 // 0, $4 // 0);
      50        
193 63 100 100     426 ($h <= 167 && $m <= 59 && $s <= 59)
      100        
194             or croak qq/Unable to parse POSIX TZ string: rule time is out of range: $str/;
195 60         65 my $secs = $h * 3600 + $m * 60 + $s;
196 60 100       124 return ($1 eq '-') ? -$secs : $secs;
197             }
198              
199             sub _parse_rule {
200 112     112   179 my ($rule_str, $time_str) = @_;
201              
202 112 100       179 my $time = defined $time_str ? _parse_rule_time($time_str) : 7200;
203              
204 109 50       448 $rule_str =~ $Rule_Rx
205             or croak qq/Unable to parse POSIX TZ string: invalid rule '$rule_str'/;
206              
207 109 100       348 if (exists $+{month}) {
    100          
208 96         460 my ($m, $w, $d) = @+{qw(month week wday)};
209 96 100 100     443 ($m >= 1 && $m <= 12)
210             or croak qq/Unable to parse POSIX TZ string: rule month out of range [1, 12]: $m/;
211 94 100       127 my $nth = ($w == 5) ? -1 : $w;
212 94         124 my $dow = 1 + ($d + 6) % 7;
213 94         401 return { type => 'M', month => $m, nth => $nth, day => $dow, time => $time };
214             }
215             elsif (exists $+{jday}) {
216 6         15 my $jday = $+{jday};
217 6 100 100     180 ($jday >= 1 && $jday <= 365)
218             or croak qq/Unable to parse POSIX TZ string: Julian day out of range [1, 365]: $jday/;
219 4         16 return { type => 'J', day => $jday, time => $time };
220             }
221             else {
222 7         19 my $nday = $+{nday};
223 7 100 66     104 ($nday >= 0 && $nday <= 365)
224             or croak qq/Unable to parse POSIX TZ string: zero-based day out of range [0, 365]: $nday/;
225 6         23 return { type => 'N', day => $nday + 1, time => $time };
226             }
227             }
228              
229             sub _parse {
230 78     78   101 my ($self, $str) = @_;
231              
232 78 100       1725 $str =~ $POSIX_TZ_Rx
233             or croak qq/Unable to parse POSIX TZ string: '$str'/;
234              
235 73         1193 my %m = %+;
236              
237 73         303 (my $std_name = $m{std_name}) =~ s/[<>]//g;
238 73         160 my $std_offset = -_parse_offset($m{std_offset});
239              
240 70 50 33     148 ($std_offset >= -86400 && $std_offset <= 86400)
241             or croak qq/Unable to parse POSIX TZ string: standard offset out of range: $std_offset/;
242              
243 70         136 $self->{std_type} = [$std_offset, 0, $std_name];
244              
245 70 100       127 return unless defined $m{dst_name};
246              
247 61         93 (my $dst_name = $m{dst_name}) =~ s/[<>]//g;
248              
249             my $dst_offset = defined $m{dst_offset}
250             ? -_parse_offset($m{dst_offset})
251 61 100       85 : $std_offset + 3600;
252              
253 60 50 33     143 ($dst_offset >= -86400 && $dst_offset <= 86400)
254             or croak qq/Unable to parse POSIX TZ string: daylight offset out of range: $dst_offset/;
255              
256 60         114 $self->{dst_type} = [$dst_offset, 1, $dst_name];
257 60         115 $self->{dst_start} = _parse_rule($m{rule_start}, $m{time_start});
258 52         96 $self->{dst_end} = _parse_rule($m{rule_end}, $m{time_end});
259              
260             # Precompute the type sequence for the 3-year transition window.
261             # For a given POSIX TZ string, DST start always falls before or
262             # after DST end within each year (northern vs southern hemisphere).
263             # This order never changes between years, so we determine it once
264             # here and reuse the fixed type array in _transitions_for_time().
265             # A leap year is used to avoid day-of-year overflow with n=365 rules.
266 52         101 my ($t0, $t1) = $self->_transitions_for_year(2024);
267 52 100       94 if ($t0 <= $t1) {
268             # Northern: start < end -> types alternate dst, std
269             $self->{types_3y} = [$self->{std_type},
270 50         124 ($self->{dst_type}, $self->{std_type}) x 3];
271             }
272             else {
273             # Southern: end < start -> types alternate std, dst
274             $self->{types_3y} = [$self->{dst_type},
275 2         8 ($self->{std_type}, $self->{dst_type}) x 3];
276             }
277              
278             # Detect whether transitions can fall outside the calendar year.
279             # For each rule, compute the worst-case calendar date and check
280             # whether rule_time - offset can push the UTC epoch past Jan 1.
281             #
282             # Forward (into next year):
283             # UTC = midnight(Dec D) + time - offset >= midnight(Jan 1)
284             # time - offset >= (32 - D) * 86400
285             #
286             # Backward (into previous year):
287             # UTC = midnight(Jan D) + time - offset < midnight(Jan 1)
288             # time - offset < -(D - 1) * 86400
289 52         57 my $cross_year = 0;
290 52         117 for my $pair (
291             [$self->{dst_start}, $self->{std_type}[0]],
292             [$self->{dst_end}, $self->{dst_type}[0]],
293             ) {
294 103         116 my ($r, $off) = @$pair;
295              
296             # Forward: rule in December pushing into next year
297 103         95 my $max_day;
298 103 100 100     345 if ($r->{type} eq 'M' && $r->{month} == 12) {
    100 100        
299 2 50       4 $max_day = $r->{nth} == -1 ? 31 : $r->{nth} * 7;
300             }
301             elsif ($r->{type} ne 'M' && $r->{day} >= 359) {
302 3         3 $max_day = 31;
303             }
304 103 100       114 if (defined $max_day) {
305 5 100       9 $cross_year = 1 if $r->{time} - $off >= (32 - $max_day) * 86400;
306             }
307              
308             # Backward: rule in January pushing into previous year
309 103         87 my $min_day;
310 103 100 100     276 if ($r->{type} eq 'M' && $r->{month} == 1) {
    100 100        
311 1 50       4 $min_day = $r->{nth} == -1 ? 25 : ($r->{nth} - 1) * 7 + 1;
312             }
313             elsif ($r->{type} ne 'M' && $r->{day} <= 7) {
314 2         3 $min_day = 1;
315             }
316 103 100       124 if (defined $min_day) {
317 3 100       8 $cross_year = 1 if $r->{time} - $off < -($min_day - 1) * 86400;
318             }
319              
320 103 100       168 last if $cross_year;
321             }
322 52         177 $self->{cross_year} = $cross_year;
323             }
324              
325             # Resolves a transition rule to a UTC epoch for the given year.
326             # $offset is the UTC offset in effect before the transition (wall clock).
327             sub _rule_to_epoch {
328 206     206   236 my ($self, $rule, $year, $offset) = @_;
329              
330 206         184 my ($month, $day);
331              
332 206 100       279 if ($rule->{type} eq 'M') {
    100          
333 178         187 $month = $rule->{month};
334 178         319 $day = nth_dow_in_month($year, $month, $rule->{nth}, $rule->{day});
335             }
336             elsif ($rule->{type} eq 'J') {
337 8         9 my $doy = $rule->{day};
338 8 100 100     25 $doy++ if $doy >= 60 && leap_year($year);
339 8         18 ($month, $day) = yd_to_md($year, $doy);
340             }
341             else {
342 20         22 my $doy = $rule->{day};
343 20 50 66     31 $doy-- if $doy == 366 && !leap_year($year);
344 20         35 ($month, $day) = yd_to_md($year, $doy);
345             }
346              
347             # rule time is wall clock; subtract offset to convert to UTC
348 206         438 return timegm_modern(0, 0, 0, $day, $month, $year) + $rule->{time} - $offset;
349             }
350              
351             sub _transitions_for_year {
352 103     103   141 my ($self, $year) = @_;
353              
354             my $t_start = $self->_rule_to_epoch(
355 103         184 $self->{dst_start}, $year, $self->{std_type}[0]);
356             my $t_end = $self->_rule_to_epoch(
357 103         149 $self->{dst_end}, $year, $self->{dst_type}[0]);
358              
359 103         156 return ($t_start, $t_end);
360             }
361              
362             # Returns \@times for the 3-year window around $time.
363             sub _transitions_window_for_year {
364 35     35   65 my ($self, $year) = @_;
365              
366 35         39 my @times;
367 35 100       68 for my $y ($self->{cross_year} ? ($year - 1, $year, $year + 1) : $year) {
368 51         69 my ($t_start, $t_end) = $self->_transitions_for_year($y);
369 51 100       80 if ($t_start <= $t_end) {
370 48         80 push @times, $t_start, $t_end;
371             }
372             else {
373 3         7 push @times, $t_end, $t_start;
374             }
375             }
376              
377 35         112 return \@times;
378             }
379              
380             sub _transitions_for_time {
381 114     114   161 my ($self, $time) = @_;
382              
383 114         239 my $year = gmtime_year($time);
384 114         132 my $year_index = $year - 1990;
385 114   100     254 my $cache = $self->{cache_years} //= [];
386 114         108 my $times;
387              
388 114 50 33     320 if ($year_index >= 0 && $year_index < 50) {
389 114   66     262 $times = $cache->[$year_index] //= $self->_transitions_window_for_year($year);
390             }
391             else {
392 0         0 $times = $self->_transitions_window_for_year($year);
393             }
394              
395 114         200 return ($times, $self->{types_3y});
396             }
397              
398             sub _type_for_utc {
399 78     78   89 my ($self, $time) = @_;
400              
401 78 100       158 return $self->{std_type} unless exists $self->{dst_start};
402              
403 67         103 my ($times, $types) = $self->_transitions_for_time($time);
404              
405 67         353 return $types->[ upper_bound($times, $time) ];
406             }
407              
408             sub offset_for_utc {
409 69 100   69 1 9114 @_ == 2 or croak q/Usage: $tz->offset_for_utc($time)/;
410 68         117 my ($self, $time) = @_;
411 68         129 return $self->_type_for_utc($time)->[0];
412             }
413              
414             sub type_info_for_utc {
415 11 100   11 1 428 @_ == 2 or croak q/Usage: $tz->type_info_for_utc($time)/;
416 10         16 my ($self, $time) = @_;
417 10         10 return @{$self->_type_for_utc($time)};
  10         39  
418             }
419              
420             sub offset_for_local {
421 50 50   50 1 7493 @_ >= 2 or croak q/Usage: $tz->offset_for_local($time, %opts)/;
422 50         76 my $type = &_resolve_local;
423 44         239 return $type->[0];
424             }
425              
426             sub type_info_for_local {
427 4 50   4 1 4006 @_ >= 2 or croak q/Usage: $tz->type_info_for_local($time, %opts)/;
428 4         8 my $type = &_resolve_local;
429 3         9 return @$type;
430             }
431              
432             sub _resolve_local {
433 54 50 33 54   202 ((@_ & 1) == 0 && @_ >= 2) or croak q/Usage: $tz->offset_for_local($time, %opts)/;
434 54         94 my ($self, $time, %p) = @_;
435              
436 54         66 my ($gap_policy, $overlap_policy);
437              
438 54         121 while (my ($key, $v) = each %p) {
439 20 100       44 if ($key eq 'gap_policy') {
    100          
440 6 100 66     94 (defined $v && exists $ValidPolicy{$v})
441             or croak qq/Invalid policy value for the parameter 'gap_policy'/;
442 5         14 $gap_policy = $v;
443             }
444             elsif ($key eq 'overlap_policy') {
445 12 100 66     111 (defined $v && exists $ValidPolicy{$v})
446             or croak qq/Invalid policy value for the parameter 'overlap_policy'/;
447 11         28 $overlap_policy = $v;
448             }
449             else {
450 2         146 croak qq/Unrecognised named parameter: '$key'/;
451             }
452             }
453              
454 50 100       87 return $self->{std_type} unless exists $self->{dst_start};
455              
456 47         107 my ($times, $types) = $self->_transitions_for_time($time);
457              
458 47 50       77 return $types->[0] unless @$times;
459              
460 47         53 my $result_idx = 0;
461              
462 47         76 for (my $i = 0; $i < @$times; $i++) {
463 92         93 my $boundary = $time - $times->[$i];
464 92         115 my $prev = $types->[$i];
465 92         98 my $next = $types->[$i + 1];
466 92         88 my $prev_off = $prev->[0];
467 92         82 my $next_off = $next->[0];
468              
469 92 100       131 if ($prev_off < $next_off) {
    50          
470             # Spring forward: gap in [prev_off, next_off)
471 51 100 100     102 if ($prev_off <= $boundary && $boundary < $next_off) {
472 10   66     23 $gap_policy //= $self->{gap_policy};
473 10         17 return _apply_policy($gap_policy, $prev, $next,
474             'Unable to resolve local time: non-existing time (gap)');
475             }
476 41 100       93 $result_idx = $i + 1 if $boundary >= $next_off;
477             }
478             elsif ($prev_off > $next_off) {
479             # Fall back: overlap in [next_off, prev_off)
480 41 100 100     108 if ($next_off <= $boundary && $boundary < $prev_off) {
481 15   66     74 $overlap_policy //= $self->{overlap_policy};
482 15         38 return _apply_policy($overlap_policy, $prev, $next,
483             'Unable to resolve local time: ambiguous time (overlap)');
484             }
485 26 100       67 $result_idx = $i + 1 if $boundary >= $prev_off;
486             }
487             else {
488 0 0       0 $result_idx = $i + 1 if $boundary >= $prev_off;
489             }
490             }
491              
492 22         37 return $types->[$result_idx];
493             }
494              
495             sub _apply_policy {
496 25     25   39 my ($policy, $prev, $next, $message) = @_;
497              
498 25 100       49 if ($policy eq 'earlier') { return $prev }
  9 100       26  
    100          
    100          
499 9         49 elsif ($policy eq 'later') { return $next }
500             elsif ($policy eq 'std') {
501 2 100       7 return $prev->[1] ? $next : $prev;
502             }
503             elsif ($policy eq 'dst') {
504 2 100       9 return $prev->[1] ? $prev : $next;
505             }
506             else {
507 3         278 croak $message;
508             }
509             }
510              
511             1;