I assume that '?' appears at the end of the left side of the equality and that the equation has a solution (the equation is not, for example, ('0 * ? = 1'). Both of these assumptions could be relaxed at the expense of somewhat more complex code. I will leave it to the reader to make those adjustments if desired.
We can write the main method as follows.
def calc(str)
str = str.gsub(/[ =]/, '')
(str = str.insert(0,'+')) if str.match?(/\A\d/)
left_str, s, right_str = str.partition(/[\+\-\/\*]\?/)
qop = s[0]
left_str = reduce_left(left_str)
coeff, right_val = shift_to_right(left_str, right_str.to_f)
solve(coeff, right_val, qop)
end
For example,
calc "3 * 7 / 3 + 4 / 2 * ? = 12"
#=> 2.5
calc "2 - 4 / 2 + 8 / 2 + ? = 20"
#=> 16.0
Suppose
str = "3 * 7 / 3 + 4 / 2 * ? = 12"
The first two steps are to adjust the formatting to simplify the following operations.
str = str.gsub(/[ =]/, '')
#=> "3*7/3+4/2*?12"
(str = str.insert(0,'+')) if str.match?(/\A\d/)
#=> "+3*7/3+4/2*?12"
Now we break the string into three pieces.
left_str, s, right_str = str.partition(/[\+\-\/\*]\?/)
#=> ["+3*7/3+4/2", "*?", "12"]
so
left_str
#=> "+3*7/3+4/2"
s #=> "*?"
right_str
#=> "12"
and save for later:
qop = s[0]
#=> "*"
The three regular expressions read as follows.
/[ =]/: match a space or equals sign
/\A\d/: match a digit at the beginning of the string
/[\+\-\/\*]\?/: match a '+', '-', '/' or '*'
The next step is to remove '*' and '/' from left_str. We can do this iteratively until none remain. The following method removes one. This uses the following regular expression:
R = /(\d+(?:\.\d+)?)([\*\/])(\d+(?:\.\d+)?)/
We can write this regular expression in free-spacing mode to make it self-documenting.
R = /
( # begin capture group 1
\d+ # match 1+ digits
(?:\.\d+) # match period followed by 1+ digits in a non-capture group
? # make the non-capture group optional
) # end capture group 1
( # begin the capture group 2
[\*\/] # match one character in the character class
) # end capture group 2
( # begin capture group 3
\d+ # match 1+ digits
(?:\.\d+) # match period followed by 1+ digits in a non-capture group
? # make the non-capture group optional
) # end capture group 3
/x # invoke free-spacing regex definition mode
def reduce_left_once(left_str)
left_str.gsub(R) { $1.to_f.public_send($2, $3.to_f) }
end
Note that m is a MatchData object.
We can test (where left_str #=> "+3*7/3+4/2"):
left_str = reduce_left_once(left_str)
#=> "+21.0/3+2.0"
left_str = reduce_left_once(left_str)
#=> "+7.0+2.0"
left_str = reduce_left_once(left_str)
#=> "+7.0+2.0"
As no change was made in the last step we are finished with this operation. This can be operationalized with the following method.
def reduce_left(left_str)
loop do
new_left_str = reduce_left_once(left_str)
break left_str if left_str == new_left_str
left_str = new_left_str
end
end
left_str = "+3*7/3+4/2"
left_str = reduce_left(left_str)
#=> "+7.0+2.0"
The next step is to shift all terms but the last in left_str to the right of the equality and adjust the right side accordingly. If qop equals '*' or '/' the last term of left_str is '?''s coefficient; else it will be shifted to the right in the last step.
def shift_to_right(left_str, right_val)
*terms, coeff = left_str.scan(/[\+\-]\d+/)
terms.each { |s| right_val -= s.to_f }
[coeff.to_f, right_val]
end
left_str = "+7.0+2.0"
right_str = "12"
coeff, right_val = shift_to_right(left_str, right_str.to_f)
#=> [2.0, 5.0]
So
coeff
#=> 2.0
right_val
#=> 5.0
At this point we have reduced the original expression to
2.0 * x = 5.0
and need to solve for x.
The regular expression /[\+\-]\d+/ in shift_to_right matches '+' or '-' followed by one or more digits.
The last step is to solve this simple linear equation. Recall that we have computed right_val #=> "12" and '?''s operator is given by qop #=> "*".
def solve(coeff, right_val, qop)
case qop
when '+' then right_val - coeff
when '-' then coeff - right_val
when '*' then right_val/coeff
else right_val * coeff
end
end
solve(coeff, right_val, qop)
#=> 2.5
Note that most of these operations would be needed if eval were used.