diff options
| author | Paul Buetow <paul@buetow.org> | 2026-03-24 22:36:18 +0200 |
|---|---|---|
| committer | Paul Buetow <paul@buetow.org> | 2026-03-24 22:36:18 +0200 |
| commit | 67d04283196dcbff59d1eb343e4fc949c329a695 (patch) | |
| tree | 7b20b1b0c6b60620fe8ce804a01104bdafc1d8e7 /internal/repl/repl.go | |
| parent | 76cb9d6f40b9d1bd6cd18fd1a0ecdb50bbd12e81 (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 'internal/repl/repl.go')
| -rw-r--r-- | internal/repl/repl.go | 380 |
1 files changed, 169 insertions, 211 deletions
diff --git a/internal/repl/repl.go b/internal/repl/repl.go index ec5f245..da264e1 100644 --- a/internal/repl/repl.go +++ b/internal/repl/repl.go @@ -1,108 +1,91 @@ package repl import ( - "bufio" "fmt" - "os" - "os/signal" - "path/filepath" "strings" "sync" - "syscall" - "codeberg.org/snonux/perc/internal/calculator" "codeberg.org/snonux/perc/internal/rpn" - "github.com/mattn/go-isatty" "github.com/c-bata/go-prompt" ) -const historyFile = ".gt_history" - -// RPNState holds the state for RPN operations in REPL -// Note: This struct should never be copied - use pointer receivers only -type RPNState struct { - vars rpn.VariableStore - rpnCalc *rpn.RPN +// REPL manages the interactive command-line interface. +type REPL struct { + ttyChecker *TTYChecker + historyMgr *HistoryManager + signalHandler *SignalHandler + prompt *prompt.Prompt + commandChain CommandHandler } -// rpnStateMu protects rpnState -// Note: The mutex must NOT be copied - keep it as a top-level variable -var rpnStateMu sync.RWMutex - -// rpnState holds the singleton RPN state for REPL operations -var rpnState *RPNState - -// getRPNState returns or creates the RPN state -// Thread-safe implementation with double-checked locking pattern -func getRPNState() *RPNState { - // First check with read lock for performance - rpnStateMu.RLock() - if rpnState != nil { - state := rpnState - rpnStateMu.RUnlock() - return state +// NewREPL creates a new REPL instance with default components. +// If executor is nil, it uses a default executor. +// If completer is nil, it uses a default completer. +func NewREPL(executor func(string), completer func(prompt.Document) []prompt.Suggest) *REPL { + repl := &REPL{ + ttyChecker: &TTYChecker{}, + historyMgr: NewHistoryManager(".gt_history"), + signalHandler: NewSignalHandler(), + commandChain: NewCommandChain(), + } + + // Set up executor - if nil, use default + execFn := executor + if execFn == nil { + execFn = func(input string) { + defaultExecutor(repl, input) + } } - rpnStateMu.RUnlock() - // Need to create - use write lock - rpnStateMu.Lock() - defer rpnStateMu.Unlock() - if rpnState == nil { - vars := rpn.NewVariables() - rpnState = &RPNState{ - vars: vars, - rpnCalc: rpn.NewRPN(vars), + // Set up completer - if nil, use default + completerFn := completer + if completerFn == nil { + completerFn = func(d prompt.Document) []prompt.Suggest { + return defaultCompleter(repl, d) } } - return rpnState + + // Load history from file + history := repl.historyMgr.Load() + + // Build the prompt + repl.prompt = NewPromptBuilder(). + SetTitle("gt - Percentage Calculator"). + SetPrefix("> "). + SetLivePrefix(func() (string, bool) { return "> ", true }). + SetExecutor(execFn). + SetCompleter(completerFn). + SetHistory(history). + Build() + + return repl } -// RunREPL starts the interactive REPL -func RunREPL() error { +// Run starts the REPL and blocks until it exits. +func (r *REPL) Run() error { // Check if stdin is a TTY - if !isatty.IsTerminal(os.Stdin.Fd()) { - fmt.Fprintln(os.Stderr, "REPL mode requires a TTY. Use 'gt <calculation>' for non-interactive mode.") - return fmt.Errorf("stdin is not a TTY") + if err := r.ttyChecker.EnsureTTY(); err != nil { + return err } - history := loadHistory() - - p := prompt.New( - executor, - completer, - prompt.OptionTitle("gt - Percentage Calculator"), - prompt.OptionPrefix("> "), - prompt.OptionLivePrefix(func() (string, bool) { - return "> ", true - }), - prompt.OptionHistory(history), - ) - - // Handle SIGINT gracefully - sigChan := make(chan os.Signal, 1) - signal.Notify(sigChan, syscall.SIGINT) - - go func() { - <-sigChan + // Start signal handler + r.signalHandler.Start(func() { fmt.Println("\nUse 'quit' or 'exit' to exit, or Ctrl+D") - }() + }) // Run the prompt - p.Run() - - // Note: History is not saved automatically in this version - // The prompt library stores it in memory but doesn't expose a getter + r.prompt.Run() return nil } -// executor runs a calculation command and returns the result -func executor(input string) { +// defaultExecutor is the default executor function. +func defaultExecutor(r *REPL, input string) { // Add panic recovery for better resilience defer func() { - if r := recover(); r != nil { - fmt.Printf("Error: Unexpected error occurred: %v\n", r) + if rec := recover(); rec != nil { + fmt.Printf("Error: Unexpected error occurred: %v\n", rec) fmt.Println("Please try a different expression or command.") } }() @@ -112,181 +95,156 @@ func executor(input string) { return } - // Check if it's a built-in command - if cmd, ok := isBuiltinCommand(input); ok { - output, err := ExecuteCommand(cmd) + // Use chain of responsibility pattern to handle the command + output, handled, err := r.commandChain.Handle(r, input) + + if handled { if err != nil { fmt.Printf("Error: %v\n", err) } if output != "" { fmt.Println(output) } - // Don't add built-in commands to history + // Don't add handled commands to history return } - // Check for rpn command prefix - if strings.HasPrefix(strings.ToLower(input), "rpn ") || strings.HasPrefix(strings.ToLower(input), "calc ") { - // Extract the expression after rpn/calc - rest := strings.TrimSpace(strings.TrimPrefix(input, strings.SplitN(input, " ", 2)[0])) - result, err := runRPN(rest) - if err != nil { - fmt.Printf("Error: %v\n", err) - return - } - fmt.Println(result) - return + // Not handled by any handler in the chain + if err != nil { + fmt.Printf("Error: %v\n", err) } +} - // Try RPN parsing first (for bare RPN expressions like "3 4 +") - rpnResult, rpnErr := runRPN(input) - if rpnErr == nil { - // Valid RPN expression - print result - fmt.Println(rpnResult) - return +// defaultCompleter is the default completer function. +func defaultCompleter(r *REPL, d prompt.Document) []prompt.Suggest { + text := d.GetWordBeforeCursor() + if text == "" { + return nil } - // Try evaluating as a single operator on the current RPN stack - // This allows incremental operations like: "1 2 +" then "+" - state := getRPNState() - fields := strings.Fields(input) - if len(fields) == 1 { - op := strings.ToLower(fields[0]) - // Check if it's a valid operator - switch op { - case "+", "-", "*", "/", "^", "%", "dup", "swap", "pop", "show", "clear", "vars": - result, err := state.rpnCalc.EvalOperator(op) - if err != nil { - fmt.Printf("Error: %v\n", err) - } else { - fmt.Println(result) - } - return + var suggestions []prompt.Suggest + for _, cmd := range builtinCommands() { + if strings.HasPrefix(strings.ToLower(cmd), strings.ToLower(text)) { + suggestions = append(suggestions, prompt.Suggest{ + Text: cmd, + Description: r.defaultGetCommandDescription(cmd), + }) } } + return suggestions +} - // Run the percentage calculation - result, err := calculator.Parse(input) - if err != nil { - fmt.Printf("Error: %v\n", err) - return +// defaultGetCommandDescription returns the description for a command. +func (r *REPL) defaultGetCommandDescription(cmd string) string { + descriptions := map[string]string{ + "help": "Show help information", + "clear": "Clear the screen", + "quit": "Exit the REPL", + "exit": "Exit the REPL", + "rpn": "Evaluate an RPN (postfix notation) expression", + "calc": "Same as rpn - evaluate an RPN expression", } - fmt.Println(result) + return descriptions[cmd] +} + +// RunREPL starts the interactive REPL. +// This is a convenience wrapper around NewREPL().Run(). +func RunREPL() error { + repl := NewREPL(nil, nil) + return repl.Run() +} + +// executor runs a calculation command and returns the result. +// This is a package-level wrapper for backward compatibility. +// It creates a minimal REPL instance without a prompt for testing purposes. +func executor(input string) { + // Create a minimal REPL instance without building a prompt + r := &REPL{ + ttyChecker: &TTYChecker{}, + historyMgr: NewHistoryManager(".gt_history"), + signalHandler: NewSignalHandler(), + commandChain: NewCommandChain(), + } + defaultExecutor(r, input) +} + +// RPNState holds the state for RPN operations in REPL +// Note: This struct should never be copied - use pointer receivers only +type RPNState struct { + vars rpn.VariableStore + rpnCalc *rpn.RPN +} + +// rpnState holds the singleton RPN state for REPL operations +var rpnState *RPNState + +// rpnStateOnce ensures rpnState is initialized exactly once +var rpnStateOnce sync.Once + +// getRPNState returns or creates the RPN state +// Thread-safe implementation using sync.Once for simpler singleton initialization +func getRPNState() *RPNState { + rpnStateOnce.Do(func() { + vars := rpn.NewVariables() + rpnState = &RPNState{ + vars: vars, + rpnCalc: rpn.NewRPN(vars), + } + }) + return rpnState } -// runRPN parses and evaluates an RPN expression +// getRPNState returns the RPN state. +// This is a REPL instance method for backward compatibility. +func (r *REPL) getRPNState() *RPNState { + return getRPNState() +} + +// runRPN parses and evaluates an RPN expression. func runRPN(input string) (string, error) { state := getRPNState() return state.rpnCalc.ParseAndEvaluate(input) } -// isBuiltinCommand checks if input starts with a built-in command -func isBuiltinCommand(input string) (string, bool) { - args := strings.Fields(input) - if len(args) == 0 { - return "", false - } - - cmd := strings.ToLower(args[0]) - for _, builtin := range builtinCommands() { - if cmd == builtin { - return input, true - } - } - return "", false +// runRPN parses and evaluates an RPN expression. +// This is a REPL instance method for backward compatibility. +func (r *REPL) runRPN(input string) (string, error) { + return runRPN(input) } -// getHistoryPath returns the path to the history file +// getHistoryPath returns the path to the history file. +// This is a package-level wrapper for backward compatibility. func getHistoryPath() string { - home, err := os.UserHomeDir() - if err != nil { - return "" - } - return filepath.Join(home, historyFile) + historyMgr := NewHistoryManager(".gt_history") + return historyMgr.Path() } -// loadHistory loads history from file +// loadHistory loads history from file. +// This is a package-level wrapper for backward compatibility. func loadHistory() []string { - historyPath := getHistoryPath() - if historyPath == "" { - return nil - } - - file, err := os.Open(historyPath) - if err != nil { - return nil - } - - var history []string - scanner := bufio.NewScanner(file) - for scanner.Scan() { - history = append(history, scanner.Text()) - } - if err := file.Close(); err != nil { - return nil - } - return history + historyMgr := NewHistoryManager(".gt_history") + return historyMgr.Load() } -// saveHistory saves history to file +// saveHistory saves history to file. +// This is a package-level wrapper for backward compatibility. func saveHistory(history []string) error { - historyPath := getHistoryPath() - if historyPath == "" { - return nil - } - - // Keep only last 1000 entries to prevent unlimited growth - if len(history) > 1000 { - history = history[len(history)-1000:] - } - - file, err := os.Create(historyPath) - if err != nil { - return err - } - defer func() { - if closeErr := file.Close(); closeErr != nil { - // Log error but don't overwrite the original error - _ = fmt.Errorf("warning: failed to close history file: %w", closeErr) - } - }() - - writer := bufio.NewWriter(file) - for _, entry := range history { - if _, err := writer.WriteString(entry + "\n"); err != nil { - return fmt.Errorf("failed to write history entry: %w", err) - } - } - if err := writer.Flush(); err != nil { - return fmt.Errorf("failed to flush history writer: %w", err) - } - return nil + historyMgr := NewHistoryManager(".gt_history") + return historyMgr.Save(history) } -// completer provides auto-completion for built-in commands -func completer(d prompt.Document) []prompt.Suggest { - text := d.GetWordBeforeCursor() - if text == "" { - return nil +// isBuiltinCommand checks if input starts with a built-in command +func isBuiltinCommand(input string) (string, bool) { + args := strings.Fields(input) + if len(args) == 0 { + return "", false } - var suggestions []prompt.Suggest - for _, cmd := range builtinCommands() { - if strings.HasPrefix(strings.ToLower(cmd), strings.ToLower(text)) { - suggestions = append(suggestions, prompt.Suggest{Text: cmd, Description: getCommandDescription(cmd)}) + cmd := strings.ToLower(args[0]) + for _, builtin := range builtinCommands() { + if cmd == builtin { + return input, true } } - return suggestions -} - -func getCommandDescription(cmd string) string { - descriptions := map[string]string{ - "help": "Show help information", - "clear": "Clear the screen", - "quit": "Exit the REPL", - "exit": "Exit the REPL", - "rpn": "Evaluate an RPN (postfix notation) expression", - "calc": "Same as rpn - evaluate an RPN expression", - } - return descriptions[cmd] + return "", false } |
