File Coverage

blib/lib/Validate/CodiceFiscale.pm
Criterion Covered Total %
statement 211 227 92.9
branch 63 76 82.8
condition 39 44 88.6
subroutine 27 28 96.4
pod 5 5 100.0
total 345 380 90.7


line stmt bran cond sub pod time code
1             package Validate::CodiceFiscale;
2 3     3   163524 use v5.24;
  3         37  
3 3     3   16 use Carp;
  3         7  
  3         183  
4 3     3   643 use experimental qw< signatures >;
  3         3558  
  3         36  
5             { our $VERSION = '0.003002' }
6              
7 3     3   561 use List::Util 'sum';
  3         6  
  3         366  
8 3     3   1571 use Time::Local 'timegm';
  3         6885  
  3         178  
9 3     3   2156 use JSON::PP 'decode_json';
  3         48223  
  3         191  
10 3     3   24 use Exporter 'import';
  3         5  
  3         9593  
11              
12             our @EXPORT_OK = qw< assert_valid_cf decode_cf is_valid_cf validate_cf r >;
13              
14             # PUBLIC interface
15              
16 17     17 1 10032 sub assert_valid_cf ($cf, %options) {
  17         33  
  17         30  
  17         25  
17 17 100       44 my $errors = validate_cf($cf, all_errors => 0, %options) or return;
18              
19             defined(my $ecb = $options{on_error})
20 14 50       215 or croak join ', ', $errors->@*;
21              
22 0         0 my $exception = $ecb->($errors->@*);
23 0         0 die $exception; # just as a fallback, $ecb might throw by itself
24             } ## end sub assert_valid_cf
25              
26 1     1 1 11391 sub decode_cf ($cf, %options) {
  1         2  
  1         3  
  1         3  
27 1         5 return _decode_and_validate($cf, %options, all_errors => 1);
28             }
29              
30 17     17 1 9932 sub is_valid_cf ($cf, %options) {
  17         31  
  17         32  
  17         21  
31 17         31 my $error = 0;
32 17     14   108 _validate_cf($cf, $options{data}, sub { $error = 1; return 0 });
  14         582  
  14         95  
33 17         104 return !$error;
34             }
35              
36 34     34 1 6001 sub validate_cf ($cf, %options) {
  34         59  
  34         59  
  34         72  
37 34         95 my $r = _decode_and_validate($cf, %options);
38 34   50     92 my $errors = $r->{errors} // [];
39 34 100       191 return scalar($errors->@*) ? $errors : undef;
40             } ## end sub validate_cf
41              
42             # The following is useful for one-lines:
43             #
44             # $ perl -MValidate::CodiceFiscale=r -er bcadfe88a48h501p
45             #
46 0     0 1 0 sub r (@args) {
  0         0  
  0         0  
47 0 0       0 @args = @ARGV unless @args;
48 0         0 my $i = 0;
49 0         0 my $n = 0;
50 0         0 for my $cf (@ARGV) {
51 0 0       0 if (my $errors = validate_cf($cf)) {
52 0         0 say "$i not ok - " . join(', ', $errors->@*);
53 0         0 ++$n;
54             }
55             else {
56 0         0 say "$i ok - $cf";
57             }
58 0         0 ++$i;
59             } ## end for my $cf (@ARGV)
60 0 0       0 return $n ? 1 : 0;
61             } ## end sub r
62              
63             exit r(@ARGV) unless caller(); # modulino
64              
65             # PRIVATE interface
66              
67 35     35   44 sub _decode_and_validate ($cf, %options) {
  35         58  
  35         51  
  35         53  
68 35   100     130 my $data = $options{data} // undef;
69              
70 35   100     102 my $collect_all_errors = $options{all_errors} // 1;
71 35         57 my @errors;
72 31     31   51 my $callback = sub ($msg) {
  31         1271  
  31         56  
73 31         58 push @errors, $msg;
74 31         123 return $collect_all_errors;
75 35         151 };
76              
77 35         86 my $r = _validate_cf($cf, $data, $callback);
78 35         106 $r->{errors} = \@errors;
79 35         176 return $r;
80             }
81              
82 52     52   80 sub _validate_cf ($cf, $data, $cb) {
  52         86  
  52         89  
  52         77  
  52         66  
83 52         101 state $consonant = qr{(?imxs:[BCDFGHJKLMNPQRSTVWXYZ])};
84 52         77 state $vowel = qr{(?imxs:[AEIOU])};
85 52         388 state $namish = qr{(?imxs:
86             $consonant $consonant $consonant # includes CCX, CXX, XXX
87             | $consonant $consonant $vowel
88             | $consonant $vowel $vowel
89             | $consonant $vowel X
90             | $vowel $vowel $vowel
91             | $vowel $vowel X
92             | $vowel X X
93             )};
94 52         101 state $digitish = qr{(?imxs:[0-9LMNPQRSTUV])};
95              
96 52 100       143 if (length($cf) != 16) {
97 3         10 $cb->('invalid length');
98 3         7 return {};
99             }
100              
101 49         119 $cf = uc($cf);
102 49         284 my %portions = (
103             surname => substr($cf, 0, 3),
104             name => substr($cf, 3, 3),
105             date => substr($cf, 6, 5),
106             place => substr($cf, 11, 4),
107             checksum => substr($cf, 15, 1),
108             );
109 49         143 my $retval = {portions => \%portions};
110              
111             return $retval
112 49 100 100     694 if $portions{name} !~ m{\A$namish\z}mxs
113             && !$cb->('invalid name');
114              
115             return $retval
116 47 100 100     518 if $portions{surname} !~ m{\A$namish\z}mxs
117             && !$cb->('invalid surname');
118              
119 43         116 my ($y, $m, $d, $sex) = _expand_date($portions{date}, $data);
120 43         188 $retval->@{qw< year month day sex >} = ($y, $m, $d, $sex);
121 43 100 100     103 return $retval
122             if !_is_valid_cf_date($y, $m, $d)
123             && !$cb->('invalid birth date');
124              
125 39         72 my $date;
126 39 100       227 $date = $retval->{date} = sprintf('%04d-%02d-%02d', $y, $m, $d)
127             if defined($y);
128              
129 39 100       105 if (defined(my $p = _place_name_for($portions{place}, $date))) {
130 36         80 $retval->{place} = $p;
131             }
132             else {
133 3 100       9 return $retval unless $cb->('invalid birth place');
134             }
135              
136 37         88 my $checksum = _cf_checksum($cf);
137 37 100 100     138 return $retval
138             if $checksum ne substr($cf, -1, 1)
139             && !$cb->("invalid checksum (should be: $checksum)");
140              
141 33 100       100 return $retval unless $data;
142              
143 20 100       83 if (defined(my $surname = $data->{surname})) {
144 6 100 100     17 return $retval
145             if substr($cf, 0, 3) ne _compact_surname($surname)
146             && !$cb->('surname mismatch');
147             }
148 18 100       43 if (defined(my $name = $data->{name})) {
149 6 100 100     18 return $retval
150             if substr($cf, 3, 3) ne _compact_name($name)
151             && !$cb->('name mismatch');
152             }
153 16 100       36 if (defined(my $birthdate = $data->{date})) {
154 6         14 my ($male, $female) = _compact_birthdates($birthdate);
155 6         20 my $got = _normalized_birthdate(substr($cf, 6, 5));
156 6 100 66     64 return $retval
      100        
157             if ($got ne $male)
158             && ($got ne $female)
159             && !$cb->('birth date mismatch');
160             } ## end if (defined(my $birthdate...))
161 14 100       35 if (defined(my $sex = $data->{sex})) {
162 8         29 my $got = _normalized_birthdate(substr($cf, 6, 5));
163 8         23 my $day = substr($got, -2, 2) + 0;
164 8 100 66     81 return $retval
      100        
165             if ((lc($sex) eq 'm' && $day > 31)
166             || (lc($sex) eq 'f' && $day < 41))
167             && !$cb->('sex mismatch');
168             } ## end if (defined(my $sex = ...))
169 12 100       32 if (defined(my $place = $data->{place})) {
170             my $got = $retval->{place} //
171 6   33     14 _normalized_birthplace($portions{place});
172 6 100 100     26 return $retval
173             if fc($got) ne fc($place)
174             && !$cb->('birth place mismatch');
175             } ## end if (defined(my $place ...))
176              
177 10         36 return $retval;
178             } ## end sub _validate_cf
179              
180 37     37   56 sub _cf_checksum ($cf) {
  37         48  
  37         59  
181 37         655 state $odd_checksums = {
182             0 => 1,
183             1 => 0,
184             2 => 5,
185             3 => 7,
186             4 => 9,
187             5 => 13,
188             6 => 15,
189             7 => 17,
190             8 => 19,
191             9 => 21,
192             A => 1,
193             B => 0,
194             C => 5,
195             D => 7,
196             E => 9,
197             F => 13,
198             G => 15,
199             H => 17,
200             I => 19,
201             J => 21,
202             K => 2,
203             L => 4,
204             M => 18,
205             N => 20,
206             O => 11,
207             P => 3,
208             Q => 6,
209             R => 8,
210             S => 12,
211             T => 14,
212             U => 16,
213             V => 10,
214             W => 22,
215             X => 25,
216             Y => 24,
217             Z => 23,
218             },
219             my $even_checksums = {
220             0 => 0,
221             1 => 1,
222             2 => 2,
223             3 => 3,
224             4 => 4,
225             5 => 5,
226             6 => 6,
227             7 => 7,
228             8 => 8,
229             9 => 9,
230             A => 0,
231             B => 1,
232             C => 2,
233             D => 3,
234             E => 4,
235             F => 5,
236             G => 6,
237             H => 7,
238             I => 8,
239             J => 9,
240             K => 10,
241             L => 11,
242             M => 12,
243             N => 13,
244             O => 14,
245             P => 15,
246             Q => 16,
247             R => 17,
248             S => 18,
249             T => 19,
250             U => 20,
251             V => 21,
252             W => 22,
253             X => 23,
254             Y => 24,
255             Z => 25,
256             };
257 37         75 state $checksums_for = [$odd_checksums, $even_checksums];
258 37         185 my @chars = split m{}mxs, substr($cf, 0, 15); # no checksum
259 37         132 my $sum = sum map { $checksums_for->[$_ % 2]{$chars[$_]} } 0 .. $#chars;
  555         1118  
260 37         254 chr(ord('A') + ($sum % 26));
261             } ## end sub _cf_checksum
262              
263 96     96   133 sub _normalized_string ($string, @positions) {
  96         151  
  96         164  
  96         131  
264 96         135 state $letters = [qw< L M N P Q R S T U V >];
265 96         147 state $digit_for = {map { $letters->[$_] => $_ } 0 .. $letters->$#*};
  20         59  
266 96         204 for my $i (@positions) {
267 345         557 my $current = substr($string, $i, 1);
268             substr($string, $i, 1, $digit_for->{$current})
269 345 50       758 if exists $digit_for->{$current};
270             }
271 96         300 return $string;
272             } ## end sub _normalized_string
273              
274 39     39   52 sub _normalized_birthplace ($place) { _normalized_string($place, 1 .. 3) }
  39         65  
  39         59  
  39         105  
275 57     57   73 sub _normalized_birthdate ($date) { _normalized_string($date, 0, 1, 3, 4) }
  57         110  
  57         71  
  57         117  
276              
277 43     43   67 sub _expand_date ($date, $opts) {
  43         70  
  43         73  
  43         52  
278 43         82 state $mlf = [split m{}mxs, 'ABCDEHLMPRST'];
279 43         70 state $month_for = {map { $mlf->[$_] => $_ } 0 .. $mlf->$#*};
  24         71  
280              
281 43         96 $date = _normalized_birthdate($date);
282 43 100       240 my ($y, $mc, $d) = $date =~ m{\A(\d\d)([ABCDEHLMPRST])(\d\d)\z}mxs
283             or return;
284 40         90 my $m = 1 + $month_for->{$mc};
285 40         147 $_ += 0 for ($d, $y);
286 40 100       101 my $sex = $d > 40 ? 'F' : 'M';
287 40 100       89 $d -= 40 if $d > 40;
288              
289             # century: the initial digits of a year
290 40 50 100     145 if (defined(my $years_baseline = ($opts // {})->{years_baseline})) {
291 0         0 $y += $years_baseline;
292             }
293             else { # whatever in the last 100 years
294 40         1137 my $this_year = 1900 + (localtime)[5];
295 40         188 $y += 100 * int($this_year / 100);
296 40 50       131 $y -= 100 if $y > $this_year;
297             }
298              
299 40         173 return ($y, $m, $d, $sex);
300             } ## end sub _expand_date
301              
302 43     43   67 sub _is_valid_cf_date ($y, $m, $d) {
  43         65  
  43         73  
  43         56  
  43         56  
303 43         96 return !!(eval { timegm(30, 30, 12, $d, $m - 1, $y); 1 });
  43         215  
  37         1176  
304             }
305              
306 6     6   8 sub _compact_birthdates ($birthdate) {
  6         19  
  6         11  
307 6         45 state $month_letter_for = ['', split m{}mxs, 'ABCDEHLMPRST'];
308 6         39 my ($y, $m, $d) = split m{\D}mxs, $birthdate;
309 6 50       21 ($y, $d) = ($d, $y) if $d > 31;
310 6         11 $y %= 100;
311 6         14 $m = $month_letter_for->[$m + 0];
312 6         11 map { sprintf '%02d%s%02d', $y, $m, $_ } ($d, $d + 40);
  12         86  
313             } ## end sub _compact_birthdates
314              
315 6     6   13 sub _compact_surname ($surname) {
  6         10  
  6         10  
316 6         17 my ($cs, $vs) = _consonants_and_vowels($surname);
317 6         22 my @retval = ($cs->@*, $vs->@*, ('X') x 3);
318 6         41 return join '', @retval[0 .. 2];
319             }
320              
321 6     6   8 sub _compact_name ($name) {
  6         11  
  6         8  
322 6         11 my ($cs, $vs) = _consonants_and_vowels($name);
323 6 100       19 splice $cs->@*, 1, 1 if $cs->@* > 3;
324 6         20 my @retval = ($cs->@*, $vs->@*, ('X') x 3);
325 6         52 return join '', @retval[0 .. 2];
326             } ## end sub _compact_name
327              
328 12     12   21 sub _consonants_and_vowels ($string) {
  12         19  
  12         17  
329 12         17 my (@consonants, @vowels);
330 12         41 for my $char (grep { m{[A-Z]}mxs } split m{}mxs, uc($string)) {
  63         183  
331 63 100       142 if ($char =~ m{[AEIOU]}mxs) { push @vowels, $char }
  30         87  
332 33         73 else { push @consonants, $char }
333             }
334 12         40 return (\@consonants, \@vowels);
335             } ## end sub _consonants_and_vowels
336              
337             sub _places {
338 2     2   5 state $retval = do {
339 2         9 local $/;
340 2         29 binmode DATA, ':raw';
341 2         22193 (my $json = readline(DATA)) =~ s{\n+}{}gmxs;
342 2         27 decode_json($json);
343             };
344             }
345              
346 39     39   61 sub _place_name_for ($place, $birthdate) {
  39         73  
  39         55  
  39         55  
347 39         61 state $place_for = _places();
348 39 50       24941463 my $record = $place_for->{_normalized_birthplace($place)} or return;
349 39 100       100 return "[$record->[-1]{name}]" unless defined($birthdate);
350 38         74 for my $candidate ($record->@*) {
351 69 100       165 next if $birthdate gt $candidate->{end};
352 35 50       92 last if $birthdate lt $candidate->{start};
353 35         119 return $candidate->{name};
354             }
355 3         11 return;
356             } ## end sub _place_name_for
357              
358             1;
359              
360             __DATA__