#! /usr/bin/perl # The copyright notice and license are in the POD at the bottom. use strict; use warnings; use Locale::PO qw(); use Getopt::Long qw(GetOptions :config bundling gnu_compat); use autouse 'Pod::Usage' => qw(pod2usage); my $VERSION = "1.6"; sub show_version { print "msgaccel-check $VERSION\n"; pod2usage({-verbose => 99, -sections => "COPYRIGHT AND LICENSE", -exitval => 0}); } # The character that precedes accelerators in strings. # Set with the --accelerator-tag=CHARACTER option. my $Opt_accelerator_tag; # True if, for missing or fuzzy translations, the msgid string should # be checked instead of msgstr. Set with the --msgid-fallback and # --no-msgid-fallback options. my $Opt_msgid_fallback = 1; sub acceleration_arrays_eq ($$) { my($left, $right) = @_; ref($left) eq "ARRAY" or return 0; ref($right) eq "ARRAY" or return 0; @$left == @$right or return 0; $left->[$_] eq $right->[$_] or return 0 foreach (0 .. $#$right); return 1; } sub check_po_file ($) { # The name of the PO file to be checked. my($po_file_name) = @_; # A nested hash that lists the accelerators and their uses. # $accelerators{$accelerator}{$ctxname}{ACCELERATIONS}[$i]{LINENO} # 1. In %accelerators, the keys are one-character strings, # and the values are hash references. # 2. In %{$accelerators{$accelerator}}, the keys are names # of contexts in which the accelerator is used, and the # values are references to "crossing" hashes. # 3. %{$accelerators{$accelerator}{$ctxname}} is a "crossing" hash, # so named because it describes how an accelerator and a context # cross each other. It has the following elements: # (ACCELERATIONS => [see point 4 below], # REPORTED => (1 if this is a conflict and has been reported), # AVOID => (1 if this accelerator should not be suggested in this # context)) # 4. @{$accelerators{$accelerator}{$ctxname}{ACCELERATIONS}} is a list of # references to "acceleration" hashes. If the same acceleration occurs # in multiple contexts, then the references are to the same hash. # 5. %{$accelerators{$accelerator}{ctxname}{ACCELERATIONS}[$i]} is an # "acceleration" hash. It has the following structure: # (PO => (the Locale::PO object), # CTXNAMES => [read-only list of names of contexts in which the # accelerator is used], # ACCELERATOR => (a one-character string), # LINENO => (line number in the PO file), # STRING => (the msgid or msgstr string that defines the accelerator; # unquoted as much as possible), # EXPLAIN => (a string to be displayed if a conflict is found)) my %accelerators; # How many entries had checkable accelerators. my $checkable_count = 0; # How many conflicts have been found so far. my $conflict_count = 0; { my $pos = Locale::PO->load_file_asarray($po_file_name) or warn "$po_file_name: $!\n", return 2; foreach my $po (@$pos) { my $automatic = $po->automatic() or next; my($ctxnames) = ($automatic =~ /^accelerator_context\(([^\)]*)\)/) or next; my @ctxnames = split(/\s*,\s*/, $ctxnames); my @accelerations; my $msgid = $po->msgid(); my $msgstr = $po->msgstr(); if ($po->dequote($msgstr) ne "" && !$po->fuzzy()) { if (my($accelerator) = ($msgstr =~ /\Q$Opt_accelerator_tag\E(.)/s)) { push @accelerations, { PO => $po, CTXNAMES => \@ctxnames, ACCELERATOR => $accelerator, LINENO => $po->msgstr_begin_lineno(), STRING => $msgstr, EXPLAIN => "msgstr $msgstr" }; } # TODO: look for accelerators in plural forms? } elsif ($Opt_msgid_fallback && $po->dequote($msgid) ne "") { if (my($accelerator) = ($msgid =~ /\Q$Opt_accelerator_tag\E(.)/s)) { push @accelerations, { PO => $po, CTXNAMES => \@ctxnames, ACCELERATOR => $accelerator, LINENO => $po->msgid_begin_lineno(), STRING => $msgid, EXPLAIN => "msgid $msgid" }; } } $checkable_count++ if @accelerations; foreach my $acceleration (@accelerations) { my $accelerator = uc($acceleration->{ACCELERATOR}); foreach my $crossing (@{$accelerators{$accelerator}}{@ctxnames}) { push @{$crossing->{ACCELERATIONS}}, $acceleration; $crossing->{AVOID} = 1 if $acceleration->{EXPLAIN} =~ /^msgstr/; } } } } foreach my $accelerator (sort keys %accelerators) { my $ctxhash = $accelerators{$accelerator}; foreach my $crossing (@{$ctxhash}{sort keys %$ctxhash}) { my $accelerations = $crossing->{ACCELERATIONS}; if ($accelerations && @$accelerations > 1 && !$crossing->{REPORTED}) { my @ctxnames_in_conflict; foreach my $inner_ctxname (keys %$ctxhash) { my $inner_crossing = $ctxhash->{$inner_ctxname}; if (acceleration_arrays_eq($inner_crossing->{ACCELERATIONS}, $accelerations)) { push @ctxnames_in_conflict, $inner_ctxname; $inner_crossing->{REPORTED} = 1; } } my $ctxnames_in_conflict = join(", ", map(qq("$_"), @ctxnames_in_conflict)); warn "$po_file_name: Accelerator conflict for \"$accelerator\" in $ctxnames_in_conflict:\n"; foreach my $acceleration (@$accelerations) { my $lineno = $acceleration->{LINENO}; my $explain = $acceleration->{EXPLAIN}; # Get a string of unique characters in the string, # preferring characters that start a word. # FIXME: should remove quotes and resolve \n etc. my $displaystr = $acceleration->{STRING}; $displaystr =~ s/\Q$Opt_accelerator_tag\E//g; my $suggestions = ""; foreach my $char ($displaystr =~ /\b(\w)/g, $displaystr =~ /(\w)/g) { $suggestions .= $char unless $suggestions =~ /\Q$char\E/i; } # But don't suggest unavailable characters. SUGGESTION: foreach my $char (split(//, $suggestions)) { foreach my $ctxname (@{$acceleration->{CTXNAMES}}) { $suggestions =~ s/\Q$char\E//, next SUGGESTION if $accelerators{uc($char)}{$ctxname}{AVOID}; } } warn "$po_file_name:$lineno: $explain\n"; if ($suggestions eq "") { warn "$po_file_name:$lineno: no suggestions :-(\n"; } else { warn "$po_file_name:$lineno: suggestions: $suggestions\n"; } } # foreach $acceleration in conflict $conflict_count++; } # if found a conflict } # foreach context known for $accelerator } # foreach $accelerator print "$po_file_name: " . ($checkable_count == 1 ? "Checked 1 entry" : "Checked $checkable_count entries") . ", " . ($conflict_count == 1 ? "found 1 accelerator conflict" : "found $conflict_count accelerator conflicts") . ".\n"; return $conflict_count ? 1 : 0; } GetOptions("accelerator-tag=s" => sub { my($option, $value) = @_; die "Cannot use multiple --accelerator-tag options\n" if defined($Opt_accelerator_tag); die "--accelerator-tag requires a single-character argument\n" if length($value) != 1; $Opt_accelerator_tag = $value; }, "msgid-fallback!" => \$Opt_msgid_fallback, "help" => sub { pod2usage({-verbose => 1, -exitval => 0}) }, "version" => \&show_version) or exit 2; $Opt_accelerator_tag = "~" unless defined $Opt_accelerator_tag; print(STDERR "$0: missing file operand\n"), exit 2 unless @ARGV; my $max_error = 0; foreach my $po_file_name (@ARGV) { my $error = check_po_file($po_file_name); $max_error = $error if $error > $max_error; } exit $max_error; __END__ =head1 NAME msgaccel-check - Scan a PO file for conflicting accelerator keys. =head1 SYNOPSIS B [I