summaryrefslogtreecommitdiff
path: root/cmd
diff options
context:
space:
mode:
authorPaul Buetow <paul@buetow.org>2026-03-24 22:36:18 +0200
committerPaul Buetow <paul@buetow.org>2026-03-24 22:36:18 +0200
commit67d04283196dcbff59d1eb343e4fc949c329a695 (patch)
tree7b20b1b0c6b60620fe8ce804a01104bdafc1d8e7 /cmd
parent76cb9d6f40b9d1bd6cd18fd1a0ecdb50bbd12e81 (diff)
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.
Diffstat (limited to 'cmd')
-rw-r--r--cmd/gt/main.go89
-rw-r--r--cmd/gt/main_test.go173
2 files changed, 262 insertions, 0 deletions
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 <calculation>")
+ 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.