Podlators.pm revision 1.4
1# Helper functions to test the podlators distribution.
2#
3# This module is an internal implementation detail of the podlators test
4# suite.  It provides some supporting functions to make it easier to write
5# tests.
6#
7# SPDX-License-Identifier: GPL-1.0-or-later OR Artistic-1.0-Perl
8
9package Test::Podlators;
10
11use 5.008;
12use strict;
13use warnings;
14
15use Encode qw(decode encode);
16use Exporter;
17use File::Spec;
18use Test::More;
19
20# For Perl 5.006 compatibility.
21## no critic (ClassHierarchies::ProhibitExplicitISA)
22
23# Declare variables that should be set in BEGIN for robustness.
24our (@EXPORT_OK, @ISA, $VERSION);
25
26# Set $VERSION and everything export-related in a BEGIN block for robustness
27# against circular module loading (not that we load any modules, but
28# consistency is good).
29BEGIN {
30    @ISA       = qw(Exporter);
31    $VERSION   = '2.01';
32    @EXPORT_OK = qw(
33      read_snippet read_test_data slurp test_snippet test_snippet_with_io
34    );
35}
36
37# The file handle used to capture STDERR while we mess with file descriptors.
38my $OLD_STDERR;
39
40# The file name used to capture standard error output.
41my $SAVED_STDERR;
42
43# Internal function to clean up the standard error output file.  Leave the
44# temporary directory in place, since otherwise we race with other test
45# scripts trying to create the temporary directory when running tests in
46# parallel.
47sub _stderr_cleanup {
48    if ($SAVED_STDERR && -f $SAVED_STDERR) {
49        unlink($SAVED_STDERR);
50    }
51    return;
52}
53
54# Remove saved standard error on exit, even if we have an abnormal exit.
55END {
56    _stderr_cleanup();
57}
58
59# Internal function to redirect stderr to a file.  Stores the name in
60# $SAVED_STDERR.
61sub _stderr_save {
62    my $tmpdir = File::Spec->catdir('t', 'tmp');
63    if (!-d $tmpdir) {
64        mkdir($tmpdir, 0777) or BAIL_OUT("cannot create $tmpdir: $!");
65    }
66    my $path = File::Spec->catfile($tmpdir, "out$$.err");
67
68    ## no critic(InputOutput::RequireBriefOpen)
69    open($OLD_STDERR, '>&', STDERR) or BAIL_OUT("cannot dup STDERR: $!");
70    open(STDERR, '>', $path) or BAIL_OUT("cannot redirect STDERR: $!");
71    ## use critic
72
73    $SAVED_STDERR = $path;
74    return;
75}
76
77# Internal function to restore stderr.
78#
79# Returns: The contents of the stderr file.
80sub _stderr_restore {
81    return if !$SAVED_STDERR;
82    close(STDERR) or BAIL_OUT("cannot close STDERR: $!");
83    open(STDERR, '>&', $OLD_STDERR) or BAIL_OUT("cannot dup STDERR: $!");
84    close($OLD_STDERR) or BAIL_OUT("cannot close redirected STDERR: $!");
85    my $stderr = slurp($SAVED_STDERR);
86    _stderr_cleanup();
87    return $stderr;
88}
89
90# Read one test snippet from the provided relative file name and return it.
91# For the format, see t/data/snippets/README.
92#
93# $path     - Relative path to read test data from
94#
95# Returns: Reference to hash of test data with the following keys:
96#            name      - Name of the test for status reporting
97#            options   - Hash of options
98#            input     - The input block of the test data
99#            output    - The output block of the test data
100#            errors    - Expected errors
101#            exception - Text of exception (with file and line stripped)
102sub read_snippet {
103    my ($path) = @_;
104    $path = File::Spec->catfile('t', 'data', 'snippets', $path);
105    my %data;
106
107    # Read the sections and store them in the %data hash.
108    my ($line, $section);
109    open(my $fh, '<', $path) or BAIL_OUT("cannot open $path: $!");
110    while (defined($line = <$fh>)) {
111        if ($line =~ m{ \A \s* \[ (\S+) \] \s* \z }xms) {
112            $section = $1;
113            $data{$section} = q{};
114        } elsif ($section) {
115            $data{$section} .= $line;
116        }
117    }
118    close($fh) or BAIL_OUT("cannot close $path: $!");
119
120    # Strip trailing blank lines from all sections.
121    for my $section (keys %data) {
122        $data{$section} =~ s{ \n\s+ \z }{\n}xms;
123    }
124
125    # Clean up the name section by removing newlines and extra space.
126    if ($data{name}) {
127        $data{name} =~ s{ \A \s+ }{}xms;
128        $data{name} =~ s{ \s+ \z }{}xms;
129        $data{name} =~ s{ \s+ }{ }xmsg;
130    }
131
132    # Turn the options section into a hash.
133    if ($data{options}) {
134        my @lines = split(m{ \n }xms, $data{options});
135        delete $data{options};
136        for my $optline (@lines) {
137            next if $optline !~ m{ \S }xms;
138            my ($option, $value) = split(q{ }, $optline, 2);
139            if (defined($value)) {
140                chomp($value);
141            } else {
142                $value = q{};
143            }
144            $data{options}{$option} = $value;
145        }
146    }
147
148    # Return the results.
149    return \%data;
150}
151
152# Read one set of test data from the provided file handle and return it.
153# There are several different possible formats, which are specified by the
154# format option.
155#
156# The data read from the file handle will be ignored until a line consisting
157# solely of "###" is found.  Then, two or more blocks separated by "###" are
158# read, ending with another line of "###".  There will always be at least an
159# input and an output block, and may be more blocks based on the format
160# configuration.
161#
162# $fh         - File handle to read the data from
163# $format_ref - Reference to a hash of options describing the data
164#   errors  - Set to true to read expected errors after the output section
165#   options - Set to true to read a hash of options as the first data block
166#
167# Returns: Reference to hash of test data with the following keys:
168#            input   - The input block of the test data
169#            output  - The output block of the test data
170#            errors  - Expected errors if errors was set in $format_ref
171#            options - Hash of options if options was set in $format_ref
172#          or returns undef if no more test data is found.
173sub read_test_data {
174    my ($fh, $format_ref) = @_;
175    $format_ref ||= {};
176    my %data;
177
178    # Find the first block of test data.
179    my $line;
180    while (defined($line = <$fh>)) {
181        last if $line eq "###\n";
182    }
183    if (!defined($line)) {
184        return;
185    }
186
187    # If the format contains the options key, read the options into a hash.
188    if ($format_ref->{options}) {
189        while (defined($line = <$fh>)) {
190            last if $line eq "###\n";
191            my ($option, $value) = split(q{ }, $line, 2);
192            if (defined($value)) {
193                chomp($value);
194            } else {
195                $value = q{};
196            }
197            $data{options}{$option} = $value;
198        }
199    }
200
201    # Read the input and output sections.
202    my @sections = qw(input output);
203    if ($format_ref->{errors}) {
204        push(@sections, 'errors');
205    }
206    for my $key (@sections) {
207        $data{$key} = q{};
208        while (defined($line = <$fh>)) {
209            last if $line eq "###\n";
210            $data{$key} .= $line;
211        }
212    }
213    return \%data;
214}
215
216# Slurp output data back from a file handle.  It would be nice to use
217# Perl6::Slurp, but this is a core module, so we have to implement our own
218# wheels.  BAIL_OUT is called on any failure to read the file.
219#
220# $file  - File to read
221# $strip - If set to "man", strip out the Pod::Man header
222#
223# Returns: Contents of the file, possibly stripped
224sub slurp {
225    my ($file, $strip) = @_;
226    open(my $fh, '<', $file) or BAIL_OUT("cannot open $file: $!");
227
228    # If told to strip the man header, do so.
229    if (defined($strip) && $strip eq 'man') {
230        while (defined(my $line = <$fh>)) {
231            last if $line eq ".nh\n";
232        }
233    }
234
235    # Read the rest of the file and return it.
236    my $data = do { local $/ = undef; <$fh> };
237    close($fh) or BAIL_OUT("cannot read from $file: $!");
238    return $data;
239}
240
241# Test a formatter on a particular POD snippet.  This does all the work of
242# loading the snippet, creating the formatter, running it, and checking the
243# results, and reports those results with Test::More.
244#
245# $class       - Class name of the formatter, as a string
246# $snippet     - Path to the snippet file defining the test
247# $options_ref - Hash of options with the following keys:
248#   encoding - Expect the output to be in this non-standard encoding
249sub test_snippet {
250    my ($class, $snippet, $options_ref) = @_;
251    my $data_ref = read_snippet($snippet);
252
253    # Determine the encoding to expect for the output portion of the snippet.
254    my $encoding;
255    if (defined($options_ref)) {
256        $encoding = $options_ref->{encoding};
257    }
258    $encoding ||= 'UTF-8';
259
260    # Create the formatter object.
261    my $parser = $class->new(%{ $data_ref->{options} }, name => 'TEST');
262    isa_ok($parser, $class, 'Parser object');
263
264    # Save stderr to a temporary file and then run the parser, storing the
265    # output into a Perl variable.
266    my $errors = _stderr_save();
267    my $got;
268    $parser->output_string(\$got);
269    eval { $parser->parse_string_document($data_ref->{input}) };
270    my $exception = $@;
271    my $stderr    = _stderr_restore();
272
273    # If we were testing Pod::Man, strip off everything prior to .nh from the
274    # output so that we aren't testing the generated header.
275    if ($class eq 'Pod::Man') {
276        $got =~ s{ \A .* \n [.]nh \n }{}xms;
277    }
278
279    # Strip any trailing blank lines (Pod::Text likes to add them).
280    $got =~ s{ \n\s+ \z }{\n}xms;
281
282    # Check the output, errors, and any exception.
283    my $expected = decode($encoding, $data_ref->{output});
284    is($got, $expected, "$data_ref->{name}: output");
285    if ($data_ref->{errors} || $stderr) {
286        is($stderr, $data_ref->{errors} || q{}, "$data_ref->{name}: errors");
287    }
288    if ($data_ref->{exception} || $exception) {
289        if ($exception) {
290            $exception =~ s{ [ ] at [ ] .* }{\n}xms;
291        }
292        is($exception, $data_ref->{exception}, "$data_ref->{name}: exception");
293    }
294    return;
295}
296
297# Test a formatter with I/O streams on a particular POD snippet.  This does
298# all the work of loading the snippet, creating the formatter, running it, and
299# checking the results, and reports those results with Test::More.  It's
300# similar to test_snippet, but uses input and output temporary files instead
301# to test encoding layers and also checks the Pod::Man accent output.
302#
303# $class       - Class name of the formatter, as a string
304# $snippet     - Path to the snippet file defining the test
305# $options_ref - Hash of options with the following keys:
306#   encoding    - Expect the snippet to be in this non-standard encoding
307#   perlio_utf8 - Set to 1 to set a PerlIO UTF-8 encoding on the output file
308sub test_snippet_with_io {
309    my ($class, $snippet, $options_ref) = @_;
310    my $data_ref = read_snippet($snippet);
311
312    # Determine the encoding to expect for the output portion of the snippet.
313    my $encoding;
314    if (defined($options_ref)) {
315        $encoding = $options_ref->{encoding};
316    }
317    $encoding ||= 'UTF-8';
318
319    # Create the formatter object.
320    my $parser = $class->new(%{ $data_ref->{options} }, name => 'TEST');
321    isa_ok($parser, $class, 'Parser object');
322
323    # Write the input POD to a temporary file prefaced by the encoding
324    # directive.
325    my $tmpdir = File::Spec->catdir('t', 'tmp');
326    if (!-d $tmpdir) {
327        mkdir($tmpdir, 0777) or BAIL_OUT("cannot create $tmpdir: $!");
328    }
329    my $input_file = File::Spec->catfile('t', 'tmp', "tmp$$.pod");
330    open(my $input, '>', $input_file)
331      or BAIL_OUT("cannot create $input_file: $!");
332    print {$input} $data_ref->{input}
333      or BAIL_OUT("cannot write to $input_file: $!");
334    close($input) or BAIL_OUT("cannot flush output to $input_file: $!");
335
336    # Create an output file and parse from the input file to the output file.
337    my $output_file = File::Spec->catfile('t', 'tmp', "out$$.tmp");
338    open(my $output, '>', $output_file)
339      or BAIL_OUT("cannot create $output_file: $!");
340    if ($options_ref->{perlio_utf8}) {
341        ## no critic (BuiltinFunctions::ProhibitStringyEval)
342        ## no critic (ValuesAndExpressions::RequireInterpolationOfMetachars)
343        eval 'binmode($output, ":encoding(utf-8)")';
344        ## use critic
345    }
346
347    # Parse the input file into the output file.
348    $parser->parse_from_file($input_file, $output);
349    close($output) or BAIL_OUT("cannot flush output to $output_file: $!");
350
351    # Read back in the results.  For Pod::Man, also ensure that we didn't
352    # output the accent definitions if we wrote UTF-8 output.
353    open(my $results, '<', $output_file)
354      or BAIL_OUT("cannot open $output_file: $!");
355    my ($line, $saw_accents);
356    if ($class eq 'Pod::Man') {
357        while (defined($line = <$results>)) {
358            $line = decode('UTF-8', $line);
359            if ($line =~ m{ Accent [ ] mark [ ] definitions }xms) {
360                $saw_accents = 1;
361            }
362            last if $line =~ m{ \A [.]nh }xms;
363        }
364    }
365    my $saw = do { local $/ = undef; <$results> };
366    $saw = decode('UTF-8', $saw);
367    $saw =~ s{ \n\s+ \z }{\n}xms;
368    close($results) or BAIL_OUT("cannot close output file: $!");
369
370    # Clean up.
371    unlink($input_file, $output_file);
372
373    # Check the accent definitions and the output.
374    my $perlio = $options_ref->{perlio_utf8} ? ' (PerlIO)' : q{};
375    if ($class eq 'Pod::Man') {
376        is(
377            $saw_accents,
378            $data_ref->{options}{utf8} ? undef : 1,
379            "$data_ref->{name}: accent definitions$perlio"
380        );
381    }
382    is(
383        $saw,
384        decode($encoding, $data_ref->{output}),
385        "$data_ref->{name}: output$perlio"
386    );
387    return;
388}
389
3901;
391__END__
392
393=for stopwords
394Allbery podlators PerlIO UTF-8 formatter FH whitespace
395
396=head1 NAME
397
398Test::Podlators - Helper functions for podlators tests
399
400=head1 SYNOPSIS
401
402    use Test::Podlators qw(read_test_data);
403
404    # Read the next block of test data, including options.
405    my $data = read_test_data(\*DATA, { options => 1 });
406
407=head1 DESCRIPTION
408
409This module collects various utility functions that are useful for writing
410test cases for the podlators distribution.  It is not intended to be, and
411probably isn't, useful outside of the test suite for that module.
412
413=head1 FUNCTIONS
414
415None of these functions are imported by default.  The ones used by a script
416should be explicitly imported.
417
418=over 4
419
420=item read_snippet(PATH)
421
422Read one test snippet from the provided relative file name and return it.  The
423path should be relative to F<t/data/snippets>.  For the format, see
424F<t/data/snippets/README>.
425
426The result will be a hash with the following keys:
427
428=over 4
429
430=item name
431
432The name of the test, for reporting purposes.
433
434=item options
435
436A hash of any options to values, if any options were specified.
437
438=item input
439
440Input POD to try formatting.
441
442=item output
443
444The expected output.
445
446=item errors
447
448Expected errors from the POD formatter.
449
450=item exception
451
452An expected exception from the POD formatter, with the file and line
453information stripped from the end of the exception.
454
455=back
456
457=item read_test_data(FH, FORMAT)
458
459Reads a block of test data from FH, looking for test information according to
460the description provided in FORMAT.  All data prior to the first line
461consisting of only C<###> will be ignored.  Then, the test data must consist
462of two or more blocks separated by C<###> and ending in a final C<###> line.
463
464FORMAT is optional, in which case the block of test data should be just input
465text and output text.  If provided, it should be a reference to a hash with
466one or more of the following keys:
467
468=over 4
469
470=item options
471
472If set, the first block of data in the test description is a set of options in
473the form of a key, whitespace, and a value, one per line.  The value may be
474missing, in which case the value associated with the key is the empty string.
475
476=back
477
478The return value is a hash with at least some of the following keys:
479
480=over 4
481
482=item input
483
484The input data for the test.  This is always present.
485
486=item options
487
488If C<options> is set in the FORMAT argument, this is the hash of keys and
489values in the options section of the test data.
490
491=item output
492
493The output data for the test.  This is always present.
494
495=back
496
497=item slurp(FILE[, STRIP])
498
499Read the contents of FILE and return it as a string.  If STRIP is set to
500C<man>, strip off any Pod::Man header from the file before returning it.
501
502=item test_snippet(CLASS, SNIPPET[, OPTIONS])
503
504Test a formatter on a particular POD snippet.  This does all the work of
505loading the snippet, creating the formatter by instantiating CLASS, running
506it, and checking the results.  Results are reported with Test::More.
507
508OPTIONS, if present, is a reference to a hash of options.  Currently, only
509one key is supported: C<encoding>, which, if set, specifies the encoding of
510the output portion of the snippet.
511
512=item test_snippet_with_io(CLASS, SNIPPET[, OPTIONS])
513
514The same as test_snippet(), except, rather than parsing the input into a
515string buffer, this function uses real, temporary input and output files.
516This can be used to test I/O layer handling and proper encoding.
517
518OPTIONS, if present, is a reference to a hash of options.  Currently, only one
519key is supported: C<perlio_utf8>, which, if set to true, will set a PerlIO
520UTF-8 encoding layer on the output file before writing to it.
521
522=back
523
524=head1 AUTHOR
525
526Russ Allbery <rra@cpan.org>
527
528=head1 COPYRIGHT AND LICENSE
529
530Copyright 2015-2016, 2018-2020 Russ Allbery <rra@cpan.org>
531
532This program is free software; you may redistribute it and/or modify it
533under the same terms as Perl itself.
534
535=cut
536
537# Local Variables:
538# copyright-at-end-flag: t
539# End:
540