A SimpleX Chat bot that answers arithmetic (closes #1)
check / check (push) Successful in 54s
check / check (push) Successful in 54s
Remove the template's HTTP service, database and fx wiring. Add exact arithmetic on go/parser and go/constant, a client that runs simplex-chat as a child process and drives its WebSocket API, and the bot, which keeps an auto-accepting address and replies to each message. The image adds the checksum-pinned simplex-chat v7.0.2 on Ubuntu 22.04. Model: opus-5-5
This commit is contained in:
@@ -0,0 +1,181 @@
|
||||
// Package calc evaluates the arithmetic people send the bot: decimal
|
||||
// numbers, + - * /, unary minus and parentheses.
|
||||
//
|
||||
// The expression is parsed by go/parser and computed by go/constant,
|
||||
// which does exact rational arithmetic: 5 * 5/2 is exactly 12.5, and
|
||||
// 0.1 + 0.2 is exactly 0.3, so a result carries no binary floating
|
||||
// point noise until the moment it is formatted.
|
||||
package calc
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"go/ast"
|
||||
"go/constant"
|
||||
"go/parser"
|
||||
"go/token"
|
||||
"math"
|
||||
"regexp"
|
||||
"strconv"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// MaxInputLength caps an expression, in bytes, so a message cannot make
|
||||
// the bot do unbounded work. Every operation's cost grows with the size
|
||||
// of its operands, and the operands can only grow with the input.
|
||||
const MaxInputLength = 256
|
||||
|
||||
// Results of magnitude plainUpper or more are written in exponent form
|
||||
// (1e+21 rather than twenty-two digits), and so are fractions smaller
|
||||
// than plainLower (1e-07 rather than 0.0000001).
|
||||
const (
|
||||
plainUpper = 1e21
|
||||
plainLower = 1e-6
|
||||
)
|
||||
|
||||
// Errors returned by Evaluate. The bot turns each into a reply.
|
||||
var (
|
||||
ErrTooLong = errors.New("expression too long")
|
||||
ErrNotArithmetic = errors.New("not an arithmetic expression")
|
||||
ErrDivisionByZero = errors.New("division by zero")
|
||||
ErrTooLarge = errors.New("result too large")
|
||||
)
|
||||
|
||||
// decimalLiteral is the only number syntax accepted. Go's own literal
|
||||
// syntax is wider, and parts of it are traps for someone typing
|
||||
// arithmetic: 010 is octal 8, and 0x10, 1_000 and 1i are not what a
|
||||
// calculator user means by a number.
|
||||
var decimalLiteral = regexp.MustCompile(
|
||||
`^([0-9]+\.?[0-9]*|\.[0-9]+)([eE][+-]?[0-9]+)?$`,
|
||||
)
|
||||
|
||||
// Evaluate computes an arithmetic expression and returns its result as
|
||||
// text: whole numbers without a decimal point, fractions in the
|
||||
// shortest form that reads back as the same float64.
|
||||
func Evaluate(input string) (string, error) {
|
||||
s := strings.TrimSpace(input)
|
||||
if len(s) > MaxInputLength {
|
||||
return "", ErrTooLong
|
||||
}
|
||||
|
||||
if s == "" {
|
||||
return "", ErrNotArithmetic
|
||||
}
|
||||
|
||||
expr, err := parser.ParseExpr(s)
|
||||
if err != nil {
|
||||
return "", ErrNotArithmetic
|
||||
}
|
||||
|
||||
v, err := eval(expr)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
return format(v)
|
||||
}
|
||||
|
||||
// eval walks the syntax tree, allowing only the node types and
|
||||
// operators of arithmetic. Anything else — identifiers, calls, strings,
|
||||
// shifts, comparisons — is refused, not evaluated.
|
||||
func eval(e ast.Expr) (constant.Value, error) {
|
||||
switch n := e.(type) {
|
||||
case *ast.BasicLit:
|
||||
return literal(n)
|
||||
case *ast.ParenExpr:
|
||||
return eval(n.X)
|
||||
case *ast.UnaryExpr:
|
||||
if n.Op != token.ADD && n.Op != token.SUB {
|
||||
return nil, ErrNotArithmetic
|
||||
}
|
||||
|
||||
x, err := eval(n.X)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return constant.UnaryOp(n.Op, x, 0), nil
|
||||
case *ast.BinaryExpr:
|
||||
return binary(n)
|
||||
default:
|
||||
return nil, ErrNotArithmetic
|
||||
}
|
||||
}
|
||||
|
||||
func binary(n *ast.BinaryExpr) (constant.Value, error) {
|
||||
switch n.Op { //nolint:exhaustive // every other operator is refused.
|
||||
case token.ADD, token.SUB, token.MUL, token.QUO:
|
||||
default:
|
||||
return nil, ErrNotArithmetic
|
||||
}
|
||||
|
||||
x, err := eval(n.X)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
y, err := eval(n.Y)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// constant.BinaryOp panics on a zero divisor.
|
||||
if n.Op == token.QUO && constant.Sign(y) == 0 {
|
||||
return nil, ErrDivisionByZero
|
||||
}
|
||||
|
||||
// token.QUO divides exactly, integers included: 25/2 is 12.5.
|
||||
v := constant.BinaryOp(x, n.Op, y)
|
||||
|
||||
// go/constant represents an overflow to infinity as Unknown.
|
||||
if v.Kind() == constant.Unknown {
|
||||
return nil, ErrTooLarge
|
||||
}
|
||||
|
||||
return v, nil
|
||||
}
|
||||
|
||||
func literal(n *ast.BasicLit) (constant.Value, error) {
|
||||
if n.Kind != token.INT && n.Kind != token.FLOAT {
|
||||
return nil, ErrNotArithmetic
|
||||
}
|
||||
|
||||
if !decimalLiteral.MatchString(n.Value) {
|
||||
return nil, ErrNotArithmetic
|
||||
}
|
||||
|
||||
// Read as FLOAT whatever the token says, which makes every literal
|
||||
// decimal: as INT, a leading zero would make it octal.
|
||||
v := constant.MakeFromLiteral(n.Value, token.FLOAT, 0)
|
||||
|
||||
// The syntax was checked above, so Unknown here means the exponent
|
||||
// overflowed.
|
||||
if v.Kind() == constant.Unknown {
|
||||
return nil, ErrTooLarge
|
||||
}
|
||||
|
||||
return v, nil
|
||||
}
|
||||
|
||||
// format writes a result for a person to read. A whole number of
|
||||
// ordinary size is written exactly, digit for digit; anything else goes
|
||||
// through float64, whose shortest round-trip form is free of the noise
|
||||
// (0.30000000000000004) that printing a binary fraction to a fixed
|
||||
// precision produces.
|
||||
func format(v constant.Value) (string, error) {
|
||||
f, _ := constant.Float64Val(v)
|
||||
if math.IsInf(f, 0) || math.IsNaN(f) {
|
||||
return "", ErrTooLarge
|
||||
}
|
||||
|
||||
abs := math.Abs(f)
|
||||
|
||||
if i := constant.ToInt(v); i.Kind() == constant.Int && abs < plainUpper {
|
||||
return i.ExactString(), nil
|
||||
}
|
||||
|
||||
if abs >= plainUpper || abs < plainLower {
|
||||
return strconv.FormatFloat(f, 'g', -1, 64), nil
|
||||
}
|
||||
|
||||
return strconv.FormatFloat(f, 'f', -1, 64), nil
|
||||
}
|
||||
@@ -0,0 +1,150 @@
|
||||
package calc_test
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"sneak.berlin/go/simplexcalc/internal/calc"
|
||||
)
|
||||
|
||||
// TestEvaluate covers the two examples the bot was specified with, and
|
||||
// the arithmetic around them.
|
||||
func TestEvaluate(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
cases := map[string]string{
|
||||
// The specification's own examples.
|
||||
"2 + 2": "4",
|
||||
"5 * 5/2": "12.5",
|
||||
|
||||
"2+2": "4",
|
||||
" 7 - 10 \n": "-3",
|
||||
"-3 * 2": "-6",
|
||||
"+4": "4",
|
||||
"-(-4)": "4",
|
||||
"(1 + 2) * 3": "9",
|
||||
"1 + 2 * 3": "7",
|
||||
"((2))": "2",
|
||||
"8 / 2 / 2": "2",
|
||||
"10 - 2 - 3": "5",
|
||||
"7 / 2": "3.5",
|
||||
"25/2": "12.5",
|
||||
"1 / 3": "0.3333333333333333",
|
||||
"2 / 3": "0.6666666666666666",
|
||||
"0.1 + 0.2": "0.3",
|
||||
"1.5 * 2": "3",
|
||||
"2.50 * 2": "5",
|
||||
".5 + .5": "1",
|
||||
"3. * 2": "6",
|
||||
"1e3 + 1": "1001",
|
||||
"2.5e-1": "0.25",
|
||||
"010 + 1": "11",
|
||||
"-0": "0",
|
||||
"0 / 5": "0",
|
||||
// Exact: a float64 would print 99999999980000000000.
|
||||
"9999999999 * 9999999999": "99999999980000000001",
|
||||
"1e21": "1e+21",
|
||||
"1e20": "100000000000000000000",
|
||||
"1 / 1e7": "1e-07",
|
||||
"1 / 1e6": "0.000001",
|
||||
"1234567.5": "1234567.5",
|
||||
"-1 / 4": "-0.25",
|
||||
"1e300 * 1e8": "1e+308",
|
||||
}
|
||||
|
||||
for in, want := range cases {
|
||||
t.Run(in, func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
got, err := calc.Evaluate(in)
|
||||
if err != nil {
|
||||
t.Fatalf("Evaluate(%q) failed: %v", in, err)
|
||||
}
|
||||
|
||||
if got != want {
|
||||
t.Errorf("Evaluate(%q) = %q, want %q", in, got, want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestEvaluateRefuses covers what must be answered with an error rather
|
||||
// than a number, and never with a panic.
|
||||
func TestEvaluateRefuses(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
cases := map[string]error{
|
||||
"": calc.ErrNotArithmetic,
|
||||
" ": calc.ErrNotArithmetic,
|
||||
"hello": calc.ErrNotArithmetic,
|
||||
"/help": calc.ErrNotArithmetic,
|
||||
"2 +": calc.ErrNotArithmetic,
|
||||
"2 2": calc.ErrNotArithmetic,
|
||||
"2 + 2 =": calc.ErrNotArithmetic,
|
||||
"x + 1": calc.ErrNotArithmetic,
|
||||
"len(\"abc\")": calc.ErrNotArithmetic,
|
||||
"\"a\" + \"b\"": calc.ErrNotArithmetic,
|
||||
"'a' + 1": calc.ErrNotArithmetic,
|
||||
"2i * 2i": calc.ErrNotArithmetic,
|
||||
"0x10 + 1": calc.ErrNotArithmetic,
|
||||
"1_000 + 1": calc.ErrNotArithmetic,
|
||||
"7 % 2": calc.ErrNotArithmetic,
|
||||
"2 ^ 3": calc.ErrNotArithmetic,
|
||||
"1 << 10": calc.ErrNotArithmetic,
|
||||
"1 == 1": calc.ErrNotArithmetic,
|
||||
"!1": calc.ErrNotArithmetic,
|
||||
"func() int { return 1 }()": calc.ErrNotArithmetic,
|
||||
"1 / 0": calc.ErrDivisionByZero,
|
||||
"1 / (2 - 2)": calc.ErrDivisionByZero,
|
||||
"5 / 0.0": calc.ErrDivisionByZero,
|
||||
"1e400": calc.ErrTooLarge,
|
||||
"1e300 * 1e300": calc.ErrTooLarge,
|
||||
"1e999999999 * 1e999999999": calc.ErrTooLarge,
|
||||
"1 / 1e-400": calc.ErrTooLarge,
|
||||
}
|
||||
|
||||
for in, want := range cases {
|
||||
t.Run(in, func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
got, err := calc.Evaluate(in)
|
||||
if !errors.Is(err, want) {
|
||||
t.Errorf("Evaluate(%q) = %q, %v; want error %v", in, got, err, want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestEvaluateCapsInput: the length cap is what bounds the work a
|
||||
// message can cause, so it must hold exactly at the boundary.
|
||||
func TestEvaluateCapsInput(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
// "1+1+...+1" with the last term padded to land exactly on the cap.
|
||||
longest := strings.Repeat("1+", calc.MaxInputLength/2-1) + "10"
|
||||
if len(longest) != calc.MaxInputLength {
|
||||
t.Fatalf("test setup: expression is %d bytes, want %d",
|
||||
len(longest), calc.MaxInputLength)
|
||||
}
|
||||
|
||||
got, err := calc.Evaluate(longest)
|
||||
if err != nil {
|
||||
t.Fatalf("an expression of exactly MaxInputLength bytes was refused: %v", err)
|
||||
}
|
||||
|
||||
if want := "137"; got != want {
|
||||
t.Errorf("Evaluate(longest) = %q, want %q", got, want)
|
||||
}
|
||||
|
||||
_, err = calc.Evaluate(longest + "0")
|
||||
if !errors.Is(err, calc.ErrTooLong) {
|
||||
t.Errorf("an expression over MaxInputLength gave %v, want ErrTooLong", err)
|
||||
}
|
||||
|
||||
// Surrounding whitespace is not part of the expression.
|
||||
_, err = calc.Evaluate(" " + longest + "\n")
|
||||
if err != nil {
|
||||
t.Errorf("whitespace around a maximal expression counted against the cap: %v", err)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user