From 67d04283196dcbff59d1eb343e4fc949c329a695 Mon Sep 17 00:00:00 2001 From: Paul Buetow Date: Tue, 24 Mar 2026 22:36:18 +0200 Subject: feat: Add RPN mode, rational number support, and improve REPL - Add RPN (Reverse Polish Notation) calculator with stack-based operations - Support precise rational number calculations using *big.Rat - Implement chain of responsibility pattern for command handling - Add auto-completion for built-in commands - Add history persistence with configurable max entries - Support standard operators: +, -, *, /, ^, %, lg, log, ln - Support hyper operators: [+], [-], [*], [/], [^], [%], [lg], [log], [ln] - Support stack manipulation: dup, swap, pop, show - Support variable assignments and management - Add rat mode for switching between float64 and rational calculations - Refactor calculator to return Calculation struct with formatting - Add proper version support (v0.3.0) All changes follow Go best practices with comprehensive test coverage. --- cmd/gt/main.go | 89 +++++++++++++++++++++++++++ cmd/gt/main_test.go | 173 ++++++++++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 262 insertions(+) create mode 100644 cmd/gt/main.go create mode 100644 cmd/gt/main_test.go (limited to 'cmd') diff --git a/cmd/gt/main.go b/cmd/gt/main.go new file mode 100644 index 0000000..795dbe9 --- /dev/null +++ b/cmd/gt/main.go @@ -0,0 +1,89 @@ +package main + +import ( + "fmt" + "os" + "strings" + + "codeberg.org/snonux/perc/internal" + "codeberg.org/snonux/perc/internal/calculator" + "codeberg.org/snonux/perc/internal/repl" + "codeberg.org/snonux/perc/internal/rpn" + "github.com/mattn/go-isatty" +) + +func main() { + output, err := runCommand(os.Args) + if err != nil { + fmt.Println("Error:", err) + os.Exit(1) + } + fmt.Println(output) +} + +func runCommand(args []string) (string, error) { + if len(args) < 2 { + // No args provided - check if stdin is a TTY for REPL mode + if isatty.IsTerminal(os.Stdin.Fd()) { + if err := runREPL(); err != nil { + return "", err + } + return "", nil + } + printUsage() + return "", fmt.Errorf("no input provided") + } + + if args[1] == "version" { + return internal.Version, nil + } + + input := strings.Join(args[1:], " ") + + // Try RPN parsing first (for bare RPN expressions like "3 4 +") + rpnResult, rpnErr := runRPN(input) + if rpnErr == nil { + return rpnResult, nil + } + + // Fall back to percentage calculation + result, err := calculator.Parse(input) + if err != nil { + return "", fmt.Errorf("rpn fallback failed for input %q: %w", input, err) + } + + return result, nil +} + +// runREPL runs the REPL and handles errors +func runREPL() error { + if err := repl.RunREPL(); err != nil { + return fmt.Errorf("REPL error: %w", err) + } + return nil +} + +// runRPN parses and evaluates an RPN expression +func runRPN(input string) (string, error) { + vars := rpn.NewVariables() + rpnCalc := rpn.NewRPN(vars) + return rpnCalc.ParseAndEvaluate(input) +} + +func printUsage() { + fmt.Println("Usage: gt ") + fmt.Println(" gt version") + fmt.Println("\nPercentage calculator examples:") + fmt.Println(" gt 20% of 150") + fmt.Println(" gt what is 20% of 150") + fmt.Println(" gt 30 is what % of 150") + fmt.Println(" gt 30 is 20% of what") + fmt.Println("\nRPN (postfix notation) examples:") + fmt.Println(" gt 3 4 +") + fmt.Println(" gt 3 4 + 4 4 - *") + fmt.Println(" gt x 5 = x x +") + fmt.Println(" gt 2 3 ^") + fmt.Println(" gt dup swap pop show") + fmt.Println("\nStart REPL mode interactively by running without arguments:") + fmt.Println(" gt") +} diff --git a/cmd/gt/main_test.go b/cmd/gt/main_test.go new file mode 100644 index 0000000..df4d59f --- /dev/null +++ b/cmd/gt/main_test.go @@ -0,0 +1,173 @@ +package main + +import ( + "os" + "strings" + "testing" +) + +func TestRunCommandVersion(t *testing.T) { + args := []string{"gt", "version"} + result, err := runCommand(args) + if err != nil { + t.Fatalf("runCommand(['gt', 'version']) returned error: %v", err) + } + if result != "dev" && !strings.HasPrefix(result, "v") { + t.Errorf("runCommand(['gt', 'version']) = %q, expected version string", result) + } +} + +func TestRunCommandCalc(t *testing.T) { + // RPN expressions are now parsed directly without 'calc' prefix + args := []string{"gt", "3", "4", "+"} + result, err := runCommand(args) + if err != nil { + t.Fatalf("runCommand(['gt', '3', '4', '+']) returned error: %v", err) + } + if result != "7" { + t.Errorf("runCommand(['gt', '3', '4', '+']) = %q, want '7'", result) + } +} + +func TestRunCommandRPN(t *testing.T) { + // RPN expressions are now parsed directly without 'rpn' prefix + args := []string{"gt", "3", "4", "+"} + result, err := runCommand(args) + if err != nil { + t.Fatalf("runCommand(['gt', '3', '4', '+']) returned error: %v", err) + } + if result != "7" { + t.Errorf("runCommand(['gt', '3', '4', '+']) = %q, want '7'", result) + } +} + +func TestRunCommandRPNWithAssignment(t *testing.T) { + // RPN expressions with assignment are now parsed directly + args := []string{"gt", "x", "5", "=", "x", "x", "+"} + result, err := runCommand(args) + if err != nil { + t.Fatalf("runCommand with assignment returned error: %v", err) + } + if result != "10" { + t.Errorf("runCommand with assignment = %q, want '10'", result) + } +} + +func TestRunCommandPercentage(t *testing.T) { + args := []string{"gt", "20% of 150"} + result, err := runCommand(args) + if err != nil { + t.Fatalf("runCommand(['gt', '20%% of 150']) returned error: %v", err) + } + if !strings.Contains(result, "30") { + t.Errorf("runCommand(['gt', '20%% of 150']) = %q, should contain '30'", result) + } +} + +func TestRunCommandInvalidRPN(t *testing.T) { + args := []string{"gt", "5", "0", "/"} + _, err := runCommand(args) + if err == nil { + t.Error("runCommand(['gt', '5', '0', '/']) should return error for division by zero") + } +} + +func TestRunCommandUnknownToken(t *testing.T) { + // Unknown token in RPN expression should fail + args := []string{"gt", "unknown"} + _, err := runCommand(args) + if err == nil { + t.Error("runCommand(['gt', 'unknown']) should return error") + } +} + +func TestPrintUsage(t *testing.T) { + // Just verify the function doesn't panic + // We can't easily test the output since it goes to stdout + printUsage() +} + +func TestRunCommandUnknownInput(t *testing.T) { + // Unknown input should fail + args := []string{"gt", "unknown 3 4 +"} + _, err := runCommand(args) + if err == nil { + t.Error("runCommand with unknown input should return error") + } +} + +func TestMain(t *testing.T) { + // Save original os.Args + oldArgs := os.Args + defer func() { os.Args = oldArgs }() + + // Test with version command + os.Args = []string{"gt", "version"} + // Note: we can't actually call main() in tests because it calls os.Exit() + // Instead we test via runCommand which is what main() calls + result, err := runCommand(os.Args) + if err != nil { + t.Fatalf("runCommand(['gt', 'version']) returned error: %v", err) + } + if result != "dev" && !strings.HasPrefix(result, "v") { + t.Errorf("runCommand(['gt', 'version']) = %q, expected version string", result) + } +} + +func TestRunCommandCalcChain(t *testing.T) { + // RPN expression chain without 'calc' prefix + args := []string{"gt", "3", "4", "+", "4", "4", "-", "*"} + result, err := runCommand(args) + if err != nil { + t.Fatalf("runCommand with chain returned error: %v", err) + } + if result != "0" { + t.Errorf("runCommand with chain = %q, want '0'", result) + } +} + +func TestRunCommandRPNPower(t *testing.T) { + // RPN power without 'rpn' prefix + args := []string{"gt", "2", "3", "^"} + result, err := runCommand(args) + if err != nil { + t.Fatalf("runCommand with power returned error: %v", err) + } + if result != "8" { + t.Errorf("runCommand with power = %q, want '8'", result) + } +} + +func TestRunCommandRPNModulo(t *testing.T) { + // RPN modulo without 'rpn' prefix + args := []string{"gt", "10", "3", "%"} + result, err := runCommand(args) + if err != nil { + t.Fatalf("runCommand with modulo returned error: %v", err) + } + if result != "1" { + t.Errorf("runCommand with modulo = %q, want '1'", result) + } +} + +func TestRunCommandNoArgs(t *testing.T) { + // Test with no arguments (simulating stdin not being TTY) + args := []string{"gt"} + _, err := runCommand(args) + if err == nil { + t.Error("runCommand with no args should return error") + } + if !strings.Contains(err.Error(), "no input provided") { + t.Errorf("Error = %v, should contain 'no input provided'", err) + } +} + +// The following tests were removed because they tested subcommand handling +// which has been removed: +// - TestRunCommandRepl (repl command) +// - TestRunCommandReplFlag (--repl flag) +// - TestRunCommandCalcWithShow (calc with show) +// - TestRunCommandCalcWithVars (calc with vars) +// - TestRunCommandCalcWithClear (calc with clear) + +// These commands are now only available in REPL mode, not in command-line mode. -- cgit v1.2.3