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   999 use strict;
  2         3  
  2         69  
3 2     2   6 use warnings;
  2         2  
  2         64  
4 2     2   16 use v5.10;
  2         4  
5              
6             our $VERSION = '0.92';
7              
8 2     2   8 use Carp qw[croak];
  2         8  
  2         101  
9 2         139 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   987 rdn_to_ymd];
  2         4  
15 2         83 use Time::Str::Time qw[ gmtime_year
16 2     2   311 timegm_modern ];
  2         4  
17 2         108 use Time::Str::Util qw[upper_bound
18 2     2   291 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         5507  
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 187536 (@_ & 1 && @_ >= 3) or croak q/Usage: Time::TZif::POSIX->new(tz_string => $string)/;
68 85         256 my ($class, %p) = @_;
69              
70 85         125 my (%state, $tz_string);
71              
72 85         234 while (my ($key, $v) = each %p) {
73 130 100       260 if ($key eq 'tz_string') {
    100          
    100          
    100          
74 80         188 $tz_string = $v;
75             }
76             elsif ($key eq 'name') {
77 5 100       18 valid_tzdb_timezone($v)
78             or croak qq/Invalid value for the parameter 'name'/;
79 3         15 $state{name} = $v;
80             }
81             elsif ($key eq 'gap_policy') {
82 22 100 66     139 (defined $v && exists $ValidPolicy{$v})
83             or croak qq/Invalid policy value for the parameter 'gap_policy'/;
84 21         63 $state{gap_policy} = $v;
85             }
86             elsif ($key eq 'overlap_policy') {
87 21 100 66     141 (defined $v && exists $ValidPolicy{$v})
88             or croak qq/Invalid policy value for the parameter 'overlap_policy'/;
89 20         52 $state{overlap_policy} = $v;
90             }
91             else {
92 2         167 croak qq/Unrecognised named parameter: '$key'/;
93             }
94             }
95              
96 79 100       177 (defined $tz_string)
97             or croak q/Parameter 'tz_string' is required/;
98              
99 78         114 $state{tz_string} = $tz_string;
100 78   100     238 $state{gap_policy} //= 'reject';
101 78   100     182 $state{overlap_policy} //= 'reject';
102              
103 78         118 my $self = bless \%state, $class;
104 78         165 $self->_parse($tz_string);
105 61         216 return $self;
106             }
107              
108             sub _with {
109 4     4   9 my ($object, %with) = @_;
110 4         4 return bless { %{$object}, %with }, ref $object;
  4         32  
111             }
112              
113             sub name {
114 4 50   4 1 1268 @_ == 1 or croak q/Usage: $tz->name()/;
115 4         14 return $_[0]->{name};
116             }
117              
118             sub tz_string {
119 3 50   3 1 9 @_ == 1 or croak q/Usage: $tz->tz_string()/;
120 3         9 return $_[0]->{tz_string};
121             }
122              
123             sub gap_policy {
124 7 50   7 1 605 @_ == 1 or croak q/Usage: $tz->gap_policy()/;
125 7         20 return $_[0]->{gap_policy};
126             }
127              
128             sub overlap_policy {
129 5 50   5 1 642 @_ == 1 or croak q/Usage: $tz->overlap_policy()/;
130 5         12 return $_[0]->{overlap_policy};
131             }
132              
133             sub has_name {
134 2 50   2 1 7 @_ == 1 or croak q/Usage: $tz->has_name()/;
135 2         6 return exists $_[0]->{name};
136             }
137              
138             sub with_name {
139 5 100   5 1 128 @_ == 2 or croak q/Usage: $tz->with_name($name)/;
140 4         6 my ($self, $name) = @_;
141              
142 4 100       9 valid_tzdb_timezone($name)
143             or croak qq/Invalid name value/;
144              
145 3 100 100     13 if (!exists $self->{name} || $name ne $self->{name}) {
146 2         3 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         3 my ($self, $policy) = @_;
154              
155 3 100 66     89 (defined $policy && exists $ValidPolicy{$policy})
156             or croak qq/Invalid policy value/;
157              
158 2 100       3 if ($policy ne $self->{gap_policy}) {
159 1         8 return _with($self, gap_policy => $policy);
160             }
161 1         2 return $self;
162             }
163              
164             sub with_overlap_policy {
165 4 100   4 1 88 @_ == 2 or croak q/Usage: $tz->with_overlap_policy($policy)/;
166 3         4 my ($self, $policy) = @_;
167              
168 3 100 66     87 (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         1 return _with($self, overlap_policy => $policy);
173             }
174 1         2 return $self;
175             }
176              
177             sub _parse_offset {
178 75     75   116 my ($str) = @_;
179 75 50       346 $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     410 my ($h, $m, $s) = ($2, $3 // 0, $4 // 0);
      100        
182 75 100 100     670 ($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         106 my $secs = $h * 3600 + $m * 60 + $s;
185 71 100       236 return ($1 eq '-') ? -$secs : $secs;
186             }
187              
188             sub _parse_rule_time {
189 63     63   81 my ($str) = @_;
190 63 50       183 $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     257 my ($h, $m, $s) = ($2, $3 // 0, $4 // 0);
      50        
193 63 100 100     472 ($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         69 my $secs = $h * 3600 + $m * 60 + $s;
196 60 100       112 return ($1 eq '-') ? -$secs : $secs;
197             }
198              
199             sub _parse_rule {
200 112     112   226 my ($rule_str, $time_str) = @_;
201              
202 112 100       205 my $time = defined $time_str ? _parse_rule_time($time_str) : 7200;
203              
204 109 50       483 $rule_str =~ $Rule_Rx
205             or croak qq/Unable to parse POSIX TZ string: invalid rule '$rule_str'/;
206              
207 109 100       397 if (exists $+{month}) {
    100          
208 96         482 my ($m, $w, $d) = @+{qw(month week wday)};
209 96 100 100     539 ($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       139 my $nth = ($w == 5) ? -1 : $w;
212 94         123 my $dow = 1 + ($d + 6) % 7;
213 94         406 return { type => 'M', month => $m, nth => $nth, day => $dow, time => $time };
214             }
215             elsif (exists $+{jday}) {
216 6         35 my $jday = $+{jday};
217 6 100 100     199 ($jday >= 1 && $jday <= 365)
218             or croak qq/Unable to parse POSIX TZ string: Julian day out of range [1, 365]: $jday/;
219 4         22 return { type => 'J', day => $jday, time => $time };
220             }
221             else {
222 7         22 my $nday = $+{nday};
223 7 100 66     106 ($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         29 return { type => 'N', day => $nday + 1, time => $time };
226             }
227             }
228              
229             sub _parse {
230 78     78   109 my ($self, $str) = @_;
231              
232 78 100       1854 $str =~ $POSIX_TZ_Rx
233             or croak qq/Unable to parse POSIX TZ string: '$str'/;
234              
235 73         1240 my %m = %+;
236              
237 73         347 (my $std_name = $m{std_name}) =~ s/[<>]//g;
238 73         141 my $std_offset = -_parse_offset($m{std_offset});
239              
240 70 50 33     180 ($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         157 $self->{std_type} = [$std_offset, 0, $std_name];
244              
245 70 100       139 return unless defined $m{dst_name};
246              
247 61         107 (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       104 : $std_offset + 3600;
252              
253 60 50 33     149 ($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         97 $self->{dst_type} = [$dst_offset, 1, $dst_name];
257 60         121 $self->{dst_start} = _parse_rule($m{rule_start}, $m{time_start});
258 52         105 $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       73 if ($t0 <= $t1) {
268             # Northern: start < end -> types alternate dst, std
269             $self->{types_3y} = [$self->{std_type},
270 50         153 ($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         7 ($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         71 my $cross_year = 0;
290 52         140 for my $pair (
291             [$self->{dst_start}, $self->{std_type}[0]],
292             [$self->{dst_end}, $self->{dst_type}[0]],
293             ) {
294 103         139 my ($r, $off) = @$pair;
295              
296             # Forward: rule in December pushing into next year
297 103         104 my $max_day;
298 103 100 100     362 if ($r->{type} eq 'M' && $r->{month} == 12) {
    100 100        
299 2 50       5 $max_day = $r->{nth} == -1 ? 31 : $r->{nth} * 7;
300             }
301             elsif ($r->{type} ne 'M' && $r->{day} >= 359) {
302 3         4 $max_day = 31;
303             }
304 103 100       136 if (defined $max_day) {
305 5 100       10 $cross_year = 1 if $r->{time} - $off >= (32 - $max_day) * 86400;
306             }
307              
308             # Backward: rule in January pushing into previous year
309 103         90 my $min_day;
310 103 100 100     296 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       147 if (defined $min_day) {
317 3 100       8 $cross_year = 1 if $r->{time} - $off < -($min_day - 1) * 86400;
318             }
319              
320 103 100       156 last if $cross_year;
321             }
322 52         193 $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   279 my ($self, $rule, $year, $offset) = @_;
329              
330 206         206 my ($month, $day);
331              
332 206 100       335 if ($rule->{type} eq 'M') {
    100          
333 178         213 $month = $rule->{month};
334 178         381 $day = nth_dow_in_month($year, $month, $rule->{nth}, $rule->{day});
335             }
336             elsif ($rule->{type} eq 'J') {
337 8         10 my $doy = $rule->{day};
338 8 100 100     41 $doy++ if $doy >= 60 && leap_year($year);
339 8         19 ($month, $day) = yd_to_md($year, $doy);
340             }
341             else {
342 20         24 my $doy = $rule->{day};
343 20 50 66     39 $doy-- if $doy == 366 && !leap_year($year);
344 20         61 ($month, $day) = yd_to_md($year, $doy);
345             }
346              
347             # rule time is wall clock; subtract offset to convert to UTC
348 206         451 return timegm_modern(0, 0, 0, $day, $month, $year) + $rule->{time} - $offset;
349             }
350              
351             sub _transitions_for_year {
352 103     103   130 my ($self, $year) = @_;
353              
354             my $t_start = $self->_rule_to_epoch(
355 103         202 $self->{dst_start}, $year, $self->{std_type}[0]);
356             my $t_end = $self->_rule_to_epoch(
357 103         162 $self->{dst_end}, $year, $self->{dst_type}[0]);
358              
359 103         186 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   45 my ($self, $year) = @_;
365              
366 35         40 my @times;
367 35 100       79 for my $y ($self->{cross_year} ? ($year - 1, $year, $year + 1) : $year) {
368 51         79 my ($t_start, $t_end) = $self->_transitions_for_year($y);
369 51 100       66 if ($t_start <= $t_end) {
370 48         86 push @times, $t_start, $t_end;
371             }
372             else {
373 3         10 push @times, $t_end, $t_start;
374             }
375             }
376              
377 35         80 return \@times;
378             }
379              
380             sub _transitions_for_time {
381 114     114   144 my ($self, $time) = @_;
382              
383 114         235 my $year = gmtime_year($time);
384 114         139 my $year_index = $year - 1990;
385 114   100     228 my $cache = $self->{cache_years} //= [];
386 114         110 my $times;
387              
388 114 50 33     333 if ($year_index >= 0 && $year_index < 50) {
389 114   66     229 $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         209 return ($times, $self->{types_3y});
396             }
397              
398             sub _type_for_utc {
399 78     78   100 my ($self, $time) = @_;
400              
401 78 100       186 return $self->{std_type} unless exists $self->{dst_start};
402              
403 67         116 my ($times, $types) = $self->_transitions_for_time($time);
404              
405 67         419 return $types->[ upper_bound($times, $time) ];
406             }
407              
408             sub offset_for_utc {
409 69 100   69 1 9842 @_ == 2 or croak q/Usage: $tz->offset_for_utc($time)/;
410 68         147 my ($self, $time) = @_;
411 68         135 return $self->_type_for_utc($time)->[0];
412             }
413              
414             sub type_info_for_utc {
415 11 100   11 1 456 @_ == 2 or croak q/Usage: $tz->type_info_for_utc($time)/;
416 10         15 my ($self, $time) = @_;
417 10         14 return @{$self->_type_for_utc($time)};
  10         22  
418             }
419              
420             sub offset_for_local {
421 50 50   50 1 15356 @_ >= 2 or croak q/Usage: $tz->offset_for_local($time, %opts)/;
422 50         91 my $type = &_resolve_local;
423 44         235 return $type->[0];
424             }
425              
426             sub type_info_for_local {
427 4 50   4 1 4520 @_ >= 2 or croak q/Usage: $tz->type_info_for_local($time, %opts)/;
428 4         7 my $type = &_resolve_local;
429 3         10 return @$type;
430             }
431              
432             sub _resolve_local {
433 54 50 33 54   205 ((@_ & 1) == 0 && @_ >= 2) or croak q/Usage: $tz->offset_for_local($time, %opts)/;
434 54         102 my ($self, $time, %p) = @_;
435              
436 54         58 my ($gap_policy, $overlap_policy);
437              
438 54         146 while (my ($key, $v) = each %p) {
439 20 100       47 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         31 $overlap_policy = $v;
448             }
449             else {
450 2         175 croak qq/Unrecognised named parameter: '$key'/;
451             }
452             }
453              
454 50 100       111 return $self->{std_type} unless exists $self->{dst_start};
455              
456 47         90 my ($times, $types) = $self->_transitions_for_time($time);
457              
458 47 50       74 return $types->[0] unless @$times;
459              
460 47         53 my $result_idx = 0;
461              
462 47         77 for (my $i = 0; $i < @$times; $i++) {
463 92         100 my $boundary = $time - $times->[$i];
464 92         94 my $prev = $types->[$i];
465 92         103 my $next = $types->[$i + 1];
466 92         86 my $prev_off = $prev->[0];
467 92         100 my $next_off = $next->[0];
468              
469 92 100       121 if ($prev_off < $next_off) {
    50          
470             # Spring forward: gap in [prev_off, next_off)
471 51 100 100     112 if ($prev_off <= $boundary && $boundary < $next_off) {
472 10   66     25 $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       102 $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     83 if ($next_off <= $boundary && $boundary < $prev_off) {
481 15   66     29 $overlap_policy //= $self->{overlap_policy};
482 15         28 return _apply_policy($overlap_policy, $prev, $next,
483             'Unable to resolve local time: ambiguous time (overlap)');
484             }
485 26 100       53 $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         36 return $types->[$result_idx];
493             }
494              
495             sub _apply_policy {
496 25     25   41 my ($policy, $prev, $next, $message) = @_;
497              
498 25 100       53 if ($policy eq 'earlier') { return $prev }
  9 100       29  
    100          
    100          
499 9         25 elsif ($policy eq 'later') { return $next }
500             elsif ($policy eq 'std') {
501 2 100       8 return $prev->[1] ? $next : $prev;
502             }
503             elsif ($policy eq 'dst') {
504 2 100       8 return $prev->[1] ? $prev : $next;
505             }
506             else {
507 3         275 croak $message;
508             }
509             }
510              
511             1;