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   166928 use v5.24;
  3         32  
3 3     3   19 use Carp;
  3         8  
  3         209  
4 3     3   640 use experimental qw< signatures >;
  3         3754  
  3         25  
5             { our $VERSION = '0.004' }
6              
7 3     3   589 use List::Util 'sum';
  3         7  
  3         383  
8 3     3   1808 use Time::Local 'timegm';
  3         7239  
  3         181  
9 3     3   2504 use JSON::PP 'decode_json';
  3         50223  
  3         221  
10 3     3   24 use Exporter 'import';
  3         7  
  3         9864  
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 9949 sub assert_valid_cf ($cf, %options) {
  17         31  
  17         27  
  17         28  
17 17 100       41 my $errors = validate_cf($cf, all_errors => 0, %options) or return;
18              
19             defined(my $ecb = $options{on_error})
20 14 50       206 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 11690 sub decode_cf ($cf, %options) {
  1         3  
  1         2  
  1         2  
27 1         4 return _decode_and_validate($cf, %options, all_errors => 1);
28             }
29              
30 17     17 1 9688 sub is_valid_cf ($cf, %options) {
  17         30  
  17         31  
  17         24  
31 17         25 my $error = 0;
32 17     14   107 _validate_cf($cf, $options{data}, sub { $error = 1; return 0 });
  14         528  
  14         99  
33 17         96 return !$error;
34             }
35              
36 34     34 1 5645 sub validate_cf ($cf, %options) {
  34         54  
  34         61  
  34         72  
37 34         92 my $r = _decode_and_validate($cf, %options);
38 34   50     88 my $errors = $r->{errors} // [];
39 34 100       193 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   49 sub _decode_and_validate ($cf, %options) {
  35         58  
  35         47  
  35         47  
68 35   100     116 my $data = $options{data} // undef;
69              
70 35   100     93 my $collect_all_errors = $options{all_errors} // 1;
71 35         59 my @errors;
72 31     31   44 my $callback = sub ($msg) {
  31         1219  
  31         48  
73 31         62 push @errors, $msg;
74 31         124 return $collect_all_errors;
75 35         143 };
76              
77 35         84 my $r = _validate_cf($cf, $data, $callback);
78 35         106 $r->{errors} = \@errors;
79 35         170 return $r;
80             }
81              
82 52     52   77 sub _validate_cf ($cf, $data, $cb) {
  52         81  
  52         80  
  52         75  
  52         71  
83 52         86 state $consonant = qr{(?imxs:[BCDFGHJKLMNPQRSTVWXYZ])};
84 52         75 state $vowel = qr{(?imxs:[AEIOU])};
85 52         386 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         90 state $digitish = qr{(?imxs:[0-9LMNPQRSTUV])};
95              
96 52 100       124 if (length($cf) != 16) {
97 3         9 $cb->('invalid length');
98 3         7 return {};
99             }
100              
101 49         110 $cf = uc($cf);
102 49         287 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         166 my $retval = {portions => \%portions};
110              
111             return $retval
112 49 100 100     654 if $portions{name} !~ m{\A$namish\z}mxs
113             && !$cb->('invalid name');
114              
115             return $retval
116 47 100 100     503 if $portions{surname} !~ m{\A$namish\z}mxs
117             && !$cb->('invalid surname');
118              
119 43         124 my ($y, $m, $d, $sex) = _expand_date($portions{date}, $data);
120 43         540 $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         89 my $date;
126 39 100       228 $date = $retval->{date} = sprintf('%04d-%02d-%02d', $y, $m, $d)
127             if defined($y);
128              
129 39 100       103 if (defined(my $p = _place_name_for($portions{place}, $date))) {
130 36         84 $retval->{place} = $p;
131             }
132             else {
133 3 100       10 return $retval unless $cb->('invalid birth place');
134             }
135              
136 37         81 my $checksum = _cf_checksum($cf);
137 37 100 100     132 return $retval
138             if $checksum ne substr($cf, -1, 1)
139             && !$cb->("invalid checksum (should be: $checksum)");
140              
141 33 100       103 return $retval unless $data;
142              
143 20 100       61 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       42 if (defined(my $birthdate = $data->{date})) {
154 6         14 my ($male, $female) = _compact_birthdates($birthdate);
155 6         19 my $got = _normalized_birthdate(substr($cf, 6, 5));
156 6 100 66     62 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       34 if (defined(my $sex = $data->{sex})) {
162 8         33 my $got = _normalized_birthdate(substr($cf, 6, 5));
163 8         28 my $day = substr($got, -2, 2) + 0;
164 8 100 66     66 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       25 if (defined(my $place = $data->{place})) {
170             my $got = $retval->{place} //
171 6   33     15 _normalized_birthplace($portions{place});
172 6 100 100     30 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   51 sub _cf_checksum ($cf) {
  37         56  
  37         49  
181 37         663 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         76 state $checksums_for = [$odd_checksums, $even_checksums];
258 37         176 my @chars = split m{}mxs, substr($cf, 0, 15); # no checksum
259 37         170 my $sum = sum map { $checksums_for->[$_ % 2]{$chars[$_]} } 0 .. $#chars;
  555         1096  
260 37         280 chr(ord('A') + ($sum % 26));
261             } ## end sub _cf_checksum
262              
263 96     96   140 sub _normalized_string ($string, @positions) {
  96         141  
  96         153  
  96         130  
264 96         134 state $letters = [qw< L M N P Q R S T U V >];
265 96         143 state $digit_for = {map { $letters->[$_] => $_ } 0 .. $letters->$#*};
  20         62  
266 96         194 for my $i (@positions) {
267 345         589 my $current = substr($string, $i, 1);
268             substr($string, $i, 1, $digit_for->{$current})
269 345 50       757 if exists $digit_for->{$current};
270             }
271 96         294 return $string;
272             } ## end sub _normalized_string
273              
274 39     39   53 sub _normalized_birthplace ($place) { _normalized_string($place, 1 .. 3) }
  39         61  
  39         53  
  39         89  
275 57     57   83 sub _normalized_birthdate ($date) { _normalized_string($date, 0, 1, 3, 4) }
  57         98  
  57         77  
  57         112  
276              
277 43     43   64 sub _expand_date ($date, $opts) {
  43         76  
  43         61  
  43         61  
278 43         73 state $mlf = [split m{}mxs, 'ABCDEHLMPRST'];
279 43         60 state $month_for = {map { $mlf->[$_] => $_ } 0 .. $mlf->$#*};
  24         66  
280              
281 43         85 $date = _normalized_birthdate($date);
282 43 100       214 my ($y, $mc, $d) = $date =~ m{\A(\d\d)([ABCDEHLMPRST])(\d\d)\z}mxs
283             or return;
284 40         98 my $m = 1 + $month_for->{$mc};
285 40         146 $_ += 0 for ($d, $y);
286 40 100       105 my $sex = $d > 40 ? 'F' : 'M';
287 40 100       91 $d -= 40 if $d > 40;
288              
289             # century: the initial digits of a year
290 40 50 100     149 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         1094 my $this_year = 1900 + (localtime)[5];
295 40         178 $y += 100 * int($this_year / 100);
296 40 50       115 $y -= 100 if $y > $this_year;
297             }
298              
299 40         175 return ($y, $m, $d, $sex);
300             } ## end sub _expand_date
301              
302 43     43   60 sub _is_valid_cf_date ($y, $m, $d) {
  43         73  
  43         58  
  43         54  
  43         72  
303 43         65 return !!(eval { timegm(30, 30, 12, $d, $m - 1, $y); 1 });
  43         204  
  37         1157  
304             }
305              
306 6     6   8 sub _compact_birthdates ($birthdate) {
  6         9  
  6         9  
307 6         45 state $month_letter_for = ['', split m{}mxs, 'ABCDEHLMPRST'];
308 6         37 my ($y, $m, $d) = split m{\D}mxs, $birthdate;
309 6 50       22 ($y, $d) = ($d, $y) if $d > 31;
310 6         11 $y %= 100;
311 6         14 $m = $month_letter_for->[$m + 0];
312 6         13 map { sprintf '%02d%s%02d', $y, $m, $_ } ($d, $d + 40);
  12         48  
313             } ## end sub _compact_birthdates
314              
315 6     6   8 sub _compact_surname ($surname) {
  6         11  
  6         11  
316 6         14 my ($cs, $vs) = _consonants_and_vowels($surname);
317 6         21 my @retval = ($cs->@*, $vs->@*, ('X') x 3);
318 6         47 return join '', @retval[0 .. 2];
319             }
320              
321 6     6   8 sub _compact_name ($name) {
  6         13  
  6         7  
322 6         12 my ($cs, $vs) = _consonants_and_vowels($name);
323 6 100       20 splice $cs->@*, 1, 1 if $cs->@* > 3;
324 6         20 my @retval = ($cs->@*, $vs->@*, ('X') x 3);
325 6         49 return join '', @retval[0 .. 2];
326             } ## end sub _compact_name
327              
328 12     12   14 sub _consonants_and_vowels ($string) {
  12         21  
  12         15  
329 12         19 my (@consonants, @vowels);
330 12         70 for my $char (grep { m{[A-Z]}mxs } split m{}mxs, uc($string)) {
  63         171  
331 63 100       137 if ($char =~ m{[AEIOU]}mxs) { push @vowels, $char }
  30         81  
332 33         66 else { push @consonants, $char }
333             }
334 12         40 return (\@consonants, \@vowels);
335             } ## end sub _consonants_and_vowels
336              
337             sub _places {
338 2     2   3 state $retval = do {
339 2         14 local $/;
340 2         30 binmode DATA, ':raw';
341 2         22757 (my $json = readline(DATA)) =~ s{\n+}{}gmxs;
342 2         19 decode_json($json);
343             };
344             }
345              
346 39     39   78 sub _place_name_for ($place, $birthdate) {
  39         74  
  39         56  
  39         52  
347 39         57 state $place_for = _places();
348 39 50       25029613 my $record = $place_for->{_normalized_birthplace($place)} or return;
349 39 100       101 return "[$record->[-1]{name}]" unless defined($birthdate);
350 38         72 for my $candidate ($record->@*) {
351 69 100       174 next if $birthdate gt $candidate->{end};
352 35 50       75 last if $birthdate lt $candidate->{start};
353 35         117 return $candidate->{name};
354             }
355 3         9 return;
356             } ## end sub _place_name_for
357              
358             1;
359              
360             __DATA__