File Coverage

lib/App/Followme/NestedText.pm
Criterion Covered Total %
statement 220 222 99.1
branch 100 110 90.9
condition 8 9 88.8
subroutine 20 20 100.0
pod 6 14 42.8
total 354 375 94.4


line stmt bran cond sub pod time code
1             package App::Followme::NestedText;
2              
3 18     18   2150 use 5.008005;
  18         90  
4 18     18   116 use strict;
  18         45  
  18         465  
5 18     18   109 use warnings;
  18         42  
  18         579  
6 18     18   100 use integer;
  18         35  
  18         115  
7 18     18   497 use lib '../..';
  18         47  
  18         88  
8              
9             our $VERSION = "2.02";
10              
11 18     18   2848 use App::Followme::FIO;
  18         40  
  18         52027  
12              
13             require Exporter;
14             our @ISA = qw(Exporter);
15             our @EXPORT = qw(nt_parse_almost_yaml_file nt_parse_almost_xml_file
16             nt_parse_almost_yaml_string nt_parse_almost_xml_string
17             nt_write_almost_yaml_file nt_write_almost_xml_file
18             nt_merge_items);
19              
20             #----------------------------------------------------------------------
21             # Merge items from two nested lists
22              
23             sub nt_merge_items {
24 54     54 0 123 my ($old_config, $new_config) = @_;
25              
26 54         76 my $final_config;
27 54         98 my $ref = ref $old_config;
28              
29 54 50       120 if ($ref eq ref $new_config) {
30 54 100       136 if ($ref eq 'ARRAY') {
    100          
31 21         45 $final_config = [];
32 21         54 @$final_config = @$old_config;
33 21         53 my %old = map {$_ => 1} @$old_config;
  5         16  
34              
35 21         46 foreach my $item (@$new_config) {
36 29 100       110 push(@$final_config, $item) unless $old{$item};
37             }
38              
39             } elsif ($ref eq 'HASH') {
40 23         38 $final_config = {};
41 23         116 %$final_config = %$old_config;
42              
43 23         101 foreach my $name (keys %$new_config) {
44 43 100       99 if (exists $old_config->{$name}) {
45             $final_config->{$name} = nt_merge_items($old_config->{$name},
46 32         115 $new_config->{$name});
47             } else {
48 11         36 $final_config->{$name} = $new_config->{$name};
49             }
50             }
51              
52             } else {
53 10         18 $final_config = $new_config;
54             }
55              
56             } else {
57 0         0 $final_config = $new_config;
58             }
59              
60 54         138 return $final_config;
61             }
62              
63             #----------------------------------------------------------------------
64             # Read file in "almost yaml" format
65              
66             sub nt_parse_almost_yaml_file {
67 22     22 1 66 my ($filename) = @_;
68              
69 22         38 my %configuration;
70 22         74 my $page = fio_read_page($filename);
71              
72 22         59 eval {%configuration = nt_parse_almost_yaml_string($page)};
  22         79  
73 22 50       94 die "$filename: $@" if $@;
74              
75 22         110 return %configuration;
76             }
77              
78             #----------------------------------------------------------------------
79             # Read file in "almost xml" format
80              
81             sub nt_parse_almost_xml_file {
82 3     3 1 430 my ($filename) = @_;
83              
84 3         6 my %rss;
85 3         15 my $page = fio_read_page($filename);
86              
87 3         10 eval {%rss = nt_parse_almost_xml_string($page)};
  3         19  
88 3 50       13 die "$filename: $@" if $@;
89              
90 3         21 return %rss;
91             }
92              
93             #----------------------------------------------------------------------
94             # Read string in "almost yaml" Format
95              
96             sub nt_parse_almost_yaml_string {
97 98     98 1 11099 my ($page) = @_;
98              
99 98         362 my @lines = split(/\n/, $page);
100 98         312 my $block = parse_almost_yaml_block(\@lines);
101            
102 94 100       231 if (@lines) {
103 1         14 my $msg = trim_string(shift(@lines));
104 1         9 die("Bad indent at $msg\n");
105             }
106              
107 93 100       282 if (ref($block) ne 'HASH') {
108 1         7 die("Configuration must be a hash\n");
109             }
110              
111 92         458 return %$block;
112             }
113              
114             #----------------------------------------------------------------------
115             # Read string in "almost xml" Format
116              
117             sub nt_parse_almost_xml_string {
118 11     11 1 6328 my ($page) = @_;
119              
120 11         202 my @tokens = split(/(<[^>]*>)/, $page);
121 11         69 my ($block, $blockname) = parse_almost_xml_block(\@tokens);
122 8 100       37 die "Unexpected closing tag at </$blockname>\n" if $blockname;
123              
124 7         57 return %$block;
125             }
126              
127             #----------------------------------------------------------------------
128             # Write file in "almost yaml" Format
129              
130             sub nt_write_almost_yaml_file {
131 3     3 1 1215 my ($filename, %configuration) = @_;
132              
133 3         10 my ($type, $page) = format_almost_yaml_value(\%configuration);
134 3         9 $page .= "\n";
135              
136 3         14 fio_write_page($filename, $page);
137 3         12 return;
138             }
139              
140             #----------------------------------------------------------------------
141             # Write file in "almost xml" Format
142              
143             sub nt_write_almost_xml_file {
144 2     2 1 1176 my ($filename, %rss) = @_;
145              
146 2         6 my $page = "<?xml version=\"1.0\"?>\n";
147 2         10 $page .= format_almost_xml_value(\%rss);
148 2         8 $page .= "\n";
149              
150 2         13 fio_write_page($filename, $page);
151 2         8 return;
152             }
153              
154             #----------------------------------------------------------------------
155             # Format a value as a yaml string for writing
156              
157             sub format_almost_yaml_value {
158 60     60 0 5068 my ($value, $level) = @_;
159 60 100       121 $level = 0 unless defined $level;
160              
161 60         76 my $text;
162 60         100 my $type = ref $value;
163 60         116 my $leading = ' ' x (4 * $level);
164 60 100       154 if ($type eq 'ARRAY') {
    100          
    100          
165 5         11 my @subtext;
166 5         20 foreach my $subvalue (@$value) {
167 13         68 my ($subtype, $subtext) = format_almost_yaml_value($subvalue, $level+1);
168 13 50       29 if ($subtype) {
169 0         0 $subtext = $leading . "-\n" . $subtext;
170             } else {
171 13         30 $subtext = $leading . "- " . $subtext;
172             }
173 13         25 push (@subtext, $subtext);
174             }
175 5         19 $text = join("\n", @subtext);
176              
177             } elsif ($type eq 'HASH') {
178 12         23 my @subtext;
179 12         67 foreach my $name (sort keys %$value) {
180 38         66 my $subvalue = $value->{$name};
181 38         86 my ($subtype, $subtext) = format_almost_yaml_value($subvalue, $level+1);
182 38 100       78 if ($subtype) {
183 9         37 $subtext = $leading . "$name:\n" . $subtext;
184             } else {
185 29         68 $subtext = $leading . "$name: " . $subtext;
186             }
187 38         78 push (@subtext, $subtext);
188             }
189 12         39 $text = join("\n", @subtext);
190              
191             } elsif (length($value) > 60) {
192 1         3 $type = 'SCALAR';
193 1         9 my @subtext = split(/(\S.{0,59}\S*)/, $value);
194 1         7 @subtext = grep( /\S/, @subtext);
195 1         8 @subtext = map("$leading> $_", @subtext);
196 1         4 $text = join("\n", @subtext);
197            
198             } else {
199 42         92 $text = $value;
200             }
201              
202 60         157 return ($type, $text);
203             }
204              
205             #----------------------------------------------------------------------
206             # Format a value as an xml string for writing
207              
208             sub format_almost_xml_value {
209 66     66 0 3599 my ($value, $name, $level) = @_;
210 66 100       127 $name = '' unless defined $name;
211 66 100       110 $level = 0 unless defined $level;
212              
213 66         79 my $text;
214 66         104 my $type = ref $value;
215 66         109 my $leading = ' ' x (4 * $level);
216 66         143 my ($shortname) = split(/ /, $name);
217              
218 66 100       157 if ($type eq 'ARRAY') {
    100          
219 4         8 my @subtext;
220 4         12 foreach my $subvalue (@$value) {
221 12         49 my $subtext = format_almost_xml_value($subvalue, $name, $level);
222 12         23 push (@subtext, $subtext);
223             }
224 4         19 $text = join("\n", @subtext);
225              
226             } elsif ($type eq 'HASH') {
227 14         20 my @subtext;
228 14 100       39 $level += 1 if length $name;
229 14 100       40 push(@subtext, "$leading<$name>") if length $name;
230 14         34 foreach my $subname (sort_xml_hash($value)) {
231 48         75 my $subvalue = $value->{$subname};
232 48         109 my $subtext = format_almost_xml_value($subvalue, $subname, $level);
233 48         99 push (@subtext, $subtext);
234             }
235 14 100       58 push(@subtext, "$leading</$shortname>") if length $name;
236 14         112 $text = join("\n", @subtext);
237            
238             } else {
239 48 50       153 $text = length $name ? "$leading<$name>$value</$shortname>"
240             : $leading . $value;
241             }
242              
243 66         132 return $text;
244             }
245              
246             #----------------------------------------------------------------------
247             # Parse a block of "almost yaml" lines at the same indentation level
248              
249             sub parse_almost_yaml_block {
250 136     136 0 240 my ($lines) = @_;
251              
252 136         234 my @block;
253 136         234 my ($first_indent, $first_type);
254              
255 136         386 while (@$lines) {
256 308         551 my $line = shift(@$lines);
257 308         580 my ($indent, $value) = parse_almost_yaml_line($line);
258 307 100       625 next unless defined $indent;
259              
260 295 100       524 if (! defined $first_indent) {
261 107         156 $first_indent = $indent;
262 107         222 $first_type = ref($value);
263             }
264            
265 295 100       601 if ($indent == $first_indent) {
    100          
    50          
266 228         369 my $type = ref($value);
267              
268 228 100       462 if ($type ne $first_type) {
269 1         3 my $msg = trim_string($line);
270 1         7 die("Missing indent at $msg\n");
271             }
272              
273 227 100       474 if ($type eq 'ARRAY') {
    100          
274 51         169 push(@block, @$value);
275             } elsif ($type eq 'HASH') {
276 169         787 push(@block, %$value);
277             } else {
278 7         20 push(@block, $value);
279             }
280              
281             } elsif ($indent > $first_indent) {
282 40 100 66     193 if ($first_type ne 'ARRAY' &&
283             $first_type ne 'HASH') {
284 1         5 my $msg = trim_string($line);
285 1         9 die("Indent under string at $msg\n");
286             }
287              
288 39 100       103 if (length($block[-1])) {
289 1         3 my $msg = trim_string($line);
290 1         8 die("Duplicate value at $msg\n");
291            
292             }
293              
294 38         102 unshift(@$lines, $line);
295 38         118 $block[-1] = parse_almost_yaml_block($lines);
296              
297             } elsif ($indent < $first_indent) {
298 27         64 unshift(@$lines, $line);
299 27         76 last;
300             }
301             }
302              
303 131         218 my $block;
304 131 100       389 if (! defined $first_type) {
    100          
    100          
305 29         63 $block = {};
306             } elsif ($first_type eq 'ARRAY') {
307 29         104 $block = \@block;
308             } elsif ($first_type eq 'HASH') {
309 71         216 my %block = @block;
310 71         142 $block = \%block;
311             } else {
312 2         7 $block = join(' ', @block);
313             }
314            
315 131         357 return $block;
316             }
317              
318             #----------------------------------------------------------------------
319             # Parse a pair of xml tags and their contents
320              
321             sub parse_almost_xml_block {
322 81     81 0 136 my ($tokens) = @_;
323              
324 81         100 my $value;
325 81         160 while (@$tokens) {
326 292         439 my $token = shift(@$tokens);
327 292 100 100     1106 next if $token !~ /\S/ || $token =~ /^<\?/;
328              
329 200 100       623 if ($token =~ /^<\s*\/\s*([^\s>]+)/) {
    100          
330 69         144 my $ending_tagname = $1;
331 69 50       131 $value = '' unless defined $value;
332 69         200 return ($value, $ending_tagname);
333              
334             } elsif ($token =~ /^<\s*([^\s>]+)/) {
335 70         152 my $starting_tagname = $1;
336 70         154 my ($subvalue, $ending_tagname) = parse_almost_xml_block($tokens);
337 68 100       151 die "Mismatched tags at $token\n" if $starting_tagname ne $ending_tagname;
338              
339 67 100       118 $value = {} unless defined $value;
340 67 100       150 die "Unexpected text at $token\n" unless ref $value eq 'HASH';
341              
342 66 100       125 if (exists $value->{$starting_tagname}) {
343 10         20 my $old_value = $value->{$starting_tagname};
344              
345 10 100       24 if (ref $old_value eq 'ARRAY') {
346 5         18 push(@$old_value, $subvalue);
347             } else {
348 5         20 $value->{$starting_tagname} = [$old_value, $subvalue];
349             }
350            
351             } else {
352 56         157 $value->{$starting_tagname} = $subvalue;
353             }
354              
355             } else {
356 61 100       115 die "Unexpected text at \"$token\"\n" if defined $value;
357 60         96 $value = trim_string($token);
358             }
359             }
360            
361 7 50       29 $value = '' unless defined $value;
362 7         26 return ($value, '');
363             }
364              
365             #----------------------------------------------------------------------
366             # Parse a single line of "almost yaml" to get its indentation and value
367              
368             sub parse_almost_yaml_line {
369 308     308 0 516 my ($line) = @_;
370            
371 308         561 $line =~ s/\t/ /g;
372 308         616 $line .= ' ';
373              
374 308         439 my ($indent, $value);
375 308 100 100     1499 if ($line !~ /^\s*#/ && $line =~ /\S/) {
376 296         433 my $spaces;
377 296 100       1414 if ($line =~ /^(\s*)> (.*)/) {
    100          
    100          
378 11         24 $spaces = $1;
379 11         19 $value = trim_string($2);
380             } elsif ($line =~ /^(\s*)- (.*)/) {
381 80         166 $spaces = $1;
382 80         146 $value = [trim_string($2)];
383             } elsif ($line =~ /^(\s*)(\S+): (.*)/) {
384 204         464 $spaces = $1;
385 204         400 $value = {$2 => trim_string($3)};
386             } else {
387 1         3 my $msg = trim_string($line);
388 1         8 die "Bad tag at $msg\n";
389             }
390              
391 295 50       709 $indent = defined($spaces) ? length($spaces) : 0;
392             }
393            
394 307         674 return ($indent, $value);
395             }
396              
397             #----------------------------------------------------------------------
398             # Sort the keys of an xml hash so that scalars are listed first
399              
400             sub sort_xml_hash {
401 14     14 0 29 my ($hash) = @_;
402              
403 14         43 my @augmented_keys = map {[ref $hash->{$_}, $_]} keys %$hash;
  48         134  
404 14 50       63 @augmented_keys = sort {$a->[0] cmp $b->[0] || $a->[1] cmp $b->[1]} @augmented_keys;
  58         142  
405 14         27 my @keys = map {$_->[1]} @augmented_keys;
  48         84  
406              
407 14         51 return @keys;
408             }
409              
410             #----------------------------------------------------------------------
411             # Compress whitespace and remove leading and trailing space from string
412              
413             sub trim_string {
414 362     362 0 3493 my ($str) = @_;
415 362 100       754 return '' unless defined $str;
416              
417 361         1139 $str =~ s/[ \t\n]+/ /g;
418 361         814 $str =~ s/^\s//;
419 361         793 $str =~ s/\s$//;
420              
421 361         1193 return $str;
422             }
423              
424             1;
425             __END__
426              
427             =encoding utf-8
428              
429             =head1 NAME
430              
431             App::Followme::NestedText - Read a file or string using a subset of yaml or xml
432              
433             =head1 SYNOPSIS
434              
435             use App::Followme::NestedText
436             my %config = nt_parse_almost_yaml_file($filename);
437             %config = nt_parse_almost_yaml_string($str);
438             nt_write_almost_yaml_file($filename, %config);
439              
440             my %rss = nt_parse_almost_xml_file($filename);
441             %rss = nt_parse_almost_xml_string($str);
442             nt_write_almost_xml_file($filename, %rss);
443              
444             =head1 DESCRIPTION
445              
446             This module reads configuration data from either a file or string. The data
447             is a hash whose values are strings, arrays, or other hashes. Because of the
448             loose typing of Perl, numbers can be represted as strings. It supports two
449             formats. The first is a subset of yaml, called "almost yaml." This format
450             is used to read the configuration files and metadata text files that are
451             oing to be converted to web pages. In this format a hash is a list of name
452             value pairs separated by a colon and a space:
453              
454             name1: value1
455             name2: value2
456             name3: value3
457              
458             In the above example all the values are short strings and fit on a line.
459             Longer values can be split across several lines by starting each line
460             sith a greater than sign and space indented beneath the name:
461              
462             name1: value1
463             name2:
464             > A longer value
465             > split across lines
466             > however many you need
467             > for your application.
468             name3: value3
469              
470             The lines are joined with spaces into a single string.
471              
472             Array values are formatted one element per line with each line indented
473             beneath the name starting with a dash and space
474              
475             name1: value1
476             array_name:
477             - subvalue1
478             - subvalue2
479             - subvalue3
480              
481             Hash values are indented from the field containg them, each field in
482             the hash on a separate line.
483              
484             name1: value1
485             hash_name:
486             subname1: subvalue1
487             subname2: subvalue2
488             subname3: subvalue3
489              
490             Hashes, arrays, and strings can be nested to any depth, but the top level
491             must be a hash. Values may contain any character except a newline. Quotes
492             are not needed around values. Leading and trailing spaces are trimmed
493             from values, interior spaces are unchanged. Values can be the empty
494             string. Names can contain any non-whitespace character. The amount of
495             indentation is arbitrary, but must be consistent for all values in a
496             string, array, or hash. The three special characters which indicate the
497             field type (:, -, and > ) must be followed by at least one space unless
498             they are the last character on the line.
499              
500             The other format is a subset of xml, called "almost xml." This format is
501             used for rss files. In this format a hash is represented by a sequence of
502             values enclosed by tags in angle brackets. The tag names in the angle
503             brackets are the hash field names.
504              
505             <title>Liftoff News</title>
506             <link>http://liftoff.msfc.nasa.gov/</link>
507             <description>Liftoff to Space Exploration.</description>
508             <language>en-us</language>
509              
510             if a tag name is repeated the values in those tags are treated as an array:
511              
512             <item>first</item>
513             <item>second</item>
514             <item>third</item>
515              
516             A hash can also be contained in a value by placing a list of tags within
517             another pair of tags:
518              
519             <item>
520             <title>The Engine That Does More</title>
521             <link>http://liftoff.msfc.nasa.gov/news/2003/news-VASIMR.asp</link>
522             </item>
523             <item>
524             <title>Astronauts' Dirty Laundry</title>
525             <link>http://liftoff.msfc.nasa.gov/news/2003/news-laundry.asp</link>
526             </item>
527              
528             Indentation is nice for anyone looking at the file, but is not required by
529             the format.
530              
531             =head1 SUBROUTINES
532              
533             The following subroutines can be use to read nested text. Subroutine
534             names are exported when you use this module.
535              
536             =over 4
537              
538             =item my %config = nt_parse_almost_yaml_file($filename);
539              
540             Load a configuration from an almost yaml file into a hash.
541              
542             =item my %config = nt_parse_almost_yaml_string($string);
543              
544             Load a configuration from an almost yaml string into a hash.
545              
546             =item nt_write_almost_yaml_file($filename, %config);
547              
548             Write a configuration back to an almost yaml file
549              
550             =item my %rss = nt_parse_almost_xml_file($filename);
551              
552             Load a rss file into a hash.
553              
554             =item my %rss = nt_parse_almost_xml_string($string);
555              
556             Load a rss file from a string into a hash.
557              
558             =item nt_write_almost_xml_file($filename, %rss);
559              
560             Write rss back to an almost xml file
561              
562             =back
563              
564             =head1 LICENSE
565              
566             Copyright (C) Bernie Simon.
567              
568             This library is free software; you can redistribute it and/or modify
569             it under the same terms as Perl itself.
570              
571             =head1 AUTHOR
572              
573             Bernie Simon E<lt>bernie.simon@gmail.comE<gt>
574              
575             =cut