2015 twenty-four merry days of Perl Feed

Improving User Messages

Lingua::EN::Inflexion - 2015-12-05

Better Messages Please

The new product owner was determined to make an impression on the site as quickly as possible, so she produced a list of "quick wins" for us to work on. Number one on her list was the quality of our error messages.

Like pretty much every piece of software, our error messages suck. When users enter data, we validate it and tell them if there were any problems. We display messages like "1 error(s) were found". It's down to programmer laziness of course. And the wrong kind of laziness at that. But it seemed to be a simple enough thing to fix.

I moved the ticket into "in progress" and reached for Lingua::EN::Inflect, only to find something interesting: Lingua::EN::Inflect is now in maintenance mode and has been superceeded by a new module called Lingua::EN::Inflexion. The notice in Lingua::EN::Inflect says that the new module "offers a cleaner and more convenient interface, has many more features (including plural->singular inflexions), and is also much better tested". It's written by Damian Conway, so those claims seem likely to be accurate. I settled down to read the documentation.

Singulars and Plurals

The problem with the message is that it doesn't know whether it needs to use singular or plural versions of the words in the text. Lingua::EN::Inflexion makes it easy to start with a singular noun and get the plural version.

use Lingua::EN::Inflexion;

my @singulars = qw[cow pig sheep];

foreach (@singulars) {
  say "$_ -> ", noun($_)->plural;
}

Which gives us:

    cow -> cows
    pig -> pigs
    sheep -> sheep

We can also go in the other direction.

use Lingua::EN::Inflexion;

my @plurals = qw[cows pigs sheep];

foreach (@plurals) {
  say "$_ -> ", noun($_)->singular;
}

This outputs:

    cows -> cow
    pigs -> pig
    sheep -> sheep

You can also ask the object whether its invocant was singular or plural.

use Lingua::EN::Inflexion;

my @nouns = qw[cow pigs sheep];

foreach (@nouns) {
  say "$_ is singular" if noun($_)->is_singular;
  say "$_ is plural" if noun($_)->is_plural;
}

This will tell us:

    cow is singular
    pigs is plural
    sheep is singular
    sheep is plural

Notice that, unsurprisingly, "sheep" is both singular and plural.

You can also use the as_regex() to get a regex that matches both the singular and plural versions of the noun.

use Lingua::EN::Inflexion;

my $string = 'Ermintrude is the best of all the cows';

if ($string =~ noun('cow')->as_regex) {
  say $&;
}

$& will contain "cows", not "cow". And it's even cleverer than that.

use Lingua::EN::Inflexion;

my $string = 'Ermintrude is ye best of all ye kine';

if ($string =~ noun('cow')->as_regex) {
  say $&;
}

Because "kine" is an obscure old plural for "cow".

If you print the value returned from noun('cow')->as_regex, you'll see that it is (?^i:kine|cows|cow) - so it's case insensitive too.

Verbs and Adjectives

Lingua::EN::Inflexion doesn't just work on nouns. You can inflect verbs and adjectives too using the verb() and adj() constructors.

my @verbs = qw[is has sits];

for (@verbs) {
  say "The plural of $_ is " . verb($_)->plural;
}

Which produces:

    The plural of is is are
    The plural of has is have
    The plural of sits is sit

And

my @adjectives = qw[my your his];

for (@adjectives) {
  say "The plural of $_ is " . adj($_)->plural;
}

Which gives:

    The plural of my is our
    The plural of your is your
    The plural of his is their

For more complex requirements, you can get the present participle, past tense and past participle of verbs.

my @verbs = qw[is has sits];

for (@verbs) {
  say "The present participle of $_ is " . verb($_)->pres_part;
  say "The past tense of $_ is " . verb($_)->past;
  say "The past participle of $_ is " . verb($_)->past_part. "\n";
}

Which produces:

The present participle of is is being
The past tense of is is was
The past participle of is is been

The present participle of has is having
The past tense of has is had
The past participle of has is had

The present participle of sits is sitting
The past tense of sits is sat
The past participle of sits is sat

Building a Message

So now we have all of the pieces that we need to construct our message. We need to know how many errors were found. Let's assume that's in $count. Then our code looks like this:

my $msg = "$count ";

if ($count == 1) {
  $msg .= noun('error')->singular;
} else {
  $msg .= noun('error')->plural'
}
$msg =. '
';
if ($count == 1) {
  $msg .= verb('
was')->singular;
} else {
  $msg .= verb('
was')->plural'
}
$msg =. ' found';

Now you can start to see why so many systems make do with the terrible messages that we are trying to get rid of here. It's just too complicated to write code like this every time you want to display a message to the user.

You can try to make it a little simpler, I suppose.

my $cardinality = ($count == 1 ? 'singular' : 'plural');
my $msg = "$count "
        . noun('error')->$cardinality
        . ' '
        . verb('was')->$cardinality
        . ' was found';

But it's really not that much better. There has to be a better way. And, of course, that's really why I'm writing this article.

Inflecting Sentences

Lingua::EN::Inflexion exports four routines. We've seen three of them (noun(), verb(), and adj()). The fourth one is called inflect() and that's the one which solves our problem and gives us our "quick win".

The subroutine takes a single string argument, where the string contains some special markup defining how you want the string processed. This string is expanded into a new string which is then returned.

In the simplest case, you would use it like this:

use Lingua::EN::Inflexion;

for (0 .. 3) {
  say inflect("<#:$_> <N:error> <V:was> found");
}

The output is:

    0 errors were found
    1 error was found
    2 errors were found
    3 errors were found

Simply by changing the number that is interpolated into the string, the noun and verb have both been changed appropriately.

We have used three of inflect()'s special mark-up tags here. <#:...> sets and displays the number which will be used in the rest of the output and <N:...> and <V:...> can be used to insert nouns and verbs which will be inflected. There's a fourth tag, <A:...> which can be used for adjectives, as you can see in this (slightly contrived) example.

use Lingua::EN::Inflexion;

for (1 .. 3) {
  say inflect("The report had <#:$_> <N:authors> " .
              "<A:our> recommendations are ... ");
}

Which produces the following output:

    The report had 1 author my recommendations are ... 
    The report had 2 authors our recommendations are ... 
    The report had 3 authors our recommendations are ...

Improving the Output

This is already much better than the output than you get from many programs, but there are easy ways to make it better. We can start by displaying "No" rather than "0". This is achieved by adding an n option to the <#:...> tags. Options are added between the command character (the #, N, V or A) and the colon.

use Lingua::EN::Inflexion;

for (0 .. 3) {
  say inflect("<#n:$_> <N:error> <V:was> found");
}

This gives us:

    no errors were found
    1 error was found
    2 errors were found
    3 errors were found

Some people prefer that zero items are displayed as singular rather than plural. We can accommodate that by using s instead of n.

use Lingua::EN::Inflexion;

for (0 .. 3) {
  say inflect("<#s:$_> <N:error> <V:was> found");
}

The output then changes to:

    no error was found
    1 error was found
    2 errors were found
    3 errors were found

If you would rather have "a" or "an" when the count is one, then you can use a (and you can stack options, so you can use it in addition to either n or s).

use Lingua::EN::Inflexion;

for (0 .. 3) {
  say inflect("<#na:$_> <N:error> <V:was> found");
}

Which gives us:

    no errors were found
    an error was found
    2 errors were found
    3 errors were found

It's quite common to spell out the numbers when the count is small. If you use the w option, then inflect() will do that for numbers up to 10.

use Lingua::EN::Inflexion;

for (0 .. 2, 9 .. 11) {
  say inflect("<#naw:$_> <N:error> <V:was> found");
}

The output is:

    no errors were found
    an error was found
    two errors were found
    nine errors were found
    ten errors were found
    11 errors were found

Finally, you can use f to get fuzzy descriptions of the numbers.

use Lingua::EN::Inflexion;

for (0, 1, 2, 4, 7, 10) {
  say inflect("<#f:$_> <N:error> <V:was> found");
}

Which gives us:

    no errors were found
    one error was found
    a couple of errors were found
    a few errors were found
    several errors were found
    many errors were found

Finally - A Real Quick Win

Users have become used to the "1 error(s) was found" style of message because it is ubiquitous. And that's because fixing the problem isn't exactly hard, it's just tedious. There's too much code to write to fix one small niggle in the user experience. Mostly, we think it's not worth the effort.

But the inflect() subroutine fixes that. Now getting it right is as trivial as it should be. That's the right kind of laziness. Damian was obviously getting bored of writing all of that code every time he wanted a grammatically satisfying user message, so he decided to fix the problem by writing a solution that he (and, through the wonder of CPAN, everyone else) could use to easily produce vastly improved messages.

So our product manager got her quick win. And now yours can too.

And your users will get a much better user experience.

SEE ALSO

Gravatar Image This article contributed by: Dave Cross <dave@perlhacks.com>