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   1991 use 5.008005;
  18         92  
4 18     18   102 use strict;
  18         43  
  18         478  
5 18     18   105 use warnings;
  18         36  
  18         597  
6 18     18   110 use integer;
  18         34  
  18         138  
7 18     18   500 use lib '../..';
  18         54  
  18         111  
8              
9             our $VERSION = "2.01";
10              
11 18     18   2881 use App::Followme::FIO;
  18         37  
  18         53318  
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 122 my ($old_config, $new_config) = @_;
25              
26 54         82 my $final_config;
27 54         97 my $ref = ref $old_config;
28              
29 54 50       135 if ($ref eq ref $new_config) {
30 54 100       152 if ($ref eq 'ARRAY') {
    100          
31 21         45 $final_config = [];
32 21         45 @$final_config = @$old_config;
33 21         58 my %old = map {$_ => 1} @$old_config;
  5         15  
34              
35 21         42 foreach my $item (@$new_config) {
36 29 100       118 push(@$final_config, $item) unless $old{$item};
37             }
38              
39             } elsif ($ref eq 'HASH') {
40 23         43 $final_config = {};
41 23         110 %$final_config = %$old_config;
42              
43 23         82 foreach my $name (keys %$new_config) {
44 43 100       107 if (exists $old_config->{$name}) {
45             $final_config->{$name} = nt_merge_items($old_config->{$name},
46 32         108 $new_config->{$name});
47             } else {
48 11         30 $final_config->{$name} = $new_config->{$name};
49             }
50             }
51              
52             } else {
53 10         23 $final_config = $new_config;
54             }
55              
56             } else {
57 0         0 $final_config = $new_config;
58             }
59              
60 54         142 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 57 my ($filename) = @_;
68              
69 22         48 my %configuration;
70 22         69 my $page = fio_read_page($filename);
71              
72 22         59 eval {%configuration = nt_parse_almost_yaml_string($page)};
  22         74  
73 22 50       80 die "$filename: $@" if $@;
74              
75 22         111 return %configuration;
76             }
77              
78             #----------------------------------------------------------------------
79             # Read file in "almost xml" format
80              
81             sub nt_parse_almost_xml_file {
82 3     3 1 29 my ($filename) = @_;
83              
84 3         7 my %rss;
85 3         12 my $page = fio_read_page($filename);
86              
87 3         11 eval {%rss = nt_parse_almost_xml_string($page)};
  3         12  
88 3 50       34 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 10323 my ($page) = @_;
98              
99 98         363 my @lines = split(/\n/, $page);
100 98         331 my $block = parse_almost_yaml_block(\@lines);
101            
102 94 100       253 if (@lines) {
103 1         3 my $msg = trim_string(shift(@lines));
104 1         7 die("Bad indent at $msg\n");
105             }
106              
107 93 100       255 if (ref($block) ne 'HASH') {
108 1         6 die("Configuration must be a hash\n");
109             }
110              
111 92         446 return %$block;
112             }
113              
114             #----------------------------------------------------------------------
115             # Read string in "almost xml" Format
116              
117             sub nt_parse_almost_xml_string {
118 11     11 1 5667 my ($page) = @_;
119              
120 11         227 my @tokens = split(/(<[^>]*>)/, $page);
121 11         39 my ($block, $blockname) = parse_almost_xml_block(\@tokens);
122 8 100       24 die "Unexpected closing tag at </$blockname>\n" if $blockname;
123              
124 7         55 return %$block;
125             }
126              
127             #----------------------------------------------------------------------
128             # Write file in "almost yaml" Format
129              
130             sub nt_write_almost_yaml_file {
131 3     3 1 977 my ($filename, %configuration) = @_;
132              
133 3         9 my ($type, $page) = format_almost_yaml_value(\%configuration);
134 3         7 $page .= "\n";
135              
136 3         24 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 1002 my ($filename, %rss) = @_;
145              
146 2         5 my $page = "<?xml version=\"1.0\"?>\n";
147 2         8 $page .= format_almost_xml_value(\%rss);
148 2         5 $page .= "\n";
149              
150 2         15 fio_write_page($filename, $page);
151 2         10 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 4839 my ($value, $level) = @_;
159 60 100       109 $level = 0 unless defined $level;
160              
161 60         78 my $text;
162 60         89 my $type = ref $value;
163 60         105 my $leading = ' ' x (4 * $level);
164 60 100       143 if ($type eq 'ARRAY') {
    100          
    100          
165 5         10 my @subtext;
166 5         10 foreach my $subvalue (@$value) {
167 13         78 my ($subtype, $subtext) = format_almost_yaml_value($subvalue, $level+1);
168 13 50       30 if ($subtype) {
169 0         0 $subtext = $leading . "-\n" . $subtext;
170             } else {
171 13         23 $subtext = $leading . "- " . $subtext;
172             }
173 13         25 push (@subtext, $subtext);
174             }
175 5         17 $text = join("\n", @subtext);
176              
177             } elsif ($type eq 'HASH') {
178 12         64 my @subtext;
179 12         67 foreach my $name (sort keys %$value) {
180 38         68 my $subvalue = $value->{$name};
181 38         72 my ($subtype, $subtext) = format_almost_yaml_value($subvalue, $level+1);
182 38 100       76 if ($subtype) {
183 9         20 $subtext = $leading . "$name:\n" . $subtext;
184             } else {
185 29         61 $subtext = $leading . "$name: " . $subtext;
186             }
187 38         109 push (@subtext, $subtext);
188             }
189 12         37 $text = join("\n", @subtext);
190              
191             } elsif (length($value) > 60) {
192 1         3 $type = 'SCALAR';
193 1         8 my @subtext = split(/(\S.{0,59}\S*)/, $value);
194 1         8 @subtext = grep( /\S/, @subtext);
195 1         6 @subtext = map("$leading> $_", @subtext);
196 1         4 $text = join("\n", @subtext);
197            
198             } else {
199 42         59 $text = $value;
200             }
201              
202 60         154 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 3147 my ($value, $name, $level) = @_;
210 66 100       124 $name = '' unless defined $name;
211 66 100       111 $level = 0 unless defined $level;
212              
213 66         81 my $text;
214 66         96 my $type = ref $value;
215 66         121 my $leading = ' ' x (4 * $level);
216 66         132 my ($shortname) = split(/ /, $name);
217              
218 66 100       146 if ($type eq 'ARRAY') {
    100          
219 4         7 my @subtext;
220 4         10 foreach my $subvalue (@$value) {
221 12         48 my $subtext = format_almost_xml_value($subvalue, $name, $level);
222 12         25 push (@subtext, $subtext);
223             }
224 4         17 $text = join("\n", @subtext);
225              
226             } elsif ($type eq 'HASH') {
227 14         19 my @subtext;
228 14 100       33 $level += 1 if length $name;
229 14 100       37 push(@subtext, "$leading<$name>") if length $name;
230 14         31 foreach my $subname (sort_xml_hash($value)) {
231 48         78 my $subvalue = $value->{$subname};
232 48         117 my $subtext = format_almost_xml_value($subvalue, $subname, $level);
233 48         93 push (@subtext, $subtext);
234             }
235 14 100       48 push(@subtext, "$leading</$shortname>") if length $name;
236 14         88 $text = join("\n", @subtext);
237            
238             } else {
239 48 50       133 $text = length $name ? "$leading<$name>$value</$shortname>"
240             : $leading . $value;
241             }
242              
243 66         146 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 236 my ($lines) = @_;
251              
252 136         216 my @block;
253 136         226 my ($first_indent, $first_type);
254              
255 136         393 while (@$lines) {
256 308         503 my $line = shift(@$lines);
257 308         588 my ($indent, $value) = parse_almost_yaml_line($line);
258 307 100       604 next unless defined $indent;
259              
260 295 100       542 if (! defined $first_indent) {
261 107         175 $first_indent = $indent;
262 107         209 $first_type = ref($value);
263             }
264            
265 295 100       588 if ($indent == $first_indent) {
    100          
    50          
266 228         362 my $type = ref($value);
267              
268 228 100       465 if ($type ne $first_type) {
269 1         4 my $msg = trim_string($line);
270 1         8 die("Missing indent at $msg\n");
271             }
272              
273 227 100       471 if ($type eq 'ARRAY') {
    100          
274 51         173 push(@block, @$value);
275             } elsif ($type eq 'HASH') {
276 169         787 push(@block, %$value);
277             } else {
278 7         16 push(@block, $value);
279             }
280              
281             } elsif ($indent > $first_indent) {
282 40 100 66     175 if ($first_type ne 'ARRAY' &&
283             $first_type ne 'HASH') {
284 1         4 my $msg = trim_string($line);
285 1         8 die("Indent under string at $msg\n");
286             }
287              
288 39 100       104 if (length($block[-1])) {
289 1         3 my $msg = trim_string($line);
290 1         9 die("Duplicate value at $msg\n");
291            
292             }
293              
294 38         95 unshift(@$lines, $line);
295 38         110 $block[-1] = parse_almost_yaml_block($lines);
296              
297             } elsif ($indent < $first_indent) {
298 27         61 unshift(@$lines, $line);
299 27         70 last;
300             }
301             }
302              
303 131         187 my $block;
304 131 100       409 if (! defined $first_type) {
    100          
    100          
305 29         65 $block = {};
306             } elsif ($first_type eq 'ARRAY') {
307 29         54 $block = \@block;
308             } elsif ($first_type eq 'HASH') {
309 71         209 my %block = @block;
310 71         207 $block = \%block;
311             } else {
312 2         7 $block = join(' ', @block);
313             }
314            
315 131         343 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 129 my ($tokens) = @_;
323              
324 81         106 my $value;
325 81         157 while (@$tokens) {
326 292         419 my $token = shift(@$tokens);
327 292 100 100     1025 next if $token !~ /\S/ || $token =~ /^<\?/;
328              
329 200 100       577 if ($token =~ /^<\s*\/\s*([^\s>]+)/) {
    100          
330 69         179 my $ending_tagname = $1;
331 69 50       139 $value = '' unless defined $value;
332 69         185 return ($value, $ending_tagname);
333              
334             } elsif ($token =~ /^<\s*([^\s>]+)/) {
335 70         135 my $starting_tagname = $1;
336 70         142 my ($subvalue, $ending_tagname) = parse_almost_xml_block($tokens);
337 68 100       167 die "Mismatched tags at $token\n" if $starting_tagname ne $ending_tagname;
338              
339 67 100       131 $value = {} unless defined $value;
340 67 100       197 die "Unexpected text at $token\n" unless ref $value eq 'HASH';
341              
342 66 100       116 if (exists $value->{$starting_tagname}) {
343 10         20 my $old_value = $value->{$starting_tagname};
344              
345 10 100       26 if (ref $old_value eq 'ARRAY') {
346 5         18 push(@$old_value, $subvalue);
347             } else {
348 5         19 $value->{$starting_tagname} = [$old_value, $subvalue];
349             }
350            
351             } else {
352 56         153 $value->{$starting_tagname} = $subvalue;
353             }
354              
355             } else {
356 61 100       112 die "Unexpected text at \"$token\"\n" if defined $value;
357 60         108 $value = trim_string($token);
358             }
359             }
360            
361 7 50       18 $value = '' unless defined $value;
362 7         18 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 492 my ($line) = @_;
370            
371 308         542 $line =~ s/\t/ /g;
372 308         601 $line .= ' ';
373              
374 308         435 my ($indent, $value);
375 308 100 100     1489 if ($line !~ /^\s*#/ && $line =~ /\S/) {
376 296         413 my $spaces;
377 296 100       1316 if ($line =~ /^(\s*)> (.*)/) {
    100          
    100          
378 11         22 $spaces = $1;
379 11         20 $value = trim_string($2);
380             } elsif ($line =~ /^(\s*)- (.*)/) {
381 80         159 $spaces = $1;
382 80         160 $value = [trim_string($2)];
383             } elsif ($line =~ /^(\s*)(\S+): (.*)/) {
384 204         440 $spaces = $1;
385 204         394 $value = {$2 => trim_string($3)};
386             } else {
387 1         3 my $msg = trim_string($line);
388 1         7 die "Bad tag at $msg\n";
389             }
390              
391 295 50       677 $indent = defined($spaces) ? length($spaces) : 0;
392             }
393            
394 307         618 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 24 my ($hash) = @_;
402              
403 14         41 my @augmented_keys = map {[ref $hash->{$_}, $_]} keys %$hash;
  48         118  
404 14 50       55 @augmented_keys = sort {$a->[0] cmp $b->[0] || $a->[1] cmp $b->[1]} @augmented_keys;
  57         124  
405 14         24 my @keys = map {$_->[1]} @augmented_keys;
  48         81  
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 3121 my ($str) = @_;
415 362 100       708 return '' unless defined $str;
416              
417 361         1077 $str =~ s/[ \t\n]+/ /g;
418 361         681 $str =~ s/^\s//;
419 361         743 $str =~ s/\s$//;
420              
421 361         1119 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