summaryrefslogtreecommitdiffstats
path: root/lib/Lintian/Output
diff options
context:
space:
mode:
Diffstat (limited to '')
-rw-r--r--lib/Lintian/Output/EWI.pm614
-rw-r--r--lib/Lintian/Output/Grammar.pm84
-rw-r--r--lib/Lintian/Output/HTML.pm331
-rw-r--r--lib/Lintian/Output/JSON.pm322
-rw-r--r--lib/Lintian/Output/Markdown.pm224
-rw-r--r--lib/Lintian/Output/Universal.pm151
6 files changed, 1726 insertions, 0 deletions
diff --git a/lib/Lintian/Output/EWI.pm b/lib/Lintian/Output/EWI.pm
new file mode 100644
index 0000000..af0fac6
--- /dev/null
+++ b/lib/Lintian/Output/EWI.pm
@@ -0,0 +1,614 @@
+# Copyright (C) 2008 Frank Lichtenheld <frank@lichtenheld.de>
+# Copyright (C) 2021 Felix Lechner
+#
+# This program is free software; you can redistribute it and/or modify
+# it under the terms of the GNU General Public License as published by
+# the Free Software Foundation; either version 2 of the License, or
+# (at your option) any later version.
+#
+# This program is distributed in the hope that it will be useful,
+# but WITHOUT ANY WARRANTY; without even the implied warranty of
+# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+# GNU General Public License for more details.
+#
+# You should have received a copy of the GNU General Public License
+# along with this program. If not, you can find it on the World Wide
+# Web at https://www.gnu.org/copyleft/gpl.html, or write to the Free
+# Software Foundation, Inc., 51 Franklin St, Fifth Floor, Boston,
+# MA 02110-1301, USA.
+
+package Lintian::Output::EWI;
+
+use v5.20;
+use warnings;
+use utf8;
+
+use Const::Fast;
+use HTML::HTML5::Entities;
+use List::Compare;
+use Term::ANSIColor ();
+use Text::Wrap;
+use Unicode::UTF8 qw(encode_utf8);
+
+use Lintian::Output::Markdown qw(markdown_citation);
+
+# for tty hyperlinks
+const my $OSC_HYPERLINK => qq{\033]8;;};
+const my $OSC_DONE => qq{\033\\};
+const my $BEL => qq{\a};
+
+const my $EMPTY => q{};
+const my $SPACE => q{ };
+const my $COLON => q{:};
+const my $DOT => q{.};
+const my $NEWLINE => qq{\n};
+const my $PARAGRAPH_BREAK => $NEWLINE x 2;
+
+const my $YES => q{yes};
+const my $NO => q{no};
+
+const my $COMMENT_PREFIX => q{N:} . $SPACE;
+
+const my $DESCRIPTION_INDENTATION => 2;
+const my $DESCRIPTION_PREFIX => $COMMENT_PREFIX
+ . $SPACE x $DESCRIPTION_INDENTATION;
+
+const my $SCREEN_INDENTATION => 4;
+const my $SCREEN_PREFIX => $COMMENT_PREFIX . $SPACE x $SCREEN_INDENTATION;
+
+const my %COLORS => (
+ 'E' => 'bright_white on_bright_red',
+ 'W' => 'black on_bright_yellow',
+ 'I' => 'bright_white on_bright_blue',
+ 'P' => 'bright_white on_green',
+ 'C' => 'bright_white on_bright_magenta',
+ 'X' => 'bright_white on_yellow',
+ 'O' => 'bright_white on_bright_black',
+ 'M' => 'bright_black on_bright_white',
+);
+
+const my %CODE_PRIORITY => (
+ 'E' => 30,
+ 'W' => 40,
+ 'I' => 50,
+ 'P' => 60,
+ 'X' => 70,
+ 'C' => 80,
+ 'O' => 90,
+ 'M' => 100,
+);
+
+const my %TYPE_PRIORITY => (
+ 'source' => 30,
+ 'binary' => 40,
+ 'udeb' => 50,
+ 'changes' => 60,
+ 'buildinfo' => 70,
+);
+
+use Moo;
+use namespace::clean;
+
+with 'Lintian::Output::Grammar';
+
+=head1 NAME
+
+Lintian::Output::EWI - standard hint output
+
+=head1 SYNOPSIS
+
+ use Lintian::Output::EWI;
+
+=head1 DESCRIPTION
+
+Provides standard hint output.
+
+=head1 INSTANCE METHODS
+
+=over 4
+
+=item tag_count_by_processable
+
+=cut
+
+has tag_count_by_processable => (is => 'rw', default => sub { {} });
+
+=item issue_hints
+
+=cut
+
+sub issue_hints {
+ my ($self, $profile, $groups, $option) = @_;
+
+ my %sorter;
+ for my $group (@{$groups // []}) {
+
+ for my $processable ($group->get_processables) {
+
+ my $type = $processable->type;
+ my $type_priority = $TYPE_PRIORITY{$type};
+
+ for my $hint (@{$processable->hints}) {
+
+ my $tag = $profile->get_tag($hint->tag_name);
+
+ my $override_status = 0;
+ $override_status = 1
+ if defined $hint->override || @{$hint->masks};
+
+ my $ranking_code = $tag->code;
+ $ranking_code = 'X'
+ if $tag->experimental;
+ $ranking_code = 'O'
+ if defined $hint->override;
+ $ranking_code = 'M'
+ if @{$hint->masks};
+
+ my $code_priority = $CODE_PRIORITY{$ranking_code};
+
+ my %for_output;
+ $for_output{hint} = $hint;
+ $for_output{processable} = $processable;
+
+ push(
+ @{
+ $sorter{$override_status}{$code_priority}{$tag->name}
+ {$type_priority}{$processable->name}{$hint->context}
+ },
+ \%for_output
+ );
+ }
+ }
+ }
+
+ for my $override_status (sort keys %sorter) {
+
+ my %by_code_priority = %{$sorter{$override_status}};
+
+ for my $code_priority (sort { $a <=> $b } keys %by_code_priority) {
+
+ my %by_tag_name = %{$by_code_priority{$code_priority}};
+
+ for my $tag_name (sort keys %by_tag_name) {
+
+ my %by_type_priority = %{$by_tag_name{$tag_name}};
+
+ for
+ my $type_priority (sort { $a <=> $b }keys %by_type_priority){
+
+ my %by_processable_name
+ = %{$by_type_priority{$type_priority}};
+
+ for my $processable_name (sort keys %by_processable_name) {
+
+ my %by_context
+ = %{$by_processable_name{$processable_name}};
+
+ for my $context (sort keys %by_context) {
+
+ my $for_output
+ = $sorter{$override_status}{$code_priority}
+ {$tag_name}{$type_priority}{$processable_name}
+ {$context};
+
+ for my $each (@{$for_output}) {
+
+ my $hint = $each->{hint};
+ my $processable = $each->{processable};
+
+ $self->print_hint($profile, $hint,
+ $processable,$option)
+ if ( !defined $hint->override
+ && !@{$hint->masks})
+ || $option->{'show-overrides'};
+ }
+ }
+ }
+ }
+ }
+ }
+ }
+
+ return;
+}
+
+=item C<print_hint>
+
+=cut
+
+sub print_hint {
+ my ($self, $profile, $hint, $processable, $option) = @_;
+
+ my $tag_name = $hint->tag_name;
+ my $tag = $profile->get_tag($tag_name);
+
+ my @want_references = @{$option->{'display-source'} // []};
+ my @have_references = @{$tag->see_also};
+
+ # keep only the first word
+ s{^ ([\w-]+) \s }{$1}x for @have_references;
+
+ # drop anything in parentheses at the end
+ s{ [(] \S+ [)] $}{}x for @have_references;
+
+ # check if hint refers to the selected references
+ my $reference_lc= List::Compare->new(\@have_references, \@want_references);
+
+ my @found_references = $reference_lc->get_intersection;
+
+ return
+ if @want_references
+ && !@found_references;
+
+ my $information = $hint->context;
+ $information = $SPACE . $self->_quote_print($information)
+ unless $information eq $EMPTY;
+
+ # Limit the output so people do not drown in hints. Some hints are
+ # insanely noisy (hi static-library-has-unneeded-section)
+ my $limit = $option->{'tag-display-limit'};
+ if ($limit) {
+
+ my $processable_id = $processable->identifier;
+ my $emitted_count
+ = $self->tag_count_by_processable->{$processable_id}{$tag_name}++;
+
+ return
+ if $emitted_count >= $limit;
+
+ my $msg
+ = ' ... use "--tag-display-limit 0" to see all (or pipe to a file/program)';
+ $information = $self->_quote_print($msg)
+ if $emitted_count >= $limit-1;
+ }
+
+ say encode_utf8('N:')
+ if $option->{info};
+
+ my $text = $tag_name;
+
+ my $code = $tag->code;
+ $code = 'X' if $tag->experimental;
+ $code = 'O' if defined $hint->override;
+ $code = 'M' if @{$hint->masks};
+
+ my $tag_color = $COLORS{$code};
+
+ $text = Term::ANSIColor::colored($tag_name, $tag_color)
+ if $option->{color};
+
+ my $output;
+ if ($option->{hyperlinks} && $option->{color}) {
+ my $target= 'https://lintian.debian.org/tags/' . $tag_name;
+ $output .= $self->osc_hyperlink($text, $target);
+ } else {
+ $output .= $text;
+ }
+
+ local $Text::Wrap::columns
+ = $option->{'output-width'} - length $COMMENT_PREFIX;
+
+ # do not wrap long words such as urls; see #719769
+ local $Text::Wrap::huge = 'overflow';
+
+ if ($hint->override && length $hint->override->justification) {
+
+ my $wrapped = wrap($COMMENT_PREFIX, $COMMENT_PREFIX,
+ $hint->override->justification);
+ say encode_utf8($wrapped);
+ }
+
+ for my $mask (@{$hint->masks}) {
+
+ say encode_utf8($COMMENT_PREFIX . 'masked by screen ' . $mask->screen);
+
+ next
+ unless length $mask->excuse;
+
+ my $wrapped= wrap($COMMENT_PREFIX, $COMMENT_PREFIX, $mask->excuse);
+ say encode_utf8($wrapped);
+ }
+
+ my $type = $EMPTY;
+ $type = $SPACE . $processable->type
+ unless $processable->type eq 'binary';
+
+ say encode_utf8($code
+ . $COLON
+ . $SPACE
+ . $processable->name
+ . $type
+ . $COLON
+ . $SPACE
+ . $output
+ . $information);
+
+ if ($option->{info}) {
+
+ # show only on first issuance
+ $self->describe_tag($profile->data, $tag, $option->{'output-width'})
+ unless $self->issued_tag($tag->name);
+ }
+
+ return;
+}
+
+=item C<_quote_print($string)>
+
+Called to quote a string. By default it will replace all
+non-printables with "?". Sub-classes can override it if
+they allow non-ascii printables etc.
+
+=cut
+
+sub _quote_print {
+ my ($self, $string) = @_;
+
+ $string =~ s/[^[:print:]]/?/g;
+
+ return $string;
+}
+
+=item C<osc_hyperlink>
+
+=cut
+
+sub osc_hyperlink {
+ my ($self, $text, $target) = @_;
+
+ my $start = $OSC_HYPERLINK . $target . $BEL;
+ my $end = $OSC_HYPERLINK . $BEL;
+
+ return $start . $text . $end;
+}
+
+=item issuedtags
+
+Hash containing the names of tags which have been issued.
+
+=cut
+
+has issuedtags => (is => 'rw', default => sub { {} });
+
+=item C<issued_tag($tag_name)>
+
+Indicate that the named tag has been issued. Returns a boolean value
+indicating whether the tag had previously been issued by the object.
+
+=cut
+
+sub issued_tag {
+ my ($self, $tag_name) = @_;
+
+ return $self->issuedtags->{$tag_name}++ ? 1 : 0;
+}
+
+=item describe_tags
+
+=cut
+
+sub describe_tags {
+ my ($self, $data, $tags, $columns) = @_;
+
+ for my $tag (@{$tags}) {
+
+ my $name;
+ my $code;
+
+ if (defined $tag) {
+ $name = $tag->name;
+ $code = $tag->code;
+
+ } else {
+ $name = 'unknown-tag';
+ $code = 'N';
+ }
+
+ say encode_utf8('N:');
+ say encode_utf8("$code: $name");
+
+ $self->describe_tag($data, $tag, $columns);
+ }
+
+ return;
+}
+
+=item describe_tag
+
+=cut
+
+sub describe_tag {
+ my ($self, $data, $tag, $columns) = @_;
+
+ local $Text::Wrap::columns = $columns;
+
+ # do not wrap long words such as urls; see #719769
+ local $Text::Wrap::huge = 'overflow';
+
+ my $wrapped = $COMMENT_PREFIX . $NEWLINE;
+
+ if (defined $tag) {
+
+ my $plain_explanation = markdown_to_plain($tag->explanation,
+ $columns - length $DESCRIPTION_PREFIX);
+
+ $wrapped .= $DESCRIPTION_PREFIX . $_ . $NEWLINE
+ for split(/\n/, $plain_explanation);
+
+ if (@{$tag->see_also}) {
+
+ $wrapped .= $COMMENT_PREFIX . $NEWLINE;
+
+ my @see_also_markdown
+ = map { markdown_citation($data, $_) } @{$tag->see_also};
+ my $markdown
+ = 'Please refer to '
+ . $self->oxford_enumeration('and', @see_also_markdown)
+ . ' for details.'
+ . $NEWLINE;
+ my $plain = markdown_to_plain($markdown,
+ $columns - length $DESCRIPTION_PREFIX);
+
+ $wrapped .= $DESCRIPTION_PREFIX . $_ . $NEWLINE
+ for split(/\n/, $plain);
+ }
+
+ $wrapped .= $COMMENT_PREFIX . $NEWLINE;
+
+ my $visibility_prefix = 'Visibility: ';
+ $wrapped.= wrap(
+ $DESCRIPTION_PREFIX . $visibility_prefix,
+ $DESCRIPTION_PREFIX . $SPACE x length $visibility_prefix,
+ $tag->visibility . $NEWLINE
+ );
+
+ $wrapped .= wrap($DESCRIPTION_PREFIX, $DESCRIPTION_PREFIX,
+ 'Show-Always: '. ($tag->show_always ? $YES : $NO) . $NEWLINE);
+
+ my $check_prefix = 'Check: ';
+ $wrapped .= wrap(
+ $DESCRIPTION_PREFIX . $check_prefix,
+ $DESCRIPTION_PREFIX . $SPACE x length $check_prefix,
+ $tag->check . $NEWLINE
+ );
+
+ if (@{$tag->renamed_from}) {
+
+ $wrapped .= wrap($DESCRIPTION_PREFIX, $DESCRIPTION_PREFIX,
+ 'Renamed from: '
+ . join($SPACE, @{$tag->renamed_from})
+ . $NEWLINE);
+ }
+
+ $wrapped
+ .= wrap($DESCRIPTION_PREFIX, $DESCRIPTION_PREFIX,
+ 'This tag is experimental.' . $NEWLINE)
+ if $tag->experimental;
+
+ $wrapped .= wrap($DESCRIPTION_PREFIX, $DESCRIPTION_PREFIX,
+ 'This tag is a classification. There is no issue in your package.'
+ . $NEWLINE)
+ if $tag->visibility eq 'classification';
+
+ for my $screen (@{$tag->screens}) {
+
+ $wrapped .= $COMMENT_PREFIX . $NEWLINE;
+
+ $wrapped
+ .= wrap($DESCRIPTION_PREFIX, $DESCRIPTION_PREFIX,
+ 'Screen: ' . $screen->name . $NEWLINE);
+
+ $wrapped .= wrap($SCREEN_PREFIX, $SCREEN_PREFIX,
+ 'Advocates: '. join(', ', @{$screen->advocates}). $NEWLINE);
+
+ my $combined = $screen->reason . $NEWLINE;
+ if (@{$screen->see_also}) {
+
+ $combined .= $NEWLINE;
+
+ my @see_also_markdown
+ = map { markdown_citation($data, $_) } @{$screen->see_also};
+ $combined
+ .= 'Read more in '
+ . $self->oxford_enumeration('and', @see_also_markdown)
+ . $DOT
+ . $NEWLINE;
+ }
+
+ my $reason_prefix = 'Reason: ';
+ my $plain = markdown_to_plain($combined,
+ $columns - length($SCREEN_PREFIX . $reason_prefix));
+
+ my @lines = split(/\n/, $plain);
+ $wrapped
+ .= $SCREEN_PREFIX . $reason_prefix . (shift @lines) . $NEWLINE;
+ $wrapped
+ .= $SCREEN_PREFIX
+ . $SPACE x (length $reason_prefix)
+ . $_
+ . $NEWLINE
+ for @lines;
+ }
+
+ } else {
+ $wrapped
+ .= wrap($DESCRIPTION_PREFIX, $DESCRIPTION_PREFIX, 'Unknown tag.');
+ }
+
+ $wrapped .= $COMMENT_PREFIX . $NEWLINE;
+
+ print encode_utf8($wrapped);
+
+ return;
+}
+
+=item markdown_to_plain
+
+=cut
+
+sub markdown_to_plain {
+ my ($markdown, $columns) = @_;
+
+ # use angular brackets for emphasis
+ $markdown =~ s{<i>|<em>}{&lt;}g;
+ $markdown =~ s{</i>|</em>}{&gt;}g;
+
+ # drop Markdown hyperlinks
+ $markdown =~ s{\[([^\]]+)\]\([^\)]+\)}{$1}g;
+
+ # drop all HTML tags except Markdown shorthand <$url>
+ $markdown =~ s{<(?![a-z]+://)[^>]+>}{}g;
+
+ # drop brackets around Markdown shorthand <$url>
+ $markdown =~ s{<([a-z]+://[^>]+)>}{$1}g;
+
+ # substitute HTML entities
+ my $plain = decode_entities($markdown);
+
+ local $Text::Wrap::columns = $columns
+ if defined $columns;
+
+ # do not wrap long words such as urls; see #719769
+ local $Text::Wrap::huge = 'overflow';
+
+ my @paragraphs = split(/\n{2,}/, $plain);
+
+ my @lines;
+ for my $paragraph (@paragraphs) {
+
+ # do not wrap preformatted paragraphs
+ unless ($paragraph =~ /^\s/) {
+
+ # reduce whitespace throughout, including newlines
+ $paragraph =~ s/\s+/ /g;
+
+ # trim beginning and end of each line
+ $paragraph =~ s/^\s+|\s+$//mg;
+
+ $paragraph = wrap($EMPTY, $EMPTY, $paragraph);
+ }
+
+ push(@lines, $EMPTY);
+ push(@lines, split(/\n/, $paragraph));
+ }
+
+ # drop leading blank line
+ shift @lines;
+
+ my $wrapped;
+ $wrapped .= $_ . $NEWLINE for @lines;
+
+ return $wrapped;
+}
+
+=back
+
+=cut
+
+1;
+
+# Local Variables:
+# indent-tabs-mode: nil
+# cperl-indent-level: 4
+# End:
+# vim: syntax=perl sw=4 sts=4 sr et
diff --git a/lib/Lintian/Output/Grammar.pm b/lib/Lintian/Output/Grammar.pm
new file mode 100644
index 0000000..e9d62bd
--- /dev/null
+++ b/lib/Lintian/Output/Grammar.pm
@@ -0,0 +1,84 @@
+# Copyright (C) 2021 Felix Lechner
+#
+# This program is free software; you can redistribute it and/or modify
+# it under the terms of the GNU General Public License as published by
+# the Free Software Foundation; either version 2 of the License, or
+# (at your option) any later version.
+#
+# This program is distributed in the hope that it will be useful,
+# but WITHOUT ANY WARRANTY; without even the implied warranty of
+# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+# GNU General Public License for more details.
+#
+# You should have received a copy of the GNU General Public License
+# along with this program. If not, you can find it on the World Wide
+# Web at https://www.gnu.org/copyleft/gpl.html, or write to the Free
+# Software Foundation, Inc., 51 Franklin St, Fifth Floor, Boston,
+# MA 02110-1301, USA.
+
+package Lintian::Output::Grammar;
+
+use v5.20;
+use warnings;
+use utf8;
+
+use Const::Fast;
+
+use Moo::Role;
+use namespace::clean;
+
+const my $EMPTY => q{};
+const my $SPACE => q{ };
+const my $COMMA => q{,};
+
+=head1 NAME
+
+Lintian::Output::Grammar - sentence helpers
+
+=head1 SYNOPSIS
+
+ use Lintian::Output::Grammar;
+
+=head1 DESCRIPTION
+
+Helps with human readable output.
+
+=head1 INSTANCE METHODS
+
+=over 4
+
+=item oxford_enumeration
+
+=cut
+
+sub oxford_enumeration {
+ my ($self, $conjunctive, @alternatives) = @_;
+
+ return $EMPTY
+ unless @alternatives;
+
+ # remove and save last element
+ my $final = pop @alternatives;
+
+ my $maybe_comma = (@alternatives > 1 ? $COMMA : $EMPTY);
+
+ my $text = $EMPTY;
+ $text = join($COMMA . $SPACE, @alternatives) . "$maybe_comma $conjunctive "
+ if @alternatives;
+
+ $text .= $final;
+
+ return $text;
+}
+
+=back
+
+=cut
+
+1;
+
+# Local Variables:
+# indent-tabs-mode: nil
+# cperl-indent-level: 4
+# End:
+# vim: syntax=perl sw=4 sts=4 sr et
diff --git a/lib/Lintian/Output/HTML.pm b/lib/Lintian/Output/HTML.pm
new file mode 100644
index 0000000..8fd1126
--- /dev/null
+++ b/lib/Lintian/Output/HTML.pm
@@ -0,0 +1,331 @@
+# Copyright (C) 2020-2021 Felix Lechner
+#
+# This program is free software; you can redistribute it and/or modify
+# it under the terms of the GNU General Public License as published by
+# the Free Software Foundation; either version 2 of the License, or
+# (at your option) any later version.
+#
+# This program is distributed in the hope that it will be useful,
+# but WITHOUT ANY WARRANTY; without even the implied warranty of
+# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+# GNU General Public License for more details.
+#
+# You should have received a copy of the GNU General Public License
+# along with this program. If not, you can find it on the World Wide
+# Web at https://www.gnu.org/copyleft/gpl.html, or write to the Free
+# Software Foundation, Inc., 51 Franklin St, Fifth Floor, Boston,
+# MA 02110-1301, USA.
+
+package Lintian::Output::HTML;
+
+use v5.20;
+use warnings;
+use utf8;
+
+use Const::Fast;
+use Path::Tiny;
+use Text::Markdown::Discount qw(markdown);
+use Text::Xslate qw(mark_raw);
+use Time::Duration;
+use Time::Moment;
+use Unicode::UTF8 qw(encode_utf8);
+
+use Lintian::Output::Markdown qw(markdown_citation);
+
+const my $EMPTY => q{};
+const my $SPACE => q{ };
+const my $NEWLINE => qq{\n};
+const my $PARAGRAPH_BREAK => $NEWLINE x 2;
+
+const my %CODE_PRIORITY => (
+ 'E' => 30,
+ 'W' => 40,
+ 'I' => 50,
+ 'P' => 60,
+ 'X' => 70,
+ 'C' => 80,
+ 'O' => 90,
+ 'M' => 100,
+);
+
+use Moo;
+use namespace::clean;
+
+with 'Lintian::Output::Grammar';
+
+=head1 NAME
+
+Lintian::Output::HTML - standalone HTML hint output
+
+=head1 SYNOPSIS
+
+ use Lintian::Output::HTML;
+
+=head1 DESCRIPTION
+
+Provides standalone HTML hint output.
+
+=head1 INSTANCE METHODS
+
+=over 4
+
+=item issue_hints
+
+Print all hints passed in array. A separate arguments with processables
+is necessary to report in case no hints were found.
+
+=cut
+
+sub issue_hints {
+ my ($self, $profile, $groups) = @_;
+
+ $groups //= [];
+
+ my %output;
+
+ my $lintian_version = $ENV{LINTIAN_VERSION};
+ $output{'lintian-version'} = $lintian_version;
+
+ my @allgroups_output;
+ $output{groups} = \@allgroups_output;
+
+ for my $group (sort { $a->name cmp $b->name } @{$groups}) {
+
+ my %group_output;
+
+ $group_output{'group-id'} = $group->name;
+ $group_output{name} = $group->source_name;
+ $group_output{version} = $group->source_version;
+
+ my $start = Time::Moment->from_string($group->processing_start);
+ my $end = Time::Moment->from_string($group->processing_end);
+ $group_output{start} = $start->strftime('%c');
+ $group_output{end} = $end->strftime('%c');
+ $group_output{duration} = duration($start->delta_seconds($end));
+
+ my @processables = $group->get_processables;
+ my $any_processable = shift @processables;
+ $group_output{'maintainer'}
+ = $any_processable->fields->value('Maintainer');
+
+ push(@allgroups_output, \%group_output);
+
+ my @allfiles_output;
+ $group_output{'input-files'} = \@allfiles_output;
+
+ for my $processable (sort {$a->path cmp $b->path}
+ $group->get_processables) {
+ my %file_output;
+ $file_output{filename} = path($processable->path)->basename;
+ $file_output{hints}
+ = $self->hintlist($profile, $processable->hints);
+ push(@allfiles_output, \%file_output);
+ }
+ }
+
+ my $style_sheet = $profile->data->style_sheet->css;
+
+ my $templatedir = "$ENV{LINTIAN_BASE}/templates";
+ my $tx = Text::Xslate->new(path => [$templatedir]);
+ my $page = $tx->render(
+ 'standalone-html.tx',
+ {
+ title => 'Lintian Tags',
+ style_sheet => mark_raw($style_sheet),
+ output => \%output,
+ }
+ );
+
+ print encode_utf8($page);
+
+ return;
+}
+
+=item C<hintlist>
+
+=cut
+
+sub hintlist {
+ my ($self, $profile, $arrayref) = @_;
+
+ my %sorter;
+ for my $hint (@{$arrayref // []}) {
+
+ my $tag = $profile->get_tag($hint->tag_name);
+
+ my $override_status = 0;
+ $override_status = 1
+ if defined $hint->override || @{$hint->masks};
+
+ my $ranking_code = $tag->code;
+ $ranking_code = 'X'
+ if $tag->experimental;
+ $ranking_code = 'O'
+ if defined $hint->override;
+ $ranking_code = 'M'
+ if @{$hint->masks};
+
+ my $code_priority = $CODE_PRIORITY{$ranking_code};
+
+ push(
+ @{
+ $sorter{$override_status}{$code_priority}{$tag->name}
+ {$hint->context}
+ },
+ $hint
+ );
+ }
+
+ my @sorted;
+ for my $override_status (sort keys %sorter) {
+ my %by_code_priority = %{$sorter{$override_status}};
+
+ for my $code_priority (sort { $a <=> $b } keys %by_code_priority) {
+ my %by_tag_name = %{$by_code_priority{$code_priority}};
+
+ for my $tag_name (sort keys %by_tag_name) {
+ my %by_context = %{$by_tag_name{$tag_name}};
+
+ for my $context (sort keys %by_context) {
+
+ my $hints
+ = $sorter{$override_status}{$code_priority}{$tag_name}
+ {$context};
+
+ push(@sorted, $_)for @{$hints};
+ }
+ }
+ }
+ }
+
+ my @html_hints;
+ for my $hint (@sorted) {
+
+ my $tag = $profile->get_tag($hint->tag_name);
+
+ my %html_hint;
+ push(@html_hints, \%html_hint);
+
+ $html_hint{tag_name} = $hint->tag_name;
+
+ $html_hint{url} = 'https://lintian.debian.org/tags/' . $hint->tag_name;
+
+ $html_hint{context} = $hint->context
+ if length $hint->context;
+
+ $html_hint{visibility} = $tag->visibility;
+
+ $html_hint{visibility} = 'experimental'
+ if $tag->experimental;
+
+ my @comments;
+ if ($hint->override) {
+
+ $html_hint{visibility} = 'override';
+
+ push(@comments, $hint->override->justification)
+ if length $hint->override->justification;
+ }
+
+ # order matters
+ $html_hint{visibility} = 'mask'
+ if @{ $hint->masks };
+
+ for my $mask (@{$hint->masks}) {
+
+ push(@comments, 'masked by screen ' . $mask->screen);
+ push(@comments, $mask->excuse)
+ if length $mask->excuse;
+ }
+
+ $html_hint{comments} = \@comments
+ if @comments;
+ }
+
+ return \@html_hints;
+}
+
+=item describe_tags
+
+=cut
+
+sub describe_tags {
+ my ($self, $data, $tags) = @_;
+
+ for my $tag (@{$tags}) {
+
+ say encode_utf8('<p>Name: ' . $tag->name . '</p>');
+ say encode_utf8($EMPTY);
+
+ print encode_utf8(markdown($self->markdown_description($data, $tag)));
+ }
+
+ return;
+}
+
+=item markdown_description
+
+=cut
+
+sub markdown_description {
+ my ($self, $data, $tag) = @_;
+
+ my $description = $tag->explanation;
+
+ my @extras;
+
+ if (@{$tag->see_also}) {
+
+ my @markdown
+ = map { markdown_citation($data, $_) } @{$tag->see_also};
+ my $references
+ = 'Please refer to '
+ . $self->oxford_enumeration('and', @markdown)
+ . ' for details.';
+
+ push(@extras, $references);
+ }
+
+ push(@extras, 'Visibility: '. $tag->visibility);
+
+ push(@extras, 'Check: ' . $tag->check)
+ if length $tag->check;
+
+ push(@extras, 'Renamed from: ' . join($SPACE, @{$tag->renamed_from}))
+ if @{$tag->renamed_from};
+
+ push(@extras, 'This tag is experimental.')
+ if $tag->experimental;
+
+ push(@extras,
+ 'This tag is a classification. There is no issue in your package.')
+ if $tag->visibility eq 'classification';
+
+ for my $screen (@{$tag->screens}) {
+
+ my $screen_description = 'Screen: ' . $screen->name . $NEWLINE;
+ $screen_description
+ .= 'Advocates: ' . join(', ', @{$screen->advocates}) . $NEWLINE;
+ $screen_description .= 'Reason: ' . $screen->reason . $NEWLINE;
+
+ $screen_description .= 'See-Also: ' . $NEWLINE;
+
+ push(@extras, $screen_description);
+ }
+
+ $description .= $PARAGRAPH_BREAK . $_ for @extras;
+
+ return $description;
+}
+
+=back
+
+=cut
+
+1;
+
+# Local Variables:
+# indent-tabs-mode: nil
+# cperl-indent-level: 4
+# End:
+# vim: syntax=perl sw=4 sts=4 sr et
diff --git a/lib/Lintian/Output/JSON.pm b/lib/Lintian/Output/JSON.pm
new file mode 100644
index 0000000..08996e2
--- /dev/null
+++ b/lib/Lintian/Output/JSON.pm
@@ -0,0 +1,322 @@
+# Copyright (C) 2020-2021 Felix Lechner
+#
+# This program is free software; you can redistribute it and/or modify
+# it under the terms of the GNU General Public License as published by
+# the Free Software Foundation; either version 2 of the License, or
+# (at your option) any later version.
+#
+# This program is distributed in the hope that it will be useful,
+# but WITHOUT ANY WARRANTY; without even the implied warranty of
+# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+# GNU General Public License for more details.
+#
+# You should have received a copy of the GNU General Public License
+# along with this program. If not, you can find it on the World Wide
+# Web at https://www.gnu.org/copyleft/gpl.html, or write to the Free
+# Software Foundation, Inc., 51 Franklin St, Fifth Floor, Boston,
+# MA 02110-1301, USA.
+
+package Lintian::Output::JSON;
+
+use v5.20;
+use warnings;
+use utf8;
+
+use Const::Fast;
+use Time::Piece;
+use JSON::MaybeXS;
+
+use Lintian::Output::Markdown qw(markdown_citation);
+
+const my $EMPTY => q{};
+
+const my %CODE_PRIORITY => (
+ 'E' => 30,
+ 'W' => 40,
+ 'I' => 50,
+ 'P' => 60,
+ 'X' => 70,
+ 'C' => 80,
+ 'O' => 90,
+ 'M' => 100,
+);
+
+use Moo;
+use namespace::clean;
+
+=head1 NAME
+
+Lintian::Output::JSON - JSON hint output
+
+=head1 SYNOPSIS
+
+ use Lintian::Output::JSON;
+
+=head1 DESCRIPTION
+
+Provides JSON hint output.
+
+=head1 INSTANCE METHODS
+
+=over 4
+
+=item issue_hints
+
+Print all hints passed in array. A separate arguments with processables
+is necessary to report in case no hints were found.
+
+=cut
+
+sub issue_hints {
+ my ($self, $profile, $groups) = @_;
+
+ $groups //= [];
+
+ my %output;
+
+ $output{lintian_version} = $ENV{LINTIAN_VERSION};
+
+ my @allgroups_output;
+ $output{groups} = \@allgroups_output;
+
+ for my $group (sort { $a->name cmp $b->name } @{$groups}) {
+
+ my %group_output;
+ $group_output{group_id} = $group->name;
+ $group_output{source_name} = $group->source_name;
+ $group_output{source_version} = $group->source_version;
+
+ push(@allgroups_output, \%group_output);
+
+ my @allfiles_output;
+ $group_output{input_files} = \@allfiles_output;
+
+ for my $processable (sort {$a->path cmp $b->path}
+ $group->get_processables) {
+
+ my %file_output;
+ $file_output{path} = $processable->path;
+ $file_output{hints}
+ = $self->hintlist($profile, $processable->hints);
+
+ push(@allfiles_output, \%file_output);
+ }
+ }
+
+ # convert to UTF-8 prior to encoding in JSON
+ my $encoder = JSON->new;
+ $encoder->canonical;
+ $encoder->utf8;
+ $encoder->pretty;
+
+ my $json = $encoder->encode(\%output);
+
+ # output encoded JSON; is already in UTF-8
+ print $json;
+
+ return;
+}
+
+=item C<hintlist>
+
+=cut
+
+sub hintlist {
+ my ($self, $profile, $arrayref) = @_;
+
+ my %sorter;
+ for my $hint (@{$arrayref // []}) {
+
+ my $tag = $profile->get_tag($hint->tag_name);
+
+ my $override_status = 0;
+ $override_status = 1
+ if defined $hint->override || @{$hint->masks};
+
+ my $ranking_code = $tag->code;
+ $ranking_code = 'X'
+ if $tag->experimental;
+ $ranking_code = 'O'
+ if defined $hint->override;
+ $ranking_code = 'M'
+ if @{$hint->masks};
+
+ my $code_priority = $CODE_PRIORITY{$ranking_code};
+
+ push(
+ @{
+ $sorter{$override_status}{$code_priority}{$tag->name}
+ {$hint->context}
+ },
+ $hint
+ );
+ }
+
+ my @sorted;
+ for my $override_status (sort keys %sorter) {
+ my %by_code_priority = %{$sorter{$override_status}};
+
+ for my $code_priority (sort { $a <=> $b } keys %by_code_priority) {
+ my %by_tag_name = %{$by_code_priority{$code_priority}};
+
+ for my $tag_name (sort keys %by_tag_name) {
+ my %by_context = %{$by_tag_name{$tag_name}};
+
+ for my $context (sort keys %by_context) {
+
+ my $hints
+ = $sorter{$override_status}{$code_priority}{$tag_name}
+ {$context};
+
+ push(@sorted, $_)for @{$hints};
+ }
+ }
+ }
+ }
+
+ my @hint_dictionaries;
+ for my $hint (@sorted) {
+
+ my $tag = $profile->get_tag($hint->tag_name);
+
+ my %hint_dictionary;
+ push(@hint_dictionaries, \%hint_dictionary);
+
+ $hint_dictionary{tag} = $tag->name;
+ $hint_dictionary{note} = $hint->note;
+
+ if ($hint->can('pointer')) {
+ my $pointer = $hint->pointer;
+
+ my %pointer_dictionary;
+
+ if ($pointer->can('item')) {
+ my $item = $pointer->item;
+
+ my %item_dictionary;
+ $item_dictionary{name} = $item->name;
+ $item_dictionary{index} = $item->index->identifier;
+
+ $pointer_dictionary{item} = \%item_dictionary;
+
+ # numerify to force JSON integer
+ # https://metacpan.org/pod/JSON::XS#simple-scalars
+ $pointer_dictionary{line_position} = $pointer->position + 0;
+ }
+
+ $hint_dictionary{pointer} = \%pointer_dictionary;
+ }
+
+ $hint_dictionary{visibility} = $tag->visibility;
+ $hint_dictionary{experimental}
+ = ($tag->experimental ? JSON()->true : JSON()->false);
+
+ for my $mask (@{ $hint->masks }) {
+
+ my %mask_dictionary;
+ $mask_dictionary{screen} = $mask->screen;
+ $mask_dictionary{excuse} = $mask->excuse;
+
+ push(@{$hint_dictionary{masks}}, \%mask_dictionary);
+ }
+
+ if ($hint->override) {
+
+ my %override_dictionary;
+ $override_dictionary{justification}
+ = $hint->override->justification;
+
+ $hint_dictionary{override} = \%override_dictionary;
+ }
+ }
+
+ return \@hint_dictionaries;
+}
+
+=item describe_tags
+
+=cut
+
+sub describe_tags {
+ my ($self, $data, $tags) = @_;
+
+ my @tag_dictionaries;
+
+ for my $tag (@{$tags}) {
+
+ my %tag_dictionary;
+ push(@tag_dictionaries, \%tag_dictionary);
+
+ $tag_dictionary{name} = $tag->name;
+ $tag_dictionary{name_spaced}
+ = ($tag->name_spaced ? JSON()->true : JSON()->false);
+ $tag_dictionary{show_always}
+ = ($tag->show_always ? JSON()->true : JSON()->false);
+
+ $tag_dictionary{explanation} = $tag->explanation;
+
+ my @tag_see_also_markdown
+ = map { markdown_citation($data, $_) } @{$tag->see_also};
+ $tag_dictionary{see_also} = \@tag_see_also_markdown;
+
+ $tag_dictionary{check} = $tag->check;
+ $tag_dictionary{visibility} = $tag->visibility;
+ $tag_dictionary{experimental}
+ = ($tag->experimental ? JSON()->true : JSON()->false);
+
+ $tag_dictionary{renamed_from} = $tag->renamed_from;
+
+ my @screen_dictionaries;
+
+ for my $screen (@{$tag->screens}) {
+
+ my %screen_dictionary;
+ push(@screen_dictionaries, \%screen_dictionary);
+
+ $screen_dictionary{name} = $screen->name;
+
+ my @advocate_emails = map { $_->format } @{$screen->advocates};
+ $screen_dictionary{advocates} = \@advocate_emails;
+
+ $screen_dictionary{reason} = $screen->reason;
+
+ my @screen_see_also_markdown
+ = map { markdown_citation($data, $_) } @{$screen->see_also};
+ $screen_dictionary{see_also} = \@screen_see_also_markdown;
+ }
+
+ $tag_dictionary{screens} = \@screen_dictionaries;
+
+ $tag_dictionary{lintian_version} = $ENV{LINTIAN_VERSION};
+ }
+
+ # convert to UTF-8 prior to encoding in JSON
+ my $encoder = JSON->new;
+ $encoder->canonical;
+ $encoder->utf8;
+ $encoder->pretty;
+
+ # encode single tags without array bracketing
+ my $object = \@tag_dictionaries;
+ $object = shift @tag_dictionaries
+ if @tag_dictionaries == 1;
+
+ my $json = $encoder->encode($object);
+
+ # output encoded JSON; is already in UTF-8
+ print $json;
+
+ return;
+}
+
+=back
+
+=cut
+
+1;
+
+# Local Variables:
+# indent-tabs-mode: nil
+# cperl-indent-level: 4
+# End:
+# vim: syntax=perl sw=4 sts=4 sr et
diff --git a/lib/Lintian/Output/Markdown.pm b/lib/Lintian/Output/Markdown.pm
new file mode 100644
index 0000000..5786612
--- /dev/null
+++ b/lib/Lintian/Output/Markdown.pm
@@ -0,0 +1,224 @@
+# -*- perl -*-
+#
+# Copyright (C) 1998 Christian Schwarz and Richard Braakman
+# Copyright (C) 2009 Russ Allbery
+# Copyright (C) 2020-2021 Felix Lechner
+#
+# This program is free software; you can redistribute it and/or modify it
+# under the terms of the GNU General Public License as published by the Free
+# Software Foundation; either version 2 of the License, or (at your option)
+# any later version.
+#
+# This program is distributed in the hope that it will be useful, but WITHOUT
+# ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or
+# FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for
+# more details.
+#
+# You should have received a copy of the GNU General Public License along with
+# this program. If not, see <http://www.gnu.org/licenses/>.
+
+package Lintian::Output::Markdown;
+
+use v5.20;
+use warnings;
+use utf8;
+
+use Exporter qw(import);
+
+our @EXPORT_OK = qw(
+ markdown_citation
+ markdown_authority
+ markdown_bug
+ markdown_manual_page
+ markdown_uri
+ markdown_hyperlink
+);
+
+use Const::Fast;
+
+const my $EMPTY => q{};
+const my $SPACE => q{ };
+
+=head1 NAME
+
+Lintian::Output::Markdown - Lintian interface for markdown output
+
+=head1 SYNOPSIS
+
+ use Lintian::Output::Markdown;
+
+=head1 DESCRIPTION
+
+Lintian::Output::Markdown provides functions for Markdown output.
+
+=head1 FUNCTIONS
+
+=over 4
+
+=item markdown_citation
+
+=cut
+
+sub markdown_citation {
+ my ($data, $citation) = @_;
+
+ if ($citation =~ m{^ ([\w-]+) \s+ (.+) $}x) {
+
+ my $volume = $1;
+ my $section = $2;
+
+ my $markdown = $data->markdown_authority_reference($volume, $section);
+
+ $markdown ||= $citation;
+
+ return $markdown;
+ }
+
+ if ($citation =~ m{^ ([\w.-]+) [(] (\d\w*) [)] $}x) {
+
+ my $name = $1;
+ my $section = $2;
+
+ return markdown_manual_page($name, $section);
+ }
+
+ if ($citation =~ m{^(?:Bug)?#(\d+)$}) {
+
+ my $number = $1;
+ return markdown_bug($number);
+ }
+
+ # turn bare file into file uris
+ $citation =~ s{^ / }{file://}x;
+
+ # strip scheme from uri
+ if ($citation =~ s{^ (\w+) : // }{}x) {
+
+ my $scheme = $1;
+
+ return markdown_uri($scheme, $citation);
+ }
+
+ return $citation;
+}
+
+=item markdown_authority
+
+=cut
+
+sub markdown_authority {
+ my ($volume_title, $volume_url, $section_key, $section_title,$section_url)
+ = @_;
+
+ my $directed_link;
+ $directed_link = markdown_hyperlink($section_title, $section_url)
+ if length $section_title
+ && length $section_url;
+
+ my $pointer;
+ if (length $section_key) {
+
+ if ($section_key =~ /^[A-Z]+$/ || $section_key =~ /^appendix-/) {
+ $pointer = "Appendix $section_key";
+
+ } elsif ($section_key =~ /^\d+$/) {
+ $pointer = "Chapter $section_key";
+
+ } else {
+ $pointer = "Section $section_key";
+ }
+ }
+
+ # overall manual.
+ my $volume_link = markdown_hyperlink($volume_title, $volume_url);
+
+ if (length $directed_link) {
+
+ return "$directed_link ($pointer) in the $volume_title"
+ if length $pointer;
+
+ return "$directed_link in the $volume_title";
+ }
+
+ return "$pointer of the $volume_link"
+ if length $pointer;
+
+ return "the $volume_link";
+}
+
+=item markdown_bug
+
+=cut
+
+sub markdown_bug {
+ my ($number) = @_;
+
+ return markdown_hyperlink("Bug#$number","https://bugs.debian.org/$number");
+}
+
+=item markdown_manual_page
+
+=cut
+
+sub markdown_manual_page {
+ my ($name, $section) = @_;
+
+ my $url
+ ="https://manpages.debian.org/cgi-bin/man.cgi?query=$name&amp;sektion=$section";
+ my $hyperlink = markdown_hyperlink("$name($section)", $url);
+
+ return "the $hyperlink manual page";
+}
+
+=item markdown_uri
+
+=cut
+
+sub markdown_uri {
+ my ($scheme, $locator) = @_;
+
+ my $url = "$scheme://$locator";
+
+ # use plain path as label for files
+ return markdown_hyperlink($locator, $url)
+ if $scheme eq 'file';
+
+ # or nothing for everything else
+ return markdown_hyperlink($EMPTY, $url);
+}
+
+=item markdown_hyperlink
+
+=cut
+
+sub markdown_hyperlink {
+ my ($text, $url) = @_;
+
+ return $text
+ unless length $url;
+
+ return "<$url>"
+ unless length $text;
+
+ return "[$text]($url)";
+}
+
+=back
+
+=head1 AUTHOR
+
+Originally written by Felix Lechner <felix.lechner@lease-up.com> for Lintian.
+
+=head1 SEE ALSO
+
+lintian(1)
+
+=cut
+
+1;
+
+# Local Variables:
+# indent-tabs-mode: nil
+# cperl-indent-level: 4
+# End:
+# vim: syntax=perl sw=4 sts=4 sr et
diff --git a/lib/Lintian/Output/Universal.pm b/lib/Lintian/Output/Universal.pm
new file mode 100644
index 0000000..a16da49
--- /dev/null
+++ b/lib/Lintian/Output/Universal.pm
@@ -0,0 +1,151 @@
+# Copyright (C) 2019-2021 Felix Lechner
+#
+# This program is free software; you can redistribute it and/or modify
+# it under the terms of the GNU General Public License as published by
+# the Free Software Foundation; either version 2 of the License, or
+# (at your option) any later version.
+#
+# This program is distributed in the hope that it will be useful,
+# but WITHOUT ANY WARRANTY; without even the implied warranty of
+# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+# GNU General Public License for more details.
+#
+# You should have received a copy of the GNU General Public License
+# along with this program. If not, you can find it on the World Wide
+# Web at https://www.gnu.org/copyleft/gpl.html, or write to the Free
+# Software Foundation, Inc., 51 Franklin St, Fifth Floor, Boston,
+# MA 02110-1301, USA.
+
+package Lintian::Output::Universal;
+
+use v5.20;
+use warnings;
+use utf8;
+
+use Carp;
+use Const::Fast;
+use List::SomeUtils qw(all);
+use Unicode::UTF8 qw(encode_utf8);
+
+const my $SPACE => q{ };
+const my $COLON => q{:};
+const my $LEFT_PARENTHESIS => q{(};
+const my $RIGHT_PARENTHESIS => q{)};
+
+use Moo;
+use namespace::clean;
+
+=head1 NAME
+
+Lintian::Output::Universal -- Facilities for printing universal hints
+
+=head1 SYNOPSIS
+
+ use Lintian::Output::Universal;
+
+=head1 DESCRIPTION
+
+A class for printing hints using the 'universal' format.
+
+=head1 INSTANCE METHODS
+
+=over 4
+
+=item issue_hints
+
+Passing all groups with all processables in case no hints were found.
+
+=cut
+
+sub issue_hints {
+ my ($self, $profile, $groups) = @_;
+
+ for my $group (@{$groups // []}) {
+
+ my @by_group;
+ for my $processable ($group->get_processables) {
+
+ for my $hint (@{$processable->hints}) {
+
+ my $line
+ = $processable->name
+ . $SPACE
+ . $LEFT_PARENTHESIS
+ . $processable->type
+ . $RIGHT_PARENTHESIS
+ . $COLON
+ . $SPACE
+ . $hint->tag_name;
+
+ $line .= $SPACE . $hint->context
+ if length $hint->context;
+
+ push(@by_group, $line);
+ }
+ }
+
+ my @sorted
+ = reverse sort { order($a) cmp order($b) } @by_group;
+
+ say encode_utf8($_) for @sorted;
+ }
+
+ return;
+}
+
+=item order
+
+=cut
+
+sub order {
+ my ($line) = @_;
+
+ return package_type($line) . $line;
+}
+
+=item package_type
+
+=cut
+
+sub package_type {
+ my ($line) = @_;
+
+ my (undef, $type, undef, undef) = parse_line($line);
+ return $type;
+}
+
+=item parse_line
+
+=cut
+
+sub parse_line {
+ my ($line) = @_;
+
+ my ($package, $type, $name, $details)
+ = $line =~ qr/^(\S+)\s+\(([^)]+)\):\s+(\S+)(?:\s+(.*))?$/;
+
+ croak encode_utf8("Cannot parse line $line")
+ unless all { length } ($package, $type, $name);
+
+ return ($package, $type, $name, $details);
+}
+
+=back
+
+=head1 AUTHOR
+
+Originally written by Felix Lechner <felix.lechner@lease-up.com> for Lintian.
+
+=head1 SEE ALSO
+
+lintian(1)
+
+=cut
+
+1;
+
+# Local Variables:
+# indent-tabs-mode: nil
+# cperl-indent-level: 4
+# End:
+# vim: syntax=perl sw=4 sts=4 sr et