File Coverage

blib/lib/Datafile/Hash.pm
Criterion Covered Total %
statement 150 195 76.9
branch 59 112 52.6
condition 41 77 53.2
subroutine 11 11 100.0
pod 2 2 100.0
total 263 397 66.2


line stmt bran cond sub pod time code
1             package Datafile::Hash;
2              
3 4     4   511130 use strict;
  4         11  
  4         191  
4 4     4   46 use warnings;
  4         8  
  4         218  
5 4     4   94 use 5.014;
  4         18  
6 4     4   27 use Exporter 'import';
  4         30  
  4         162  
7 4     4   20 use Carp;
  4         6  
  4         14670  
8              
9             our @EXPORT_OK = qw(readhash writehash);
10             our $VERSION = '1.05';
11              
12 36 50 33 36   437 sub _trim { $_[0] =~ s/^\s+|\s+$//gr if defined $_[0] && $_[1] }
13              
14             sub readhash {
15 3     3 1 40 my ( $filename, $data, $opts ) = @_;
16 3   100     16 $opts //= {};
17              
18 3   100     20 my $delim = $opts->{delimiter} // '=';
19 3   50     20 my $skip_empty = $opts->{skip_empty} // 1;
20 3   50     21 my $skip_headers = $opts->{skip_headers} // 0;
21 3   50     43 my $key_fields = $opts->{key_fields} // 1;
22 3   50     18 my $comment_char = $opts->{comment_char} // '#';
23 3         7 my $search = $opts->{search};
24 3   50     14 my $verbose = $opts->{verbose} // 0;
25 3   100     13 my $group_mode = $opts->{group} // 2;
26 3 50 33     18 my $ini_mode = ( $delim eq '=' || $delim eq ':' ) ? 1 : 0;
27              
28 3         25 my @messages = ();
29 3 50       20 if ($verbose) {
30 0         0 push @messages, "# readhash: delimiter='$delim', key_fields=$key_fields\n";
31             push @messages,
32 0         0 "# opts: " . join( ", ", map { "$_=$opts->{$_}" } sort keys %$opts ) . "\n";
  0         0  
33             }
34              
35 3         7 my @compiled_patterns = ();
36 3 0 0     15 if ( defined $search && ( ref $search || length $search ) ) {
      33        
37 0 0       0 my @raw = ref $search eq 'ARRAY' ? @$search : ($search);
38 0         0 for my $pat (@raw) {
39 0 0       0 next unless defined $pat;
40 0 0       0 my $regex = ref $pat eq 'Regexp' ? $pat : qr/\Q$pat\E/i;
41 0         0 push @compiled_patterns, $regex;
42             }
43             }
44             croak
45 3 50       20 "datafile::hash::readhash: second argument (\$data) must be a HASH reference"
46             unless ref $data eq 'HASH';
47              
48 3 50       161 open my $fh, '<:encoding(UTF-8)', $filename
49             or return ( 0, ["WARNING: cannot open '$filename': $!"] );
50 3         249 %$data = ();
51              
52 3         8 my $entry_count = 0;
53 3         9 my $current_path = [];
54 3         6 my %structured = ();
55 3         9 my %groups_seen = ();
56              
57 3         141 while ( my $line = <$fh> ) {
58 30 100       474 next if $line =~ /^\s*\Q$comment_char\E/;
59 25 50       62 if ( $skip_headers > 0 ) {
60 0         0 $skip_headers--;
61 0         0 next;
62             }
63 25         163 $line =~ s/[\r\n\s]+$//;
64 25 100 66     117 next if $skip_empty && $line eq '';
65              
66 21 50       48 if (@compiled_patterns) {
67 0         0 my $all_matched = 1;
68 0         0 for my $regex (@compiled_patterns) {
69 0 0       0 $all_matched = 0 unless $line =~ $regex;
70 0 0       0 last unless $all_matched;
71             }
72 0 0       0 next unless $all_matched;
73             }
74              
75 21 100       101 if ( $line =~ /^\[\s*(.+?)\s*\]$/ ) {
76 6         18 my $section = _trim( $1, 1 );
77 6         38 @$current_path = split /\./, $section;
78              
79 6         19 my $path = '';
80 6         15 for my $part (@$current_path) {
81 7 100       21 $path = $path ? "$path.$part" : $part;
82 7         24 $groups_seen{$path}++;
83             }
84 6 50       14 push @messages, "- entering section [$section]\n" if $verbose;
85 6         58 next;
86             }
87              
88 15 50       60 unless ($ini_mode) {
89 0         0 my @fields = split /\Q${delim}\E/, $line, -1;
90 0 0       0 next if @fields < $key_fields + 1;
91              
92 0         0 my $key = join $delim, @fields[ 0 .. $key_fields - 1 ];
93 0         0 my $value = join $delim, @fields[ $key_fields .. $#fields ];
94 0         0 $data->{$key} = $value;
95             }
96             else {
97 15 50       36 if ( $key_fields > 1 ) {
98 0         0 push @messages,
99             "- warning: ignoring key_fields = $key_fields in INI mode\n";
100 0         0 $key_fields = 1;
101             }
102 15         150 my @fields = split /\Q$delim\E/, $line, 2;
103 15 50       47 next if @fields < 2;
104 15         50 my $key = join $delim, map { _trim( $_, 1 ) } @fields[ 0 .. $key_fields - 1 ];
  15         45  
105             my $value = join $delim,
106 15         44 map { _trim( $_, 1 ) } @fields[ $key_fields .. $#fields ];
  15         64  
107              
108 15 50       237 if ( $delim eq '=' ) {
109 15         155 $value =~ s/^"(.*)"$/$1/;
110 15         40 $value =~ s/\\"/"/g;
111             }
112 15         28 my $ref = \%structured;
113 15 100       43 $ref = $data if ( $group_mode == 2 );
114 15   100     88 $ref = ( $ref->{$_} //= {} ) for @$current_path;
115 15         106 $ref->{$key} = $value;
116             }
117 15         66 $entry_count++;
118             }
119 3         57 close $fh;
120              
121 3 100 66     108 if ( $ini_mode && $group_mode != 2 ) {
122 1         4 my $flatten;
123             $flatten = sub {
124 1     1   4 my ( $hash, $prefix ) = @_;
125 1         8 for my $k ( sort keys %$hash ) {
126 3         17 my $v = $hash->{$k};
127 3 50 33     11 my $full =
128             ( $group_mode == 1 && $prefix ne '' ) ? "$prefix.$k" : $k;
129 3 50       27 if ( ref $v eq 'HASH' ) {
130 0         0 $flatten->( $v, $full );
131             }
132             else {
133 3 50       13 $data->{ $group_mode == 0 ? $k : $full } = $v;
134             }
135             }
136 1         8 };
137 1         5 $flatten->( \%structured, '' );
138             }
139              
140 3 50 33     14 if ( $verbose && $ini_mode ) {
141 0         0 push @messages, "- groups: " . join( ',', sort keys %groups_seen ) . "\n";
142             }
143 3 50       10 push @messages, "- $entry_count data records read from $filename\n"
144             if $verbose;
145              
146 3         33 return ( $entry_count, \@messages, \%groups_seen );
147             }
148              
149             sub writehash {
150 3     3 1 709212 my ( $filename, $hash, $opts ) = @_;
151 3   100     21 $opts //= {};
152              
153 3   100     21 my $delim = $opts->{delimiter} // '=';
154 3   50     25 my $comment_char = $opts->{comment_char} // '#';
155 3   50     17 my $prot = $opts->{prot} // 0660;
156 3   100     20 my $backup = $opts->{backup} // 0;
157 3   50     19 my $verbose = $opts->{verbose} // 0;
158 3 50 33     35 my $ini_mode = ( $delim eq '=' || $delim eq ':' ) ? 1 : 0;
159              
160 3 50 33     77 unless ( $hash && ref $hash eq 'HASH' && keys %$hash ) {
      33        
161 0 0       0 if ( -f $filename ) {
162 0 0       0 unlink($filename)
163             or return ( 0,
164             ["ERROR: file $filename could not be deleted $!"] );
165 0         0 return ( 1, ["SUCCESS: file $filename was deleted.\n"] );
166             }
167             croak
168 0         0 "datafile::hash::writehash: second argument (\$data) must be a HASH reference"
169             }
170              
171 3         29 my $tmp = "$filename.tmp";
172 3         10 my @messages = ();
173 3 50       14 if ($verbose) {
174 0         0 push @messages, "# writehash: delimiter='$delim'\n";
175             push @messages,
176 0         0 "# opts: " . join( ", ", map { "$_=$opts->{$_}" } sort keys %$opts ) . "\n";
  0         0  
177             }
178              
179 3 50   3   196 open my $fh, '>:encoding(UTF-8):crlf', $tmp
  3         3029  
  3         62  
  3         23  
180             or return ( 0, ["ERROR: cannot open '$tmp' for writing: $!"] );
181              
182 3 100       5106 if ( my $comment = $opts->{comment} ) {
183 2 50       16 my @lines = ref $comment eq 'ARRAY' ? @$comment : split /\n/, $comment;
184 2         20 print $fh "$comment_char $_\n" for @lines;
185 2 50       10 if ($verbose) { push @messages, "> $comment_char $_\n" for @lines; }
  0         0  
186             }
187              
188 3         7 my $entry_count = 0;
189              
190 3 50       14 unless ($ini_mode) {
191 0         0 for my $k ( keys %$hash ) {
192 0         0 my $v = $hash->{$k};
193 0         0 print $fh "$k$delim$v\n";
194 0         0 $entry_count++;
195             }
196             }
197             else {
198 3         6 my $first_section = 1;
199 3         16 my %data = %$hash;
200 3         16 my $has_real_nested = grep { ref $data{$_} eq 'HASH' } keys %data;
  8         32  
201              
202 3 50 66     23 if ( !$has_real_nested && grep /\./, keys %data ) {
203 0         0 my %nested;
204 0         0 while ( my ( $k, $v ) = each %data ) {
205 0         0 my @p = split /\./, $k;
206 0         0 my $r = \%nested;
207 0   0     0 $r = ( $r->{$_} //= {} ) for @p[ 0 .. $#p - 1 ];
208 0         0 $r->{ $p[-1] } = $v;
209             }
210 0         0 %data = %nested;
211 0         0 $has_real_nested = 1;
212             }
213              
214 3         7 my %global_data;
215 3         16 for my $k ( keys %data ) {
216 8 100       28 next if ref $data{$k} eq 'HASH';
217 3         31 $global_data{$k} = delete $data{$k};
218             }
219 3         6 my $write_section;
220             $write_section = sub {
221 7     7   19 my ( $cur, $path ) = @_;
222 7 100       28 my $name = @$path ? join( '.', @$path ) : '';
223              
224 7 100       31 print $fh "\n" unless ($first_section);
225 7         13 $first_section = 0;
226 7 100       22 if ( $name ne '' ) {
227 6         31 print $fh "[$name]\n";
228 6 50       19 push @messages, "> [$name]\n" if $verbose;
229             }
230              
231 7         11 my $maxsize = 0;
232 7         25 for my $k ( sort grep { !ref $cur->{$_} } keys %$cur ) {
  16         58  
233 15 100       45 $maxsize = length($k) if length($k) > $maxsize;
234             }
235 7         21 for my $k ( sort grep { !ref $cur->{$_} } keys %$cur ) {
  16         37  
236 15         28 my $v = $cur->{$k};
237 15   66     179 my $needs_quoting =
238             ( $v =~ /[#"'\r\n]/ || $v =~ /^\s+|\s+$/ || $v eq '' );
239 15 100 66     66 if ( $delim eq '=' && $needs_quoting ) {
240 1         6 $v =~ s/"/\\"/g;
241 1         14 $v = qq("$v");
242             }
243 15         53 my $line = sprintf "%-*s %s %s", $maxsize, $k, $delim, $v;
244 15         42 print $fh "$line\n";
245 15         31 $entry_count++;
246             }
247              
248 7         22 for my $sub ( sort grep { ref $cur->{$_} eq 'HASH' } keys %$cur ) {
  16         77  
249 1         9 $write_section->( $cur->{$sub}, [ @$path, $sub ] );
250             }
251 3         26 };
252              
253 3 100       13 if ( keys %global_data ) {
254 1         6 $write_section->( \%global_data, [] );
255             }
256 3         45 for my $top ( sort keys %data ) {
257 5         21 $write_section->( $data{$top}, [$top] );
258             }
259             }
260 3 50       29 print $fh "#EOF\n" if $comment_char eq '#';
261 3 50       473 close $fh
262             or return ( 0, ["ERROR: failed to close '$tmp': $!"] );
263              
264 3 50 33     22 if ( $backup && -f $filename ) {
265 0 0       0 rename( $filename, $filename . '.bak' )
266             or push @messages,
267             "WARNING: backup to ${filename}.bak failed: $!";
268             }
269 3 50       467 rename( $tmp, $filename )
270             or return ( 0, ["ERROR: failed to rename '$tmp' to '$filename': $!"] );
271 3         99 chmod $prot, $filename;
272              
273 3 50       15 push @messages, "- $entry_count entries written to $filename\n"
274             if $verbose;
275 3         35 return ( $entry_count, \@messages );
276             }
277              
278             1;
279              
280             __END__