Calculator with Roman numbers using Raku Grammars

Using Raku grammar, I created a simple calculator that works with Roman numbers, for example: `XXI + MCMXIX`.

The second task of Weekly Challenge 227 is an interesting problem to create a simple calculator, which will work with Roman numbers.

Write a script to handle a 2-term arithmetic operation expressed in Roman numeral.

Example

IV + V     => IX
M - I      => CMXCIX
X / II     => V
XI * VI    => LXVI
VII ** III => CCCXLIII
V - V      => nulla (they knew about zero but didn't have a symbol)
V / II     => non potest (they didn't do fractions)
MMM + M    => non potest (they only went up to 3999)
V - X      => non potest (they didn't do negative numbers)

My first reaction is to use Raku’s grammars. And I have prepared the fundamentals for solving this kind of tasks already, namely:

Please refer to the materials above for the details, but in brief, the idea of converting any given Roman number to its decimal value is to use a grammar that parses it and adds up to the result based on what it sees.

A Roman number is a sequence of patterns that represent thousands, hundreds, tens, and ones. So, here is the modified grammar from one of the above posts:

grammar RomanArithmetics {

    . . .

    token roman-number {
        <thousands>? <hundreds>? <tens>? <ones>? {
            $/.make(
                ($<thousands>.made // 0) +
                ($<hundreds>.made  // 0) +
                ($<tens>.made      // 0) +
                ($<ones>.made      // 0)
            )
        }
    }

    token thousands {
        | M    { $/.make(1000) }   | MM   { $/.make(2000) }
        | MMM  { $/.make(3000) }   | MMMM { $/.make(4000) }
    }

    token hundreds {
        | C    { $/.make(100) }    | CC   { $/.make(200) }
        | CCC  { $/.make(300) }    | CD   { $/.make(400) }
        | D    { $/.make(500) }    | DC   { $/.make(600) }
        | DCC  { $/.make(700) }    | DCCC { $/.make(800) }
        | CM   { $/.make(900) }
    }

    token tens {
        | X    { $/.make(10) }     | XX   { $/.make(20) }
        | XXX  { $/.make(30) }     | XL   { $/.make(40) }
        | L    { $/.make(50) }     | LX   { $/.make(60) }
        | LXX  { $/.make(70) }     | LXXX { $/.make(80) }
        | XC   { $/.make(90) }
    }

    token ones {
        | I    { $/.make(1) }      | II   { $/.make(2) }
        | III  { $/.make(3) }      | IV   { $/.make(4) }
        | V    { $/.make(5) }      | VI   { $/.make(6) }
        | VII  { $/.make(7) }      | VIII { $/.make(8) }
        | IX   { $/.make(9) }
    }
}

In terms of grammar, a Roman number is <thousands>? <hundreds>? <tens>? <ones>, where each part is optional. To collect the decimal value, I am using the AST to pass an integer value to the next level.

For example, for the number XXI our grammar will find two tokens: XX and I, which are converted to 20 and 1. At the top level, these partial values are summed up together to get 21.

As we need a basic calculator, let’s add the corresponding rules directly to the RomanArithmetics grammar:

grammar RomanArithmetics {
    rule TOP {
        <roman-number> <op> <roman-number> {
            my $n1 = $<roman-number>[0].made;
            my $n2 = $<roman-number>[1].made;

            my $n;
            given ~$<op> {
                when '+'  {$n = $n1 +  $n2}
                when '-'  {$n = $n1 -  $n2}
                when '*'  {$n = $n1 *  $n2}
                when '/'  {$n = $n1 /  $n2}
                when '**' {$n = $n1 ** $n2}
            }

            $/.make($n)
        }
    }

    token op {
        '+' | '-' | '*' | '/' | '**'
    }

    . . .
}

Here, the TOP rule expects a string consisting of two Roman numbers with an operation symbol op between them. Value computation happens immediately in the inline actions such as $n = $n1 + $n2.

The main part of the program is done. What remains is the opposite conversion to print the result and a straightforward set of tests to print an error message if the result cannot be represented with a Roman number.

First, the reverse convertion:

sub to-roman($n is copy) {
    state @roman = 
        1000 => < M MM MMM >,
        100 => < C CC CCC CD D DC DCC DCCC CM >,
        10  => < X XX XXX XL L LX LXX LXXX XC >,
        1   => < I II III IV V VI VII VIII IX >;

    my $roman;

    for @roman -> $x {
        my $digit = ($n / $x.key).Int; 
        $roman ~= $x.value[$digit - 1] if $digit;
        $n %= $x.key;
    }

    return $roman;
}

And finally, the function that refer to the grammar and prints the result.

sub compute($input) {
    my $answer = RomanArithmetics.parse($input).made;

    my $output = "$input => ($answer) ";

    if $answer != $answer.round {
        $output ~= "non potest (they didn't do fractions)";
    }
    elsif $answer >= 4000 {
        $output ~= "non potest (they only went up to 3999)";
    }
    elsif $answer == 0 {
        $output ~= "nulla (they knew about zero but didn't have a symbol)";
    }
    elsif $answer < 0 {
        $output ~= "non potest (they didn't do negative numbers)";
    }
    else {
        $output ~= to-roman($answer);
    }

    return $output;
}

To test the program, let us equip it with the test cases from the problem description and call them one by one:

my @test-cases =
    'IV + V',
    'M - I',
    'X / II',
    'XI * VI',
    'VII ** III',
    'V - V',
    'V / II',
    'MMM + M',
    'V - X'
;

say compute($_) for @test-cases;

The program prints the following. I also added decimal value to the output so that we can see why each of the error messages was chosen.

$ raku ch-2.raku
IV + V => (9) IX
M - I => (999) CMXCIX
X / II => (5) V
XI * VI => (66) LXVI
VII ** III => (343) CCCXLIII
V - V => (0) nulla (they knew about zero but didn't have a symbol)
V / II => (2.5) non potest (they didn't do fractions)
MMM + M => (4000) non potest (they only went up to 3999)
V - X => (-5) non potest (they didn't do negative numbers)

Leave a Reply

Your email address will not be published. Required fields are marked *

Retype the CAPTCHA code from the image
Change the CAPTCHA codeSpeak the CAPTCHA code