File Coverage

blib/lib/Time/TZif/POSIX.pm
Criterion Covered Total %
statement 184 185 99.4
branch 123 138 89.1
condition 70 87 80.4
subroutine 26 26 100.0
pod 8 8 100.0
total 411 444 92.5


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