File Coverage

lib/File/ValueFile/Simple/Reader.pm
Criterion Covered Total %
statement 95 241 39.4
branch 20 142 14.0
condition 7 134 5.2
subroutine 17 28 60.7
pod 8 8 100.0
total 147 553 26.5


line stmt bran cond sub pod time code
1             # Copyright (c) 2024-2025 Philipp Schafft
2              
3             # licensed under Artistic License 2.0 (see LICENSE file)
4              
5             # ABSTRACT: module for reading and writing ValueFile files
6              
7             package File::ValueFile::Simple::Reader;
8              
9 2     2   357561 use v5.10;
  2         9  
10 2     2   14 use strict;
  2         4  
  2         60  
11 2     2   15 use warnings;
  2         4  
  2         189  
12              
13 2     2   16 use parent qw(Data::Identifier::Interface::Userdata Data::Identifier::Interface::Subobjects);
  2         5  
  2         19  
14              
15 2     2   5188 use Carp;
  2         13  
  2         185  
16 2     2   14 use Fcntl qw(SEEK_SET);
  2         4  
  2         176  
17 2     2   546 use URI::Escape qw(uri_unescape);
  2         2354  
  2         172  
18 2     2   15 use Encode ();
  2         4  
  2         71  
19              
20 2     2   690 use Data::Identifier v0.06;
  2         175960  
  2         15  
21 2     2   554 use File::ValueFile;
  2         4  
  2         443  
22              
23             use constant {
24 2         7555 RE_ISE => qr/^(?:[0-9a-f]{8}-(?:[0-9a-f]{4}-){3}[0-9a-f]{12}|[0-2](?:\.(?:0|[1-9][0-9]*))+|[a-zA-Z][a-zA-Z0-9\+\.\-]+:.*)$/,
25             KEYWORD_OK => qr/^[a-zA-Z0-9\-:\._~]*$/,
26             FORMAT_ISE => '54bf8af4-b1d7-44da-af48-5278d11e8f32',
27             ASI_ISE => 'ddd60c5c-2934-404f-8f2d-fcb4da88b633',
28             TAGNAME_ISE => 'bfae7574-3dae-425d-89b1-9c087c140c23',
29             DOT_REPEAT_ISE => '2ec67bbe-4698-4a0c-921d-1f0951923ee6',
30 2     2   17 };
  2         3  
31              
32             our $VERSION = v0.10;
33              
34              
35              
36             sub new {
37 1     1 1 478 my ($pkg, $in, %opts) = @_;
38 1         2 my $fh;
39 1         1 my $self = bless \%opts;
40              
41 1 50       3 if (ref $in) {
42 1         2 $fh = $in;
43             } else {
44 0 0       0 open($fh, '<', $in) or croak $!;
45             }
46              
47 1         6 $self->{fh} = $fh;
48              
49 1         2 foreach my $key (qw(supported_formats supported_features)) {
50 2   50     9 $self->{$key} ||= 'all';
51 2 50 33     7 if (ref($self->{$key}) ne 'ARRAY' && $self->{$key} ne 'all') {
52 0         0 $self->{$key} = [$self->{$key}];
53             }
54 2 50       3 if (ref($self->{$key})) {
55 0         0 foreach my $entry (@{$self->{$key}}) {
  0         0  
56 0 0       0 $entry = Data::Identifier->new(ise => $entry) unless ref $entry;
57             }
58             }
59             }
60              
61 1 50       2 if (ref($self->{supported_features})) {
62 0         0 push(@{$self->{supported_features}}, Data::Identifier->new(ise => DOT_REPEAT_ISE));
  0         0  
63             }
64              
65 1   50     4 $self->{utf8} = $opts{utf8} //= 'auto';
66 1 50 33     5 if ($opts{utf8} && $opts{utf8} ne 'auto') {
67 0         0 $self->{unescape} = \&_unescape_utf8;
68             } else {
69 1         2 $self->{unescape} = \&uri_unescape;
70             }
71              
72 1         2 $self->{dot_repreat} = 0;
73              
74 1         6 return $self;
75             }
76              
77             sub _special {
78 0     0   0 my ($str) = @_;
79              
80 0 0       0 if ($str eq '!null') {
    0          
81 0         0 return undef;
82             } elsif ($str eq '!empty') {
83 0         0 return '';
84             } else {
85 0         0 croak 'Invalid input';
86             }
87             }
88              
89             sub _check_supported {
90 1     1   46 my ($self, $key, $value) = @_;
91 1         2 my $list = $self->{$key};
92 1         3 my $ise = $value->ise;
93              
94 1 50       16 return if $list eq 'all';
95              
96 0         0 foreach my $entry (@{$list}) {
  0         0  
97 0 0       0 return if $entry->ise eq $ise;
98             }
99              
100 0         0 croak 'Unsupported value for '.$key.': '.$ise;
101             }
102              
103             sub _handle_special {
104 1     1   3 my ($self, $type, $marker, @args) = @_;
105 1         3 my $line = $self->{fh}->input_line_number;
106              
107 1 50       13 if ($marker eq 'ValueFile') {
    0          
108 1 0 33     5 @args = @args[0,1] if scalar(@args) == 4 && !defined($args[-1]) && !defined($args[-2]);
      33        
109 1 50       2 croak 'ValueFile (magic) marker at wrong line' unless $line == 1;
110 1 50       2 croak 'ValueFile (magic) marker not marked required' unless $type eq '!';
111 1 50 33     5 croak 'ValueFile (magic) marker with wrong number of arguments' unless scalar(@args) && scalar(@args) <= 2;
112 1 50       3 croak 'ValueFile (magic) marker not using supported format' unless $args[0] eq FORMAT_ISE;
113              
114 1 50       2 if (scalar(@args) > 1) {
115 1         8 $self->_check_supported(supported_formats => $self->{format} = Data::Identifier->new(ise => $args[1]));
116             }
117              
118 1 50       4 $self->_check_utf8($marker => $self->{format}) if $self->{utf8} eq 'auto';
119              
120 1         2 return;
121             } elsif ($marker eq 'Feature') {
122 0         0 my $id;
123              
124 0 0       0 croak 'Feature marker with wrong number of arguments' unless scalar(@args) == 1;
125              
126 0   0     0 push(@{$self->{features} //= []}, $id = Data::Identifier->new(ise => $args[0]));
  0         0  
127              
128 0 0       0 $self->_check_supported(supported_features => $id) if $type eq '!';
129 0 0       0 $self->_check_utf8($marker => $id) if $self->{utf8} eq 'auto';
130 0   0     0 $self->{dot_repreat} ||= $id->eq(DOT_REPEAT_ISE);
131              
132 0         0 return;
133             }
134              
135 0         0 croak 'Invalid marker: '.$marker;
136             }
137              
138             sub _check_utf8 {
139 1     1   1 my ($self, $marker, $id) = @_;
140 1 50       6 if (File::ValueFile->_is_utf8($id)) {
141 1         22 $self->{unescape} = \&_unescape_utf8;
142 1         2 $self->{utf8} = 1;
143             }
144             }
145              
146              
147             sub read_to_cb {
148 1     1 1 3 my ($self, $cb) = @_;
149 1         1 my $fh = $self->{fh};
150 1         1 my $unescape = $self->{unescape};
151 1         1 my @last_line;
152              
153 1         82 $fh->seek(0, SEEK_SET);
154 1         7146 $fh->input_line_number(0);
155              
156 1         16 delete $self->{format};
157 1         2 delete $self->{features};
158              
159 1         7 while (my $line = <$fh>) {
160 1         6 $line =~ s/\r?\n$//;
161 1         10 $line =~ s/#.*$//;
162 1         2 $line =~ s/^\xEF\xBB\xBF//; # skip BOMs.
163 1         4 $line =~ s/\s+/ /g;
164 1         2 $line =~ s/ $//;
165 1         1 $line =~ s/^ //;
166              
167 1 50       3 next unless length $line;
168              
169 1 50       3 if ($line =~ s/^\!([\!\?\&])//) {
170 1         3 my $type = $1;
171              
172 1 50       3 if ($self->{dot_repreat}) {
173 0         0 my @line = split(/\s+/, $line);
174 0         0 my $x = 0;
175 0         0 foreach my $e (@line) {
176 0 0       0 if ($e eq '.') {
    0          
    0          
    0          
177 0         0 $e = $last_line[$x];
178             } elsif ($e =~ s/^\.\.+$//) {
179             # done in match
180             } elsif ($e =~ KEYWORD_OK) {
181             # no-op
182             } elsif ($e =~ /^\!/) {
183 0         0 $e = _special($_);
184             } else {
185 0         0 $e = $unescape->($e);
186             }
187 0         0 $x++;
188             }
189              
190 0         0 $self->_handle_special($type, @line);
191 0         0 @last_line = @line;
192             } else {
193             $self->_handle_special($type, map{
194 1 0       2 $_ =~ KEYWORD_OK ? $_ :
  3 50       11  
195             $_ =~ /^\!/ ? _special($_) : $unescape->($_)
196             }(split(/\s+/, $line)));
197             }
198              
199             # Reload:
200 1         1 $unescape = $self->{unescape};
201              
202 1         6 next;
203             }
204              
205 0 0       0 if ($self->{dot_repreat}) {
206 0         0 my @line = split(/\s+/, $line);
207 0         0 my $x = 0;
208 0         0 foreach my $e (@line) {
209 0 0       0 if ($e eq '.') {
    0          
    0          
    0          
210 0         0 $e = $last_line[$x];
211             } elsif ($e =~ /^\.+$/) {
212 0         0 $e =~ s/^\.//;
213             } elsif ($e =~ KEYWORD_OK) {
214             # no-op
215             } elsif ($e =~ /^\!/) {
216 0         0 $e = _special($e);
217             } else {
218 0         0 $e = $unescape->($e);
219             }
220 0         0 $x++;
221             }
222              
223 0         0 $self->$cb(@line);
224 0         0 @last_line = @line;
225             } else {
226             $self->$cb(map{
227 0 0       0 $_ =~ KEYWORD_OK ? $_ :
  0 0       0  
228             $_ =~ /^\!/ ? _special($_) : $unescape->($_)
229             }(split(/\s+/, $line)));
230             }
231             }
232             }
233              
234              
235             sub read_as_hash {
236 0     0 1 0 my ($self) = @_;
237 0         0 my %hash;
238              
239             $self->read_to_cb(sub {
240 0     0   0 my (undef, @line) = @_;
241 0 0       0 croak 'Invalid data: Not key-value' unless scalar(@line) == 2;
242 0 0       0 croak 'Invalid data: Null key' unless defined($line[0]);
243 0 0       0 croak 'Invalid data: Duplicate key: '.$line[0] if exists $hash{$line[0]};
244 0         0 $hash{$line[0]} = $line[1];
245 0         0 });
246              
247 0         0 return \%hash;
248             }
249              
250              
251             sub read_as_hash_of_arrays {
252 0     0 1 0 my ($self) = @_;
253 0         0 my %hash;
254              
255             $self->read_to_cb(sub {
256 0     0   0 my (undef, @line) = @_;
257 0 0       0 croak 'Invalid data: Not key-value' unless scalar(@line) == 2;
258 0 0       0 croak 'Invalid data: Null key' unless defined($line[0]);
259 0   0     0 push(@{$hash{$line[0]} //=[]}, $line[1]);
  0         0  
260 0         0 });
261              
262 0         0 return \%hash;
263             }
264              
265              
266             sub read_as_simple_tree {
267 0     0 1 0 my ($self) = @_;
268 0         0 my $tree;
269              
270             $self->read_to_cb(sub {
271 0     0   0 my (undef, @line) = @_;
272 0         0 my $root = \$tree;
273              
274 0         0 while (scalar(@line) > 1) {
275 0         0 my $el = shift(@line);
276              
277 0 0       0 if (ref(${$root})) {
  0         0  
278 0         0 $root = \${$root}->{$el};
  0         0  
279             } else {
280 0         0 ${$root} = {
281 0 0       0 (defined(${$root}) ? (_ => ${$root}) : ()),
  0         0  
  0         0  
282             $el => undef,
283             };
284 0         0 $root = \${$root}->{$el};
  0         0  
285             }
286             }
287              
288 0 0       0 if (ref(${$root}) eq 'ARRAY') {
  0 0       0  
289 0         0 push(@{${$root}}, @line);
  0         0  
  0         0  
290 0         0 } elsif (defined ${$root}) {
291 0 0       0 croak 'Invalid data with mixed number of levels' if ref ${$root};
  0         0  
292 0         0 ${$root} = [${$root}, @line];
  0         0  
  0         0  
293             } else {
294 0         0 ${$root} = $line[0];
  0         0  
295             }
296 0         0 });
297              
298 0         0 return $tree;
299             }
300              
301              
302             sub read_as_taglist {
303 0     0 1 0 state $tagpool_source_format = Data::Identifier->new(uuid => 'e5da6a39-46d5-48a9-b174-5c26008e208e', displayname => 'tagpool-source-format')->register;
304 0         0 state $tagpool_taglist_format_v1 = Data::Identifier->new(uuid => 'afdb46f2-e13f-4419-80d7-c4b956ed85fa', displayname => 'tagpool-taglist-format-v1')->register;
305 0         0 state $tagpool_httpd_htdirectories_format = Data::Identifier->new(uuid => '25990339-3913-4b5a-8bcf-5042ef6d8b5e', displayname => 'tagpool-httpd-htdirectories-format')->register;
306 0         0 my ($self, %opts) = @_;
307 0         0 my $as = delete $opts{as};
308 0         0 my %list;
309             my $format;
310              
311 0 0       0 croak 'Stray options passed' if scalar keys %opts;
312              
313             $self->read_to_cb(sub {
314 0     0   0 my (undef, @line) = @_;
315 0         0 my $tag;
316              
317 0   0     0 $format //= $self->format(default => undef);
318              
319 0 0 0     0 if ((Data::Identifier::eq($format, $tagpool_source_format) || Data::Identifier::eq($format, $tagpool_taglist_format_v1)) && scalar(@line) >= 2 && defined($line[0]) && defined($line[1])) {
    0 0        
    0 0        
      0        
      0        
      0        
      0        
      0        
      0        
320 0 0 0     0 if ($line[0] eq 'tag' && scalar(@line) == 3) {
    0 0        
    0 0        
      0        
      0        
      0        
      0        
      0        
      0        
      0        
      0        
      0        
321 0         0 $tag = Data::Identifier->new(ise => $line[1], displayname => $line[2]);
322             } elsif ($line[0] eq 'tag-metadata' && scalar(@line) == 7 && defined($line[2]) && !defined($line[3]) && defined($line[4]) && !defined($line[5]) && defined($line[6]) && $line[2] eq ASI_ISE && $line[4] eq TAGNAME_ISE) {
323 0         0 $tag = Data::Identifier->new(ise => $line[1], displayname => $line[6]);
324             } elsif ($line[0] =~ /^tag(?:-.+)?$/ || $line[0] eq 'rule' || $line[0] eq 'filter' || $line[0] eq 'subject') {
325 0         0 $tag = Data::Identifier->new(ise => $line[1]);
326             }
327             } elsif (Data::Identifier::eq($format, $tagpool_httpd_htdirectories_format) && scalar(@line) == 3 && defined($line[0]) && defined($line[1]) && defined($line[2]) && $line[0] eq 'directory') {
328 0         0 $tag = Data::Identifier->new(ise => $line[1]);
329             } elsif (!defined($format)) {
330 0 0 0     0 if (scalar(@line) > 1 && defined($line[0]) && defined($line[1]) && $line[0] =~ /^tag-(?:ise|metadata|relation)$/) {
    0 0        
      0        
      0        
331 0 0 0     0 if ($line[0] eq 'tag-metadata' && scalar(@line) == 7 && defined($line[2]) && !defined($line[3]) && defined($line[4]) && !defined($line[5]) && defined($line[6]) && $line[2] eq ASI_ISE && $line[4] eq TAGNAME_ISE) {
      0        
      0        
      0        
      0        
      0        
      0        
      0        
332 0         0 $tag = Data::Identifier->new(ise => $line[1], displayname => $line[6]);
333             } else {
334 0         0 $tag = Data::Identifier->new(ise => $line[1]);
335             }
336             } elsif ($line[0] eq 'tag' && scalar(@line) == 3) {
337 0         0 $tag = Data::Identifier->new(ise => $line[1], displayname => $line[2]);
338             }
339              
340 0 0       0 unless (defined $tag) {
341 0         0 foreach my $entry (@line) {
342 0 0 0     0 if (defined($entry) && $entry =~ RE_ISE) {
343 0         0 my $tag = Data::Identifier->new(ise => $entry);
344 0   0     0 $list{$tag->ise} //= $tag;
345             }
346             }
347             }
348             }
349              
350 0 0       0 if (defined $tag) {
351 0         0 my $ise = $tag->ise;
352 0         0 my $old = $list{$ise};
353              
354 0 0       0 if (defined $old) {
355 0 0       0 $tag = $old if defined $old->displayname(default => undef, no_defaults => 1);
356             }
357              
358 0         0 $list{$tag->ise} = $tag;
359             }
360 0         0 });
361              
362 0 0       0 return [map {$_->as($as, so => $self)} values %list] if defined $as;
  0         0  
363              
364 0         0 return [values %list];
365             }
366              
367              
368             sub format {
369 1     1 1 2 my ($self, %opts) = @_;
370              
371 1 50       2 if (defined $self->{format}) {
372 1 50       3 return $self->{format}->as($opts{as}, so => $self) if defined $opts{as};
373 1         3 return $self->{format};
374             }
375              
376 0 0         return $opts{default} if exists $opts{default};
377 0           croak 'No value for format';
378             }
379              
380              
381             sub features {
382 0     0 1   my ($self, %opts) = @_;
383 0 0         return @{$self->{features}} if defined $self->{features};
  0            
384 0 0         return @{$opts{default}} if exists $opts{default};
  0            
385 0           croak 'No value for features';
386             }
387              
388              
389             # ---- Private helpers ----
390              
391             sub _unescape_utf8 {
392 0     0     my ($text) = @_;
393 0           state $utf8 = Encode::find_encoding('UTF-8');
394 0           return $utf8->decode(uri_unescape($text));
395             }
396              
397             1;
398              
399             __END__