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