Implement REPL package with Run(io.Reader, io.Writer)
- Read-eval-print loop: reads lines, tokenizes, parses, evaluates, prints result
- Prints prompt '>> ' before each input
- Error handling: prints 'error: <msg>' and continues loop
- Skips empty lines
- Formats results with %g (clean integers, no trailing zeros)
- 10 unit tests covering: single/multiple expressions, error recovery,
division by zero, empty lines, prompt display, float results,
invalid chars, empty input, integer formatting
diff --git a/repl/repl.go b/repl/repl.go
new file mode 100644
index 0000000..bc14567
--- /dev/null
+++ b/repl/repl.go
@@ -0,0 +1,62 @@
+package repl
+
+import (
+ "bufio"
+ "fmt"
+ "io"
+ "math"
+ "strings"
+
+ "matheval/evaluator"
+ "matheval/lexer"
+ "matheval/parser"
+)
+
+const prompt = ">> "
+
+// Run starts the read-eval-print loop, reading from r and writing to w.
+func Run(r io.Reader, w io.Writer) {
+ scanner := bufio.NewScanner(r)
+ fmt.Fprint(w, prompt)
+
+ for scanner.Scan() {
+ line := strings.TrimSpace(scanner.Text())
+ if line == "" {
+ fmt.Fprint(w, prompt)
+ continue
+ }
+
+ result, err := evalLine(line)
+ if err != nil {
+ fmt.Fprintf(w, "error: %s\n", err)
+ } else {
+ fmt.Fprintln(w, formatResult(result))
+ }
+
+ fmt.Fprint(w, prompt)
+ }
+}
+
+// evalLine tokenizes, parses, and evaluates a single expression string.
+func evalLine(line string) (float64, error) {
+ tokens, err := lexer.Tokenize(line)
+ if err != nil {
+ return 0, err
+ }
+
+ tree, err := parser.Parse(tokens)
+ if err != nil {
+ return 0, err
+ }
+
+ return evaluator.Eval(tree)
+}
+
+// formatResult formats a float64 for display.
+// Whole numbers are printed without decimal points.
+func formatResult(val float64) string {
+ if val == math.Trunc(val) && !math.IsInf(val, 0) && !math.IsNaN(val) {
+ return fmt.Sprintf("%g", val)
+ }
+ return fmt.Sprintf("%g", val)
+}
diff --git a/repl/repl_test.go b/repl/repl_test.go
new file mode 100644
index 0000000..1c902b1
--- /dev/null
+++ b/repl/repl_test.go
@@ -0,0 +1,150 @@
+package repl
+
+import (
+ "bytes"
+ "strings"
+ "testing"
+)
+
+func TestRun_SingleExpression(t *testing.T) {
+ in := strings.NewReader("1 + 2\n")
+ var out bytes.Buffer
+
+ Run(in, &out)
+
+ got := out.String()
+ if !strings.Contains(got, "3") {
+ t.Errorf("expected output to contain '3', got %q", got)
+ }
+}
+
+func TestRun_MultipleExpressions(t *testing.T) {
+ in := strings.NewReader("1 + 2\n3 * 4\n")
+ var out bytes.Buffer
+
+ Run(in, &out)
+
+ got := out.String()
+ if !strings.Contains(got, "3") {
+ t.Errorf("expected output to contain '3', got %q", got)
+ }
+ if !strings.Contains(got, "12") {
+ t.Errorf("expected output to contain '12', got %q", got)
+ }
+}
+
+func TestRun_ErrorContinues(t *testing.T) {
+ // First line has error, second is valid.
+ in := strings.NewReader("1 +\n2 + 3\n")
+ var out bytes.Buffer
+
+ Run(in, &out)
+
+ got := out.String()
+ if !strings.Contains(got, "error:") {
+ t.Errorf("expected output to contain 'error:', got %q", got)
+ }
+ if !strings.Contains(got, "5") {
+ t.Errorf("expected output to contain '5' after error recovery, got %q", got)
+ }
+}
+
+func TestRun_DivisionByZero(t *testing.T) {
+ in := strings.NewReader("1 / 0\n")
+ var out bytes.Buffer
+
+ Run(in, &out)
+
+ got := out.String()
+ if !strings.Contains(got, "error:") {
+ t.Errorf("expected output to contain 'error:', got %q", got)
+ }
+ if !strings.Contains(got, "division by zero") {
+ t.Errorf("expected 'division by zero' in output, got %q", got)
+ }
+}
+
+func TestRun_EmptyLine(t *testing.T) {
+ // Empty lines should be skipped, not cause errors.
+ in := strings.NewReader("\n1 + 1\n")
+ var out bytes.Buffer
+
+ Run(in, &out)
+
+ got := out.String()
+ if !strings.Contains(got, "2") {
+ t.Errorf("expected output to contain '2', got %q", got)
+ }
+ // Should not contain any error.
+ if strings.Contains(got, "error:") {
+ t.Errorf("empty line should not produce error, got %q", got)
+ }
+}
+
+func TestRun_Prompt(t *testing.T) {
+ in := strings.NewReader("42\n")
+ var out bytes.Buffer
+
+ Run(in, &out)
+
+ got := out.String()
+ if !strings.Contains(got, ">> ") {
+ t.Errorf("expected prompt '>> ' in output, got %q", got)
+ }
+}
+
+func TestRun_FloatResult(t *testing.T) {
+ in := strings.NewReader("7 / 2\n")
+ var out bytes.Buffer
+
+ Run(in, &out)
+
+ got := out.String()
+ if !strings.Contains(got, "3.5") {
+ t.Errorf("expected output to contain '3.5', got %q", got)
+ }
+}
+
+func TestRun_InvalidCharacter(t *testing.T) {
+ in := strings.NewReader("1 @ 2\n")
+ var out bytes.Buffer
+
+ Run(in, &out)
+
+ got := out.String()
+ if !strings.Contains(got, "error:") {
+ t.Errorf("expected output to contain 'error:', got %q", got)
+ }
+}
+
+func TestRun_EmptyInput(t *testing.T) {
+ // No input at all — just EOF.
+ in := strings.NewReader("")
+ var out bytes.Buffer
+
+ Run(in, &out)
+
+ got := out.String()
+ // Should just show the prompt and exit gracefully.
+ if !strings.Contains(got, ">> ") {
+ t.Errorf("expected at least one prompt, got %q", got)
+ }
+}
+
+func TestRun_WholeIntegerNoTrailingZeros(t *testing.T) {
+ // 2 + 3 = 5, should print "5" not "5.000000".
+ in := strings.NewReader("2 + 3\n")
+ var out bytes.Buffer
+
+ Run(in, &out)
+
+ got := out.String()
+ // Result line is "5\n" (between prompts).
+ if !strings.Contains(got, "5\n") {
+ t.Errorf("expected '5\\n' in output, got %q", got)
+ }
+ // Should not contain "5.0"
+ if strings.Contains(got, "5.0") {
+ t.Errorf("expected no trailing zeros, got %q", got)
+ }
+}