File Coverage

blib/lib/Irssi/Script/InfoParser.pm
Criterion Covered Total %
statement 10 12 83.3
branch n/a
condition n/a
subroutine 4 4 100.0
pod n/a
total 14 16 87.5


line stmt bran cond sub pod time code
1             package Irssi::Script::InfoParser;
2             # ABSTRACT: Extract information from the C<$VERSION> and C<%IRSSI> headers of an Irssi script.
3              
4 6     6   345297 use strict;
  6         17  
  6         212  
5 6     6   34 use warnings;
  6         14  
  6         157  
6 6     6   125 use v5.12;
  6         19  
  6         292  
7              
8 6     6   9236 use Moose;
  0            
  0            
9             use namespace::autoclean;
10             use feature qw/switch/;
11              
12             use Log::Any qw($log);
13              
14             use PPI;
15             use PPI::Document;
16             use PPI::Dumper;
17             use Data::Dumper;
18              
19              
20              
21             has 'file'
22             => (
23             is => 'rw',
24             isa => 'Str',
25             #required => 1,
26             );
27              
28             has '_ppi_doc'
29             => (
30             is => 'rw',
31             isa => 'PPI::Document',
32             builder => '_load_ppi_doc',
33             lazy => 1,
34             );
35              
36             has '_hash_keywords'
37             => (
38             is => 'ro',
39             isa => 'HashRef',
40             traits => [qw/Hash/],
41             builder => '_build_keyword_list',
42             handles => {
43             _is_keyword => 'exists',
44             },
45             );
46              
47             has 'metadata'
48             => (
49             traits => [qw/Hash/],
50             is => 'ro',
51             isa => 'HashRef',
52             lazy => 1,
53             builder => '_build_metadata',
54             writer => '_set_metadata',
55             handles => {
56             metadata_fields => 'keys',
57             has_field => 'exists',
58             },
59             );
60              
61             has 'version'
62             => (
63             is => 'ro',
64             isa => 'Str',
65             writer => '_set_version',
66             lazy => 1,
67             builder => '_build_version',
68             );
69              
70             has 'split_authors'
71             => (
72             is => 'ro',
73             isa => 'Bool',
74             required => 1,
75             default => 0,
76             );
77              
78             has '_is_parsed'
79             => (
80             is => 'rw',
81             isa => 'Bool',
82             default => 0,
83             );
84              
85             sub _build_metadata {
86             my $ret = $_[0]->_parse_unless_done;
87             return $ret->{metadata};
88             }
89              
90             sub _build_version {
91             my $ret = $_[0]->_parse_unless_done;
92             return $ret->{version};
93             }
94              
95             sub _parse_unless_done {
96             my ($self) = @_;
97             return if $self->_is_parsed;
98              
99             my $ret = $self->parse;
100             die "Parsing document failed" unless $ret;
101              
102             return $ret;
103             }
104              
105              
106             sub _load_ppi_doc {
107             my ($self) = @_;
108             my $file = $self->file;
109              
110             die "No Filename provided" unless $file;
111              
112             my $doc = PPI::Document->new($file, readonly => 1);
113              
114             if (not defined $doc) {
115             die "Exception parsing $file: $!"
116             }
117              
118             if ($doc->errstr) {
119             die "Exception parsing $file: " . $doc->errstr;
120             }
121             return $doc;
122             }
123              
124             sub verify_document_complete {
125             my ($self) = @_;
126             my $doc = $self->_ppi_doc;
127              
128             return $doc->complete;
129             }
130              
131             sub _build_keyword_list {
132             my @keywords =
133             ("authors", "contact", "name", "description", "licence",
134             "license", "changed", "url", "commands", "changes",
135             "modules", "sbitems", "bugs", "url_ion", "note",
136             "patch", "original_authors","original_contact",
137             "contributors",
138              
139             # TODO: this is going to be an arrayref, so it'll need special
140             # handling. Currently only in use in my scripts afaik.
141             # "requires",
142             );
143              
144             my $keyhash = {};
145             $keyhash->{$_}++ for (@keywords);
146              
147             return $keyhash;
148             }
149              
150             # look for sequences of Symbol, Operator, Quote, Statement
151             sub parse {
152             my ($self) = @_;
153             _info('Entering parse()');
154              
155             my $doc = $self->_ppi_doc;
156             die "Cannot parse an incomplete document"
157             unless $self->verify_document_complete;
158              
159             my $return_value = { version => 'UNKNOWN',
160             metadata => {},
161             };;
162             my @ver_buf;
163             my @hash_buf;
164              
165             my $statements = $doc->find('PPI::Statement');
166             _trace('Found ' . scalar @$statements . ' statements to process');
167              
168             _trace('!!! starting statement processing loop');
169             foreach my $stmt (@$statements) {
170             my $debug_str = "Statement: " . $stmt->class;
171              
172             my @tokens = $stmt->tokens;
173             $debug_str .= " Contains " . scalar(@tokens) . " tokens";
174              
175             my @significant = grep { $_->significant } @tokens;
176             $debug_str .= " Of which " . scalar(@significant) . " are significant";
177              
178             _trace($debug_str);
179              
180             my $collect_hash_tokens = 0;
181             my $collect_version_tokens = 0;
182              
183             _trace('entering significant token capture loop');
184             foreach my $token (@significant) {
185             _trace("Token: " . $token->class . ': ' . $token->content);
186              
187             if (is_IRSSI_start_symbol($token)) {
188             $collect_hash_tokens = 1;
189             _trace("### starting HASH here");
190             }
191              
192             if ($collect_hash_tokens) {
193             push @hash_buf, $token;
194             }
195              
196             if (is_VERSION_start_symbol($token)) {
197             $collect_version_tokens = 1;
198             _trace("**Starting version buffering here");
199             }
200              
201             if ($collect_version_tokens) {
202             push @ver_buf, $token;
203             }
204             }
205             _trace('finished significant token capture loop');
206              
207             # minimum of '$VERSION, =, <value>, ;' = 4 tokens.
208             if (@ver_buf > 3) {
209              
210             _debug("Going to parse version");
211             _info("version buffer: '" .
212             join(" _ ", map { $_->content } @ver_buf) . "'");
213              
214             my $version = $self->process_version_buffer(\@ver_buf);
215              
216             if (defined $version) {
217             $self->_set_version($version);
218             $return_value->{version} = $version;
219             _info("*** Version returned: $version");
220             } else {
221             _warn("*** version parsing failed");
222             }
223             @ver_buf = ();
224             }
225             if (@hash_buf > 3) {
226             _debug("Going to parse metahash");
227             _info("buffer: '" .
228             join(" _ ", map { $_->content } @hash_buf) . "'");
229              
230             my $meta = $self->process_irssi_buffer(\@hash_buf);
231             if (defined $meta) {
232             $self->_set_metadata($meta);
233             $return_value->{metadata} = $meta;
234             }
235             @hash_buf = ();
236             }
237              
238             }
239              
240             _trace('!!! finished statement processing loop');
241              
242             _info("version set to: " . $self->version);
243             _info("parse() complete. Returning $return_value");
244             $self->_is_parsed(1);
245              
246             return $return_value;
247             }
248              
249             sub process_version_buffer {
250             my ($self, $buffer) = @_;
251              
252             my $probable_version;
253             # TODO: worth making some sort of enum-ish type for the states?
254             # would aid clarity, I suppose.
255             my $state = 0;
256             my $score = 0;
257              
258             while(my $token = shift(@$buffer)) {
259             my $class = $token->class;
260             my $content = $token->content;
261              
262             given ($state) {
263              
264             when (0) {
265             if ($class =~ m/Symbol/ && $content =~ m/VERSION/) {
266             $state = 1;
267             _trace("seen VERSION, moving to state 1");
268             }
269             }
270             when (1) {
271             if ($class =~ m/Operator/ and $content =~ m/=/) {
272             _trace("seen =, moving to state 2");
273             $state = 2;
274             }
275             }
276             when (2) {
277              
278             _trace("In state 2, token content: " . $content);
279              
280             if ($probable_version = is_quoted_content($token)) {
281             _debug("got quoted content: $probable_version");
282             $state = 3;
283              
284             } elsif ($probable_version = is_number($token)) {
285             _debug("got quoted content: $probable_version");
286             $state = 3;
287              
288             } else {
289             _info("** failed parse");
290             $state = 0;
291             last;
292             }
293             }
294             when (3) {
295              
296             _trace("In state 3, type: "
297             . $token->class . " content: " . $content);
298              
299             # TODO: I suppose it could not end with a semi-colon...?
300             if (is_structure_semicolon($token)) {
301             $state = 4;
302             my $line_num = $token->line_number;
303             _info("Probable Version Number: $probable_version "
304             . "(score: $score) on line: $line_num");
305             last;
306             }
307             }
308             default { $state = 0; }
309             }
310             }
311              
312             if (defined $probable_version and $state == 4) {
313              
314             # TODO: might want to think about something like this
315             # re: line_num as a sanity check.
316             # $version = $ver if defined($line) and $line < 50;
317              
318             my $version = $probable_version;
319              
320             # TODO: quoted_content should handle this already?
321             $version =~ s/^['"]//;
322             $version =~ s/['"]$//;
323              
324             _debug("Extracted VERSION: $version");
325             return $version;
326             }
327              
328             _warn('process_version_buffer(): returning false');
329             return;
330             }
331              
332             sub is_number {
333             my ($token) = @_;
334             my $is_num = ($token->class =~ m/^PPI::Token::Number/)
335             ? 1
336             : 0;
337              
338             if ($is_num) {
339             _trace("is_number(): " . $token->content);
340             return $token->content;
341             } else {
342             _trace("is_number(): returning false");
343             return;
344             }
345             }
346              
347             sub is_comma {
348             my ($token) = @_;
349             return (($token->class eq 'PPI::Token::Operator') and
350             ($token->content eq ','))
351             }
352              
353             sub is_assign {
354             my ($token) = @_;
355             return (($token->class eq 'PPI::Token::Operator') and
356             ($token->content eq '='))
357             }
358              
359             sub is_fat_arrow {
360             my ($token) = @_;
361             return (($token->class eq 'PPI::Token::Operator') and
362             ($token->content eq '=>'))
363             }
364              
365             sub is_concat {
366             my ($token) = @_;
367             return (($token->class eq 'PPI::Token::Operator') and
368             ($token->content eq '.'))
369             }
370              
371             sub is_structure_start {
372             my ($token) = @_;
373             return (($token->class eq 'PPI::Token::Structure') and
374             ($token->content eq '('))
375             }
376              
377             sub is_structure_end {
378             my ($token) = @_;
379             return (($token->class eq 'PPI::Token::Structure') and
380             ($token->content eq ')'))
381             }
382              
383             sub is_structure_semicolon {
384             my ($token) = @_;
385             return (($token->class eq 'PPI::Token::Structure') and
386             ($token->content eq ';'))
387             }
388              
389             sub is_unquoted_word { # only hash keys are unquoted here.
390             my ($token) = @_;
391             return ($token->class eq 'PPI::Token::Word')
392             ? $token->content
393             : ();
394             }
395              
396             sub is_quoted_content {
397             my ($token) = @_;
398             if ($token->class =~ m/^PPI::Token::Quote::(Double|Single)/) {
399             my $type = ($1 eq 'Double') ? '"' : "'";
400             my $content = $token->content;
401             # remove quotes.
402             $content =~ s/^$type//; $content =~ s/$type$//;
403             _trace("is_quoted_content(): Content '$content'");
404             return $content;
405             }
406             _trace("is_quoted_content(): returning false.");
407             return ();
408             }
409              
410             sub is_IRSSI_start_symbol {
411             my ($token) = @_;
412             return (($token->class =~ m/Symbol/) and
413             ($token->content =~ m/\%IRSSI/));
414             }
415              
416             sub is_VERSION_start_symbol {
417             my ($token) = @_;
418             return (($token->class =~ m/Symbol/) and
419             ($token->content =~ m/\$VERSION/));
420             }
421              
422             sub is_valid_info_hashkey {
423             my ($self, $key) = @_;
424             return $self->_is_keyword($key);
425             }
426              
427             sub process_irssi_buffer {
428             my ($self, $buffer) = @_;
429             #no warnings 'uninitialized';
430             _trace('entering process_irssi_buffer()');
431             # results accumulator.
432             my $hash = {};
433              
434             # state variables
435             my $state = 0;
436             my $preamble = 0;
437              
438              
439             my ($key, $value);
440             my $concat_next = 0;
441             my $key_quoted = 0;
442             my $concat_buf;
443              
444             PARSE:
445             while (my $token = shift(@$buffer)) {
446             _info("Token: " . $token->class . " Content: " . $token->content);
447             #_info("** Mode: $state, intro: $intro, $key => $value ");
448              
449             if ($preamble < 3) {
450             _trace('in preamble');
451             given ($preamble) {
452             when (0) {
453             if (is_IRSSI_start_symbol($token)) {
454             $preamble = 1;
455             _debug("Seen Start, Setting preamble to 1");
456             next PARSE;
457             }
458             }
459             when (1) {
460             if (is_assign($token)) {
461             $preamble = 2;
462             _debug("Seen Assign, Setting preamble to 2");
463             next PARSE;
464             }
465             }
466             when (2) {
467             if (is_structure_start($token)) {
468             $preamble = 3;
469             _debug("Seen Structure Start, Setting premable to 3");
470              
471             next PARSE;
472             }
473             }
474             }
475             }
476             _trace('past preamble');
477              
478             # TODO:
479             # need to check if anyone has quoted their first words, or
480             # used commas rather than fat-arrows for key/val separation.
481             given ($state) {
482             when (0) {
483             my $tmp;
484             if ($tmp = is_unquoted_word($token)) {
485             $key_quoted = 0;
486             } elsif ($tmp = is_quoted_content($token)) {
487             $key_quoted = 1;
488             }
489              
490             if ($tmp and $self->is_valid_info_hashkey($tmp)) {
491             $key = $tmp;
492             _debug("Word ok, key=$key. Mode set to 1");
493             $state = 1;
494             next PARSE;
495             } else {
496             $tmp ||= '';
497             _warn("parse failure, '$tmp' not a valid key");
498             last PARSE;
499             }
500             }
501             when (1) {
502              
503             if ((is_fat_arrow($token)) or
504             ($key_quoted == 1 and is_comma($token))) {
505              
506             _debug("Mode 1 -> 2, Fat Arrow Delim (or comma)");
507             $state = 2;
508             next PARSE;
509             }
510             }
511             when (2) {
512              
513             if ($concat_buf = is_quoted_content($token)) {
514             _debug("Mode 2 -> 3, Read quoted content");
515             if ($concat_next) {
516             $value = $value . $concat_buf;
517             $concat_next = 0;
518             } else {
519             $value = $concat_buf;
520             }
521             $state = 3;
522             next PARSE;
523             }
524             }
525             when (3) {
526             if (is_concat($token)) {
527             $concat_next = 1;
528             $state = 2;
529             _debug("Concat pending");
530             next PARSE;
531             }
532              
533             if (is_comma($token) or is_structure_end($token)) {
534             _debug("Mode 3, read comma, saving $key => $value");
535              
536             $hash->{$key} = $value;
537             $key = '';
538             $value = '';
539             $state = 0;
540             next PARSE;
541             }
542             }
543             default {
544             if (is_structure_end($token) and $state != 0) {
545             _warn("Something went wrong. " .
546             "Incomplete parsing: $state/$key/$value");
547             } else {
548             last PARSE;
549             }
550             }
551             }
552             }
553              
554             unless ($state == 0) {
555             _warn("incomplete parsing, left in state: $state");
556             die;
557             }
558             $hash = $self->_postprocess_authors($hash) if $self->split_authors;
559             return $hash;
560             }
561              
562             sub _postprocess_authors {
563             my ($self, $meta) = @_;
564             _trace('_postprocess_authors() called');
565              
566             return unless exists $meta->{authors};
567              
568             my $authors_str = $meta->{authors};
569             my @authors = split /\s*,\s*/, $authors_str;
570             _trace('authors split into: ' . join(' | ', @authors));
571             if (@authors > 1) {
572             $meta->{authors} = \@authors;
573             } else {
574             $meta->{authors} = [ $authors_str ];
575             }
576             return $meta;
577             }
578              
579              
580             sub _trace {
581             if (@_ > 1) {
582             $log->tracef(@_);
583             } else {
584             $log->trace($_[0]);
585             }
586             }
587              
588             sub _info {
589             if (@_ > 1) {
590             $log->infof(@_);
591             } else {
592             $log->info($_[0]);
593             }
594             }
595              
596             sub _debug {
597             if (@_ > 1) {
598             $log->debugf(@_);
599             } else {
600             $log->debug($_[0]);
601             }
602             }
603              
604             sub _warn {
605             if (@_ > 1) {
606             $log->warnf(@_);
607             } else {
608             $log->warn($_[0]);
609             }
610             }
611              
612             __PACKAGE__->meta->make_immutable;
613              
614             1;
615              
616             __END__
617             =pod
618              
619             =head1 NAME
620              
621             Irssi::Script::InfoParser - Extract information from the C<$VERSION> and C<%IRSSI> headers of an Irssi script.
622              
623             =head1 VERSION
624              
625             version 0.004
626              
627             =head1 SYNOPSIS
628              
629             use Irssi::Script::InfoParser;
630              
631             my $parser = Irssi::Script::InfoParser->new(file => $script);
632             my $version = $parser->version;
633              
634             my @fields = $parser->metadata_fields;
635             my $metadata = $parser->metadata;
636              
637             foreach my $name (@fields) {
638             say "Value is $metadata->{$name}!";
639             }
640              
641             or
642              
643             # assuming the authors field is actually defined.
644              
645             my $parser = Irssi::Script::InfoParser->new(file => $script,
646             split_authors => 0);
647              
648             return unless $parser->has_field('authors');
649              
650             my $authors_string = $parser->metadata->{authors};
651              
652             my $parser = Irssi::Script::InfoParser->new(file => $script,
653             split_authors => 1);
654              
655             my $authors_arrayref = $parser->metadata->{authors};
656              
657             =head1 AUTHOR
658              
659             Tom Feist <shabble+cpan@metavore.org>
660              
661             =head1 COPYRIGHT AND LICENSE
662              
663             This software is copyright (c) 2011 by Tom Feist.
664              
665             This is free software; you can redistribute it and/or modify it under
666             the same terms as the Perl 5 programming language system itself.
667              
668             =cut
669