File Coverage

blib/lib/Validate/CodiceFiscale.pm
Criterion Covered Total %
statement 152 179 84.9
branch 47 62 75.8
condition 40 46 86.9
subroutine 20 22 90.9
pod 4 4 100.0
total 263 313 84.0


line stmt bran cond sub pod time code
1             package Validate::CodiceFiscale;
2 2     2   78003 use v5.24;
  2         16  
3 2     2   10 use Carp;
  2         4  
  2         117  
4 2     2   582 use experimental qw< signatures >;
  2         3604  
  2         16  
5             { our $VERSION = '0.001' }
6              
7 2     2   363 use List::Util 'sum';
  2         4  
  2         233  
8 2     2   1122 use Time::Local 'timegm';
  2         4681  
  2         114  
9 2     2   16 use Exporter 'import';
  2         4  
  2         5171  
10              
11             our @EXPORT_OK = qw< assert_valid_cf is_valid_cf validate_cf r >;
12              
13             # PUBLIC interface
14              
15 0     0 1 0 sub assert_valid_cf ($cf, %options) {
  0         0  
  0         0  
  0         0  
16 0 0       0 my $errors = validate_cf($cf, all_errors => 0, %options) or return;
17              
18             defined(my $ecb = $options{on_error})
19 0 0       0 or croak join ', ', $errors->@*;
20              
21 0         0 my $exception = $ecb->($errors->@*);
22 0         0 die $exception; # just as a fallback, $ecb might throw by itself
23              
24             } ## end sub assert_valid_cf
25              
26 17     17 1 13530 sub is_valid_cf ($cf, %options) {
  17         35  
  17         27  
  17         26  
27 17         33 my $error = 0;
28 17     14   100 _validate_cf($cf, $options{data}, sub { $error = 1; return 0 });
  14         26  
  14         65  
29 17         91 return !$error;
30             }
31              
32 17     17 1 5243 sub validate_cf ($cf, %options) {
  17         30  
  17         27  
  17         33  
33 17   100     57 my $data = $options{data} // undef;
34              
35 17   50     55 my $collect_all_errors = $options{all_errors} // 1;
36 17         31 my @errors;
37 17     17   26 my $callback = sub ($msg) {
  17         31  
  17         22  
38 17         31 push @errors, $msg;
39 17         67 return $collect_all_errors;
40 17         73 };
41              
42 17         46 _validate_cf($cf, $data, $callback);
43              
44 17 100       96 return scalar(@errors) ? \@errors : undef;
45             } ## end sub validate_cf
46              
47             # The following is useful for one-lines:
48             #
49             # $ perl -MValidate::CodiceFiscale=r -er bcadfe88a48h501p
50             #
51 0     0 1 0 sub r (@args) {
  0         0  
  0         0  
52 0 0       0 @args = @ARGV unless @args;
53 0         0 my $i = 0;
54 0         0 my $n = 0;
55 0         0 for my $cf (@ARGV) {
56 0 0       0 if (my $errors = validate_cf($cf)) {
57 0         0 say "$i not ok - " . join(', ', $errors->@*);
58 0         0 ++$n;
59             }
60             else {
61 0         0 say "$i ok - $cf";
62             }
63 0         0 ++$i;
64             } ## end for my $cf (@ARGV)
65 0 0       0 return $n ? 1 : 0;
66             } ## end sub r
67              
68             exit r(@ARGV) unless caller(); # modulino
69              
70             # PRIVATE interface
71              
72 34     34   46 sub _validate_cf ($cf, $data, $cb) {
  34         53  
  34         57  
  34         51  
  34         43  
73 34         62 state $consonant = qr{(?imxs:[BCDFGHJKLMNPQRSTVWXYZ])};
74 34         72 state $vowel = qr{(?imxs:[AEIOU])};
75 34         258 state $namish = qr{(?imxs:
76             $consonant $consonant $consonant # includes CCX, CXX, XXX
77             | $consonant $consonant $vowel
78             | $consonant $vowel $vowel
79             | $consonant $vowel X
80             | $vowel $vowel $vowel
81             | $vowel $vowel X
82             | $vowel X X
83             )};
84 34         69 state $digitish = qr{(?imxs:[0-9LMNPQRSTUV])};
85              
86 34 100       89 return $cb->('invalid length') if length($cf) != 16;
87              
88 32         65 $cf = uc($cf);
89              
90             return
91 32 100 100     425 if substr($cf, 0, 3) !~ m{\A$namish\z}mxs
92             && !$cb->('invalid surname');
93              
94             return
95 30 100 100     275 if substr($cf, 3, 3) !~ m{\A$namish\z}mxs
96             && !$cb->('invalid name');
97              
98             return
99 29 100 100     88 if !_is_valid_cf_date(substr($cf, 6, 5))
100             && !$cb->('invalid birth date');
101              
102             return
103 27 100 100     213 if substr($cf, 11, 4) !~ m{\A [A-Z] $digitish{3} \z}mxs
104             && !$cb->('invalid birth place');
105              
106 26         55 my $checksum = _cf_checksum($cf);
107             return
108 26 100 100     111 if $checksum ne substr($cf, -1, 1)
109             && !$cb->("invalid checksum (should be: $checksum)");
110              
111 24 100       66 return unless $data;
112              
113 14 100       42 if (defined(my $surname = $data->{surname})) {
114             return
115 4 100 100     10 if substr($cf, 0, 3) ne _compact_surname($surname)
116             && !$cb->('surname mismatch');
117             }
118 13 100       35 if (defined(my $name = $data->{name})) {
119             return
120 4 100 100     11 if substr($cf, 3, 3) ne _compact_name($name)
121             && !$cb->('name mismatch');
122             }
123 12 100       26 if (defined(my $birthdate = $data->{birthdate})) {
124 4         9 my ($male, $female) = _compact_birthdates($birthdate);
125 4         20 my $got = _normalized_birthdate(substr($cf, 6, 5));
126             return
127 4 100 66     44 if ($got ne $male)
      100        
128             && ($got ne $female)
129             && !$cb->('birth date mismatch');
130             } ## end if (defined(my $birthdate...))
131 11 100       34 if (defined(my $sex = $data->{sex})) {
132 6         16 my $got = _normalized_birthdate(substr($cf, 6, 5));
133 6         18 my $day = substr($got, -2, 2) + 0;
134             return
135 6 100 66     45 if ((lc($sex) eq 'm' && $day > 31)
      100        
136             || (lc($sex) eq 'f' && $day < 41))
137             && !$cb->('sex mismatch');
138             } ## end if (defined(my $sex = ...))
139 10 100       26 if (defined(my $place = $data->{birthplace})) {
140 4         9 my $got = _normalized_birthplace(substr($cf, 11, 4));
141 4         10 $place = _normalized_birthplace($place);
142             return
143 4 100 100     41 if fc($got) ne fc($place)
144             && !$cb->('birth place mismatch');
145             } ## end if (defined(my $place ...))
146              
147 9         25 return;
148             } ## end sub _validate_cf
149              
150 26     26   43 sub _cf_checksum ($cf) {
  26         35  
  26         30  
151 26         344 state $odd_checksums = {
152             0 => 1,
153             1 => 0,
154             2 => 5,
155             3 => 7,
156             4 => 9,
157             5 => 13,
158             6 => 15,
159             7 => 17,
160             8 => 19,
161             9 => 21,
162             A => 1,
163             B => 0,
164             C => 5,
165             D => 7,
166             E => 9,
167             F => 13,
168             G => 15,
169             H => 17,
170             I => 19,
171             J => 21,
172             K => 2,
173             L => 4,
174             M => 18,
175             N => 20,
176             O => 11,
177             P => 3,
178             Q => 6,
179             R => 8,
180             S => 12,
181             T => 14,
182             U => 16,
183             V => 10,
184             W => 22,
185             X => 25,
186             Y => 24,
187             Z => 23,
188             },
189             my $even_checksums = {
190             0 => 0,
191             1 => 1,
192             2 => 2,
193             3 => 3,
194             4 => 4,
195             5 => 5,
196             6 => 6,
197             7 => 7,
198             8 => 8,
199             9 => 9,
200             A => 0,
201             B => 1,
202             C => 2,
203             D => 3,
204             E => 4,
205             F => 5,
206             G => 6,
207             H => 7,
208             I => 8,
209             J => 9,
210             K => 10,
211             L => 11,
212             M => 12,
213             N => 13,
214             O => 14,
215             P => 15,
216             Q => 16,
217             R => 17,
218             S => 18,
219             T => 19,
220             U => 20,
221             V => 21,
222             W => 22,
223             X => 23,
224             Y => 24,
225             Z => 25,
226             };
227 26         51 state $checksums_for = [$odd_checksums, $even_checksums];
228 26         130 my @chars = split m{}mxs, substr($cf, 0, 15); # no checksum
229 26         108 my $sum = sum map { $checksums_for->[$_ % 2]{$chars[$_]} } 0 .. $#chars;
  390         770  
230 26         186 chr(ord('A') + ($sum % 26));
231             } ## end sub _cf_checksum
232              
233 47     47   70 sub _normalized_string ($string, @positions) {
  47         63  
  47         84  
  47         60  
234 47         67 state $letters = [qw< L M N P Q R S T U V >];
235 47         62 state $digit_for = {map { $letters->[$_] => $_ } 0 .. $letters->$#*};
  10         20  
236 47         90 for my $i (@positions) {
237 180         271 my $current = substr($string, $i, 1);
238             substr($string, $i, 1, $digit_for->{$current})
239 180 50       352 if exists $digit_for->{$current};
240             }
241 47         134 return $string;
242             } ## end sub _normalized_string
243              
244 8     8   13 sub _normalized_birthplace ($place) { _normalized_string($place, 1 .. 3) }
  8         20  
  8         12  
  8         23  
245 39     39   60 sub _normalized_birthdate ($date) { _normalized_string($date, 0, 1, 3, 4) }
  39         68  
  39         52  
  39         78  
246              
247 29     29   42 sub _is_valid_cf_date ($date) {
  29         73  
  29         43  
248 29         48 state $mlf = [split m{}mxs, 'ABCDEHLMPRST'];
249 29         47 state $month_for = {map { $mlf->[$_] => $_ } 0 .. $mlf->$#*};
  12         28  
250              
251 29         63 $date = _normalized_birthdate($date);
252 29         120 my ($y, $mc, $d) = $date =~ m{\A(\d\d)([ABCDEHLMPRST])(\d\d)\z}mxs;
253 29         75 my $m = $month_for->{$mc};
254 29         94 $_ += 0 for ($d, $y);
255 29 100       65 $d -= 40 if $d > 40;
256              
257             return !0
258             if (($m != 1) || ($y % 100) || ($d < 29))
259 29 100 33     120 && eval { timegm(30, 30, 12, $d, $m, $y + 1900); 1 };
  29   66     103  
  25         757  
260              
261             # We have $y = 0 but we might have uncertainty as to which exact century
262             # we have to consider, and they have different opinions about being leap
263             # or not.
264              
265 4 50       977 return !1 if $d != 29; # this check is trivial
266              
267             # try out "meaningful" centuries. CF was introduced in 19xx so it makes
268             # sense to check from 19 up to the current one. We mean "century" as
269             # "whatever year without the last two digits", so 20th century is 19
270 0         0 my $current_century = 19 + int((gmtime)[5] / 100);
271 0         0 for my $century (reverse(19 .. $current_century)) {
272 0 0       0 return !0 if eval { timegm(30, 30, 12, $d, $m, $century * 100); 1 };
  0         0  
  0         0  
273             }
274              
275 0         0 return !1;
276             } ## end sub _is_valid_cf_date
277              
278 4     4   5 sub _compact_birthdates ($birthdate) {
  4         7  
  4         6  
279 4         9 state $month_letter_for = ['', split m{}mxs, 'ABCDEHLMPRST'];
280 4         21 my ($y, $m, $d) = split m{\D}mxs, $birthdate;
281 4 50       13 ($y, $d) = ($d, $y) if $d > 31;
282 4         7 $y %= 100;
283 4         10 $m = $month_letter_for->[$m + 0];
284 4         19 map { sprintf '%02d%s%02d', $y, $m, $_ } ($d, $d + 40);
  8         39  
285             } ## end sub _compact_birthdates
286              
287 4     4   7 sub _compact_surname ($surname) {
  4         7  
  4         7  
288 4         9 my ($cs, $vs) = _consonants_and_vowels($surname);
289 4         15 my @retval = ($cs->@*, $vs->@*, ('X') x 3);
290 4         32 return join '', @retval[0 .. 2];
291             }
292              
293 4     4   7 sub _compact_name ($name) {
  4         5  
  4         5  
294 4         8 my ($cs, $vs) = _consonants_and_vowels($name);
295 4 100       16 splice $cs->@*, 1, 1 if $cs->@* > 3;
296 4         24 my @retval = ($cs->@*, $vs->@*, ('X') x 3);
297 4         35 return join '', @retval[0 .. 2];
298             } ## end sub _compact_name
299              
300 8     8   10 sub _consonants_and_vowels ($string) {
  8         13  
  8         11  
301 8         13 my (@consonants, @vowels);
302 8         34 for my $char (grep { m{[A-Z]}mxs } split m{}mxs, uc($string)) {
  42         105  
303 42 100       105 if ($char =~ m{[AEIOU]}mxs) { push @vowels, $char }
  20         40  
304 22         42 else { push @consonants, $char }
305             }
306 8         24 return (\@consonants, \@vowels);
307             } ## end sub _consonants_and_vowels
308              
309             1;
310              
311             __END__